DNS — pointing your instance at a real domain

DNS is not required to run Neon Law Navigator. web boots, serves the portal, and self-tests against nothing but Postgres and storage — the KIND loop and every worked example use your-domain.example, and navigator ops gcp setup provisions compute, data, and a static IP but never touches DNS. DNS is the last-mile of a public deploy: it makes the instance reachable at a real hostname and wires mail. This page is the recipe, and the tools you use to apply it.

DNS is also provider-agnostic. The record model lives behind the DnsProvider trait in cli/src/devx/dns.rs; DNSimple is the shipped implementation and the provider Neon Law runs, but the app never dials it at runtime, and a fork can point the same records at Cloud DNS or Route 53 without changing a line of application code. DNS sits above the env-var interface — see third-party-integrations.md.

The three jobs DNS does for a deploy

  1. Reach the instance. A www A record → the static gateway IP you reserved during provisioning.
  2. Redirect the apex to www. A DNSimple URL record 301s a bare neonlaw.com to https://www.neonlaw.com, so the instance only ever serves www (and workflows) — the apex never reaches your cluster.
  3. Wire mail — two independent lanes. Human mailboxes (Google Workspace) and application mail (SendGrid) coexist on one domain by living on different records: human mail on the apex MX, application inbound on a parse. subdomain.

Provision it with one command

navigator ops dns setup reconciles the whole record set through DNSimple. Every group is opt-in via a flag, so the same command serves a greenfield fork and Neon Law's live topology. It reads three environment variables — the first two provider-agnostic, the token DNSimple-specific:

export DNS_ZONE=your-domain.example        # the domain to configure (or pass --domain)
export DNS_ACCT=<your-account-id>          # DNSimple account id — see `dnsimple accounts list`
export DNSIMPLE_API_TOKEN=<token>          # secret

navigator ops dns setup \
  --gateway-ip <your-gateway-ip> \
  --redirect-apex-to-www \
  --google-workspace \
  --google-site-verification <token> \
  --sendgrid \
  --dkim-target <s1-target> \
  --dkim-target <s2-target> \
  --sendgrid-link-brand <label>=<target> \
  --spf-include amazonses.com \
  --dmarc none \
  --dry-run

Run it with --dry-run first to read every call; drop the flag to apply. Each flag maps to one record group:

GroupRecordsFlag
Reachabilitywww / workflows A → gateway IP--gateway-ip / --host
Apex→www redirectapex URL record → https://www.<zone>--redirect-apex-to-www
Human mailapex MX smtp.google.com + SPF _spf.google.com--google-workspace
Domain verificationapex google-site-verification TXT--google-site-verification
Application inboundparse. MX mx.sendgrid.net + SPF sendgrid.net--sendgrid
Application outbounds{1,2}._domainkey DKIM + link-branding CNAMEs--dkim-target / --sendgrid-link-brand
DMARC_dmarc TXT--dmarc / --dmarc-rua

Two properties make it safe to re-run. It is idempotent: against a domain that already has the records, every line reports Unchanged and zero API calls are made; a single-valued record whose content drifted is patched in place, and a missing member of a multi-valued set (the apex A/AAAA groups) is created. And it is additive — it never deletes a record it does not manage, so a _twilio verification or a CAA record you added by hand is left untouched.

The per-domain SendGrid and Google secrets — the DKIM/link-branding CNAME targets and the site-verification token — come from SendGrid's Domain Authentication wizard and Google's Admin console. The command takes them as flags and never invents them. (cargo run -p cli -- ops dns setup … is the same command from a source checkout.)

By hand, or on another DNS provider

If you manage DNS yourself or run a different provider, the same records are standard. The DNSimple-native tool is the dnsimple CLI — brew install dnsimple/dnsimple/dnsimple, then authenticate:

dnsimple auth login                            # browser flow; stores a named context
echo "$DNSIMPLE_TOKEN" | dnsimple auth login --with-token   # headless / CI: token on stdin

Set two shell variables first — don't hardcode them. ZONE is the domain you are configuring; ACCT is your DNSimple account id, which you read from dnsimple accounts list (a user token can span several accounts, so pick the one that holds the domain):

ZONE=your-domain.example      # the domain you are configuring
ACCT=<your-account-id>        # from `dnsimple accounts list`

Reach the instance — www (and workflows) → the gateway static IP

dnsimple records create "$ZONE" -a "$ACCT" --type A --name www       --content 8.232.102.111 --ttl 300
dnsimple records create "$ZONE" -a "$ACCT" --type A --name workflows --content 8.232.102.111 --ttl 300

Redirect the apex to www — a DNSimple URL record

DNSimple's redirector answers the bare domain with a 301 to https://www.<zone>, so the redirect lives entirely in the DNS provider and your cluster only ever serves www:

dnsimple records create "$ZONE" -a "$ACCT" --type URL --name "" --content "https://www.$ZONE" --ttl 300

