---
title: Publishing a site from the command line
date: 2026-09-17
description: A site on Highport is a record in your PDS. Here is the whole publication, start to finish, without opening a browser.
rkey: 3mvqm3xvwftbq
updated: 2026-09-19
---

You don't need to use the [hub](https://hub.highport.space/) to launch a website with Highport, because a website is just a `space.highport.sites.site` record in your PDS. The site record is a simple manifest of the content that is served, which means you have full control and ownership of your website's data, and updating that data is really easy.

This is short guide goes step by step through the process using [atproto.camp](https://atproto.camp) as an example. Each step shows what to do with both [`goat`](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) and [`atpxrpc`](https://tangled.org/ngerakines.me/atproto-crates).

## DNS

Highport has domains that you can use to get a site like `example.sites.highport.space` without need your own domain, but in this guide we're using `atproto.camp`, so you'll need to be able to add records to your domain's DNS.

**Don't create the DNS records yet.** You'll add two of them in step 4, and one of them can't be known until step 3: registering the domain gives it an address of its own to point at, and that address doesn't exist until you register.

## Step 1 - Tooling Prep

Add credentials to your tool of choice. This is needed to create records, upload blobs, and create service authentication tokens for XRPC calls.

```sh
atpxrpc login atproto.camp <app-password>
```

```sh
goat account login -u atproto.camp -p <app-password>
```

## Step 2 — Accept Policies

To use Highport, you first need to accept the policies that can be found on https://policies.highport.space/. Proof that you accept them is a record that lives in your PDS as a `space.highport.policy` record that shows which version you've accepted. Every `space.highport.manage.*` XRPC call is refused until it is there, so this comes first.

The https://policies.highport.space/ is powered by a site record and it's AT-URI and CID are used directly in the policy record.


First, get the AT-URI and CID of the current policy set:

```sh
curl -s https://policies.highport.space/_bard/site.json | jq '{uri, cid: .recordCid}'
```

```json
{
  "uri": "at://did:plc:tiiwv2qb3qxbh7thz5w7k5tb/space.highport.sites.site/policies.highport.space",
  "cid": "bafyreiavnjji3rgbwoiggyn3mdlxxp52zdtk6h32zeshr6jdk2xoqtxot4"
}
```

Then, create a record using that AT-URI and CID. Acceptance is based on the content identifier, not just the URI, so everything must match exactly. Build a policy record to publish to your PDS from these values:

```sh
curl -s https://policies.highport.space/_bard/site.json | jq \
  '{"$type": "space.highport.policy",
    subject: {"$type": "com.atproto.repo.strongRef", uri: .uri, cid: .recordCid},
    createdAt: (now | todate)}' > policy.json
```

Write the policy record with the `hub.highport.space` record key:

```sh
jq -n --slurpfile r policy.json \
  '{repo: "atproto.camp", collection: "space.highport.policy",
    rkey: "hub.highport.space", record: $r[0]}' \
  | atpxrpc --handle atproto.camp com.atproto.repo.putRecord
```

```sh
goat record create --rkey hub.highport.space policy.json
```

**Note:** The record key must be `hub.highport.space`, it is not decorative. It names the deployment you are accepting. A record under any other key still publishes cleanly and still validates — it is simply never consulted, and every `space.highport.manage.*` call keeps failing with `PolicyNotAccepted`. There is no error at write time to tell you, so check the key.

## Step 3 — Register

Next, invoke the `space.highport.manage.register` XRPC method to tell Highport that it should serve content for this domain. The only parameter is the `domain` and it uses the service authentication token to identify the caller and prove the authenticity of the API call.

```sh
echo '{"domain":"atproto.camp"}' \
  | atpxrpc proxy did:web:highport.space#bard_control \
      space.highport.manage.register --handle atproto.camp
```

```sh
goat xrpc procedure did:web:highport.space#bard_control \
  space.highport.manage.register domain=atproto.camp
```

```json
{
  "domain": "atproto.camp",
  "status": "pending",
  "records": [
    {
      "type": "TXT",
      "name": "_bard.atproto.camp",
      "value": "did=did:plc:puy52u7opoy3gvrv7h7qdy76",
      "ttl": 300,
      "purpose": "ownership",
      "required": true
    },
    {
      "type": "ALIAS",
      "name": "atproto.camp",
      "value": "t-ia6pbbccadzv2.highport-dns.net",
      "ttl": 300,
      "purpose": "traffic",
      "required": true
    }
  ],
  "expiresAt": "2026-09-24T21:00:45.932Z"
}
```

Registering claims the domain for your identity, and the next step proves the claim. It does not yet serve anything, and an unproven claim expires after a week.

The traffic record's value, `t-ia6pbbccadzv2.highport-dns.net` here, is made up when you register and belongs to this domain alone. Yours will be different. It's what lets Highport move one site, to another region or away from trouble, without asking you to change your DNS again. If you ask `space.highport.manage.getDomain` later, it hands back the same two records.

## Step 4 - DNS Updates

Now create the DNS records, using the `records` array that `register` just returned. Copy the values from your response, not from this page. For `atproto.camp` they were:

* The TXT record `_bard.atproto.camp` with the payload `did=did:plc:puy52u7opoy3gvrv7h7qdy76`. This is used to prove ownership of the domain.
* The ALIAS record `atproto.camp` pointing to `t-ia6pbbccadzv2.highport-dns.net`. A subdomain gets a plain `CNAME` where an apex gets an `ALIAS` (some providers call it `ANAME` or CNAME flattening), and Highport picks between them for you.

This step has to come after `register`, because the traffic record's value is only known once you've registered. Domains registered before these per-domain addresses existed were given `go.highport.space` instead, and it still works.

## Step 5 - Verification

A background process will continuously check the DNS records. It's important to do this as soon as your DNS is live, but not before then, because it will back off over time.

```sh
echo '{"domain":"atproto.camp"}' \
  | atpxrpc proxy did:web:highport.space#bard_control \
      space.highport.manage.verify --handle atproto.camp
```

```sh
goat xrpc procedure did:web:highport.space#bard_control \
  space.highport.manage.verify domain=atproto.camp
```

**Expect this to fail for a few minutes, and do not read anything into it.** Resolvers cache, including ours, and a name that just changed keeps answering with its old value for as long as the previous record's TTL and the zone's negative-caching window allow. If the ownership check reports that `_bard` did not answer, the usual cause is that nothing is wrong yet. Wait and ask again.

When all three checks pass, the domain is `verified` and Highport will serve a site at it. It becomes `active` once a site record has actually been indexed and a snapshot is serving — so on a first publication you will see `verified` here, and `active` only after step 6. The response below says `active` because it came from re-running `verify` on a domain that was already serving.

```json
{
  "status": "active",
  "checks": [
    {
      "name": "ownership",
      "passed": true,
      "checkedAt": "2026-09-18T00:06:44.877Z"
    },
    {
      "name": "traffic",
      "passed": true,
      "checkedAt": "2026-09-18T00:06:38.042Z"
    },
    {
      "name": "conflict",
      "passed": true,
      "checkedAt": "2026-09-18T00:06:44.877Z"
    }
  ]
}
```

## Step 6 - Site Content

Every file in your site is a blob in your repository. For atproto.camp there is just one `index.html` file:

```sh
cat index.html \
  | atpxrpc --handle atproto.camp --bytes --content-type text/html \
      com.atproto.repo.uploadBlob
```

```sh
goat blob upload index.html
```

```json
{
  "blob": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreiez5dty6x2ugwwqimoywyzs362qrbqge23an3jzqene6fcx5wvieu"
    },
    "mimeType": "text/html",
    "size": 10884
  }
}
```

Next, create the `space.highport.sites.site` record and use the `blob` from the upload as a `/` path mapping:

```json
{
  "$type": "space.highport.sites.site",
  "source": {
    "$type": "space.highport.sites.defs#manifest",
    "name": "atproto.camp",
    "resources": {
      "/": {
        "src": {
          "$type": "blob",
          "ref": {
            "$link": "bafkreiez5dty6x2ugwwqimoywyzs362qrbqge23an3jzqene6fcx5wvieu"
          },
          "mimeType": "text/html",
          "size": 10884
        },
        "contentType": "text/html; charset=utf-8"
      }
    }
  }
}
```

**Important**: The record key must be the hostname of the registered site. In this example the site is https://atproto.camp/, so the record key is `atproto.camp`.

```sh
jq -n --slurpfile r site.json \
  '{repo: "atproto.camp", collection: "space.highport.sites.site",
    rkey: "atproto.camp", record: $r[0]}' \
  | atpxrpc --handle atproto.camp com.atproto.repo.putRecord
```

```sh
goat record create --rkey atproto.camp site.json
```

You can also publish this record before the domain is verified. It will be held rather than lost, and picked up within seconds of verification succeeding.

**Publish the record soon after uploading the blob.** Your PDS will "stage" blobs that are uploaded for a period of time, but blobs that aren't linked to records will eventually be garbage collected.

## Step 7 - Wait

```sh
atpxrpc proxy did:web:highport.space#bard_control \
  space.highport.manage.getIndexStatus domain=atproto.camp \
  --handle atproto.camp
```

```sh
goat xrpc query did:web:highport.space#bard_control \
  space.highport.manage.getIndexStatus domain==atproto.camp
```

```json
{
  "state": "active",
  "rev": "3mvqkggujqi2c",
  "cid": "bafyreihnu44x467ptqc7qnqkxsqheqpgdb54vsbkxbm54dfxhsjamr2p6u",
  "entryCount": 22,
  "fetched": 22,
  "pending": 0,
  "failed": [],
  "errors": [],
  "activatedAt": "2026-09-11T01:02:47.188Z",
  "edge": {
    "status": "routed",
    "domain": "ngerakines.me",
    "checkedAt": "2026-09-18T00:22:03.321Z"
  }
}
```

Then the only check that really counts:

```sh
curl -sI https://atproto.camp/
```

```
HTTP/2 200
accept-ranges: bytes
cache-control: public, max-age=60
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'
content-type: text/html; charset=utf-8
date: Fri, 18 Sep 2026 00:22:36 GMT
etag: "bafkreiez5dty6x2ugwwqimoywyzs362qrbqge23an3jzqene6fcx5wvieu"
permissions-policy: interest-cohort=()
referrer-policy: strict-origin-when-cross-origin
x-bard-cid: bafyreihnu44x467ptqc7qnqkxsqheqpgdb54vsbkxbm54dfxhsjamr2p6u
x-bard-did: did:plc:puy52u7opoy3gvrv7h7qdy76
x-bard-outcome: hit
x-bard-rev: 3mvqkggujqi2c
x-content-type-options: nosniff
content-length: 10884
```

The `etag` is the content identifier of the blob you uploaded. The `x-bard-cid` is the content identifier of the record you published.

There is no certificate to request. Highport obtains one during the first TLS handshake for a name it has not served before, so by the time you can make that request, it has already happened.

## Pizza Party

And that's it! A few commands to create records and kick of verification, along with some DNS configuration. Running through this (albeit a little slowly) for `atproto.camp` took less than 10 minutes end to end, and half of that was waiting for caches to expire.
