Custom domains

This guide serves an app from a domain you own, such as shop.example.com. The CLI enrolls the domain and prints the DNS records to publish. The domain serves the app after the records are published and a check finds the domain and its certificate active.

The commands use production. district login --staging records staging as the current environment, and every later command that takes no environment flag uses it. Each command reads the clone in the current directory, or takes a trailing positional repository argument from outside a clone.

What you need

  • A domain at a DNS provider, and access to publish records in its zone. The zone of shop.example.com is example.com.
  • An app with a durable target. district app create publishes a repository as an app with a primary target, and district target create adds another one. A repository with no app is a usage error that names district app create.
  • An available custom-domain slot. An app holds at most 2 custom domains. Each domain is attached to one durable target of the app and serves the deployment that target holds. A custom domain cannot serve a preview target because preview targets expire.

Add the domain

district domain add attaches the domain to one durable target of the app and enrolls it. It prints the target the domain serves, the DNS records the owner must publish, and the command that checks them.

$ district domain add shop.example.com
added shop.example.com (hdom_01K4…)
status ○ pending
target shopfront

Publish these DNS records, then run: district domain verify shop.example.com
TYPE   HOST                  VALUE                                                    PURPOSE
CNAME  shop                  hosting.ondistrict.net                                   routing
TXT    _district-verify      district-verify=7d3f9c2e-8a41-4b6e-9f0c-1d2e3f4a5b6c     ownership

Optional. Publish it to issue the certificate before you change the routing record:
CNAME  _acme-challenge.shop  a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d.dcv.cloudflare.com  dcv-delegation

Each HOST is relative to example.com. If your provider asks for the full name, add .example.com to it.
The TXT record at the zone apex covers every hostname under it in this workspace.
If you cannot edit the zone apex, publish the same record at the hostname or at any level between.

Without --target, a terminal asks which app target serves the domain:

Select the target for shop.example.com:
  1) shopfront (shopfront.ondistrict.net) [primary]
  2) next (next.ondistrict.net)
  3) Create a new subdomain
Enter a number from 1 to 3, or press Enter for shopfront:

An empty answer selects the primary target. An app with no primary target has no default. The last entry creates a target. The command asks for its label, offers the first label of the domain, and attaches the domain to the new target. Three answers that select nothing end the command, and the error names --target.

--target <label> names the target and skips the question. The label must be a durable target of the app. A label that names no durable target is a usage error that lists the durable labels, and the command enrolls nothing. A session with no terminal does not send a target. The API attaches the domain to the app's primary target and refuses an app with no primary target. Its message names --target.

The API refuses a request if another app holds the domain, the app already holds 2 domains, or the domain is inside a platform-owned zone. It also refuses a caller who cannot update the app. It uses its own message for each refusal. --json prints the domain, with dnsRecords, as one JSON object.

A repeat for a domain the app already holds asks for no target. It prints that domain and the records that apply at that time, and says which target the domain keeps.

Publish the DNS records

Publish the two required records at the DNS provider for the zone. Each HOST is relative to the zone. Most provider forms append the zone. A form that asks for the full name takes the host with .example.com after it, so shop becomes shop.example.com.

The routing record sends the domain's traffic to District. The ownership record is a TXT at _district-verify on the zone apex. Its value holds the workspace's token, so one record covers every hostname the workspace adds under that zone. If you cannot edit the zone apex, publish the same record at the hostname or at any level between. The domain serves after a check finds both records.

The dcv-delegation record is optional. It lets District issue the certificate before the routing record changes. Without it, District proves control of the domain to the certificate authority over HTTP after the routing record resolves, and renews the certificate the same way.

For an apex domain such as example.com, the routing record's host is @, the form's name for the zone itself. A CNAME on the zone is not valid DNS, so that row reads CNAME* and a footnote names the record types a provider offers instead:

TYPE    HOST              VALUE                                                    PURPOSE
CNAME*  @                 hosting.ondistrict.net                                   routing
TXT     _district-verify  district-verify=7d3f9c2e-8a41-4b6e-9f0c-1d2e3f4a5b6c     ownership

Optional. Publish it to issue the certificate before you change the routing record:
CNAME   _acme-challenge   a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d.dcv.cloudflare.com  dcv-delegation

Each HOST is relative to example.com. If your provider asks for the full name, add .example.com to it.
The TXT record at the zone apex covers every hostname under it in this workspace.
If you cannot edit the zone apex, publish the same record at the hostname or at any level between.
* A root record cannot be a CNAME at most providers. Select ALIAS, ANAME or CNAME flattening for it.

A recently enrolled domain can return no delegation record while the provider's read catches up. The command then names district domain status, which prints the records again.

Check that the domain serves the app

district domain verify checks the domain now instead of waiting for District's scheduled check. It prints the domain after the check and exits 0 only when the status is active, so a shell loop can wait for it:

$ until district domain verify shop.example.com; do sleep 30; done
hostname      shop.example.com (hdom_01K4…)
target        shopfront
status        ○ pending
verified      no
cert expires  -
added         2026-08-25T09:00:00Z

If you published the records just now, wait a few minutes and run this command again. Automatic checks also run
while the domain is pending, less often as time passes. Read the records with: district domain status shop.example.com
shop.example.com is pending, not active
hostname      shop.example.com (hdom_01K4…)
target        shopfront
status        ✔ active
verified      yes
cert expires  2026-12-01T00:00:00Z
added         2026-08-25T09:00:00Z

While the domain is not active, the command prints a note above the verdict: published records take minutes to propagate, District's checks run while the domain is pending, and district domain status prints the records.

One check of a domain runs at a time. For two minutes after a check starts, another call does not start a second check. It prints the domain as it stands. --json prints the domain as one JSON object, without the note, and uses the command's exit code.

Each district domain command exits 3 when the certificate provider does not answer. The domain is unchanged. Run the command again after the number of seconds in the message.

Check the state later

district domain list prints one row per domain: the host, the label of the target it serves, the status, whether it serves the app, the certificate expiry, and when it was added. - stands for a certificate that is not issued.

$ district domain list
HOSTNAME          TARGET     STATUS     VERIFIED  CERT EXPIRES          ADDED
shop.example.com  shopfront  ✔ active   yes       2026-12-01T00:00:00Z  2026-08-25T09:00:00Z
www.example.com   next       ○ pending  no        -                     2026-08-26T09:00:00Z

A domain holds one of four statuses:

  • pending: the platform waits for the records or the certificate.
  • active: the domain serves the app.
  • failing: the domain serves the app, and checks fail.
  • detached: the platform no longer holds the enrollment.

For an app with no custom domain, the command prints nothing and exits 0. district domain status <hostname> reports one domain: its state, the target it serves, the certificate expiry, when it was added, and the same DNS records that district domain add printed.

Serve a domain from another target

A custom domain serves the deployment its target holds, so a domain on a target that is not primary serves what that target serves. district domain add shop.example.com --target next attaches the domain to the next target, and district deploy --target next then changes what the domain serves, without touching the primary target.

A repeat does not move a domain: --target with another label is a usage error that names district domain remove. To move a domain, remove it, then add it again with the other label.

district target delete refuses a target that custom domains serve, and its message names those domains. Remove each domain first.

Remove a domain

district domain remove removes the domain from the app, and the domain then stops serving the app. The command prints the resolved domain and the environment, then asks Remove <hostname>? [y/N]. --yes skips the question, and a session with no terminal needs it.

The owner's DNS records are not changed. Remove them at the DNS provider separately.