The redirector serves plain HTTP out of the box, but HTTPS needs two things: a certificate for the apex, and a DNSimple Teams plan or higher. HTTPS redirects are a Teams-tier feature — on the Solo plan the redirector answers port 80 only, and https://<zone> fails the TLS handshake no matter what. On Teams, DNSimple still does not auto-issue a certificate for a URL record, so you issue a free, auto-renewing Let's Encrypt certificate for the domain (its default covers both the apex and www):

# order → note the returned certificate id → issue it
dnsimple certificates order-letsencrypt "$ZONE" -a "$ACCT" --auto-renew
dnsimple certificates issue-letsencrypt   "$ZONE" -a "$ACCT" <certificate-id>

Issuance is asynchronous (Let's Encrypt validates through the DNSimple-delegated zone); the certificate state moves from new through requesting to issued, after which the redirector serves the apex over HTTPS. The --redirect-apex-to-www flag writes the URL record and prints this same certificate reminder — it does not issue the certificate for you.

Migrating an existing domain whose apex still points at another redirect (e.g. a set of apex A/AAAA forwarding records) requires deleting those apex records first. ops dns setup is additive and never deletes, and a URL record cannot coexist with address records on the same name, so the command refuses to runconflicting records at the apex — until you remove them by hand:

dnsimple records list "$ZONE" -a "$ACCT" | awk '$2=="A" || $2=="AAAA"'   # find the apex forwarding record ids
dnsimple records delete "$ZONE" -a "$ACCT" <record-id>                    # remove each, then create the URL record above

Human mail — Google Workspace (the apex MX)

dnsimple records create "$ZONE" -a "$ACCT" --type MX --name "" --content smtp.google.com --priority 1 --ttl 3600
dnsimple records create "$ZONE" -a "$ACCT" --type TXT --name "" --ttl 3600 \
  --content 'v=spf1 include:_spf.google.com include:amazonses.com include:sendgrid.net -all'
dnsimple records create "$ZONE" -a "$ACCT" --type CNAME --name chat --content ghs.googlehosted.com --ttl 300

The single SPF record authorizes all three senders for the domain at once — Google Workspace (_spf.google.com), Amazon SES, and SendGrid — so mail from any of them passes SPF.

Application mail — SendGrid (outbound authentication + inbound parse)

dnsimple records create "$ZONE" -a "$ACCT" --type CNAME --name s1._domainkey --ttl 3600 \
  --content s1.domainkey.u36914952.wl203.sendgrid.net
dnsimple records create "$ZONE" -a "$ACCT" --type CNAME --name s2._domainkey --ttl 3600 \
  --content s2.domainkey.u36914952.wl203.sendgrid.net
dnsimple records create "$ZONE" -a "$ACCT" --type MX --name parse --content mx.sendgrid.net --priority 10 --ttl 3600

Then, in SendGrid → Inbound Parse, register the host parse.your-domain.example and point it at Navigator's inbound webhook. The application side — the webhook route and the event pipeline — is email-events-pipeline.md.

Why the app MX lives on a subdomain

Human mail and application mail share one domain without ever colliding, because they live on different records: the command writes Google Workspace's MX at the apex (smtp.google.com) and SendGrid Inbound Parse's MX on the parse. subdomain (mx.sendgrid.net). That is why --google-workspace and --sendgrid can both be set on the same run — one touches the apex, the other a subdomain, so neither overwrites the other's mail exchanger.

Routing support@ into Navigator — the Google Workspace step

Client mail sent to support@your-domain.example is a Google Workspace address, but we want it to flow into Navigator's inbound pipeline. Workspace forwards it to SendGrid Inbound Parse with one Gmail routing rule — no DNS change:

  1. Google Admin → Apps → Google Workspace → Gmail → Routing (https://admin.google.com/ac/apps/gmail/routing).
  2. Add another rule. Name it descriptively — Neon Law's is support => sendgrid inbound parse — and apply it to inbound messages for your organization.
  3. Match: envelope recipient is support@your-domain.example.
  4. Action: check Change envelope recipient → Replace recipient and enter intake@parse.your-domain.example.
  5. Save and confirm the rule shows Enabled.

The result: a sender emails support@your-domain.example → Workspace rewrites the envelope recipient to intake@parse.your-domain.example → the parse. subdomain's MX (mx.sendgrid.net) delivers it to SendGrid → Inbound Parse POSTs it to Navigator's webhook. Staff mail to every other address on the domain is untouched and lands in Workspace as usual.

Verify

navigator ops dns setup --dry-run          # every line Unchanged, 0 calls → the zone matches
dig +short www.your-domain.example          # → the gateway IP
curl -sI https://your-domain.example        # → 301 to https://www.your-domain.example (redirector + cert)
dig +short your-domain.example MX           # → smtp.google.com (Workspace)
dig +short parse.your-domain.example MX     # → mx.sendgrid.net (Inbound Parse)