Skip to content

Operator admin edge deployment (admin.*)

Last Updated: 2026-06-10

Deploy apps/admin static bundle and substratum-ops-api behind the admin.* origin (ADR 38, entitlement admin).

Topology

ComponentRole
infra/admin Pulumi stackDroplet, reserved IP, firewall, DNS A for admin.*
Caddy (cloud-init)TLS, SPA from /var/www/admin, proxy /internal + /.well-known → ops-api
substratum-ops-apiInternal :18280 (or VPC address) — not a public hostname
apps/admin buildVite static output rsync'd to /var/www/admin

Same-origin rule: browser only talks to admin.*; session cookie substratum_ops_session is set on that origin (oauth-and-pds-origins pattern for ops).

URL map (prod)

PurposeExampleWhere it comes from
Public browser originhttps://admin.substratum.cloudpulumi stack output adminPublicBaseUrl — set PUBLIC_BASE_URL and OPS_UI_PUBLIC_BASE_URL on ops-api
Caddy upstream (internal)http://127.0.0.1:18280opsApiUpstream / Spindle ADMIN_OPS_API_UPSTREAM — where Caddy proxies /internal and /.well-known

Garage v1 runs ops-api on the same droplet as Caddy, so the upstream is loopback. Spindle optional ADMIN_DOMAIN (default admin.substratum.cloud); leave ADMIN_OPS_API_UPSTREAM unset unless ops-api listens on another host.

Prerequisites

  • Pulumi stack data provisioned — data deployment
  • Pulumi stack admin provisioned — see infra/admin/AGENTS.md
  • Staff operator_role rows in Postgres
  • Ops OAuth client registered for https://admin.<domain>

1. Provision infrastructure

bash
cd infra/admin
npm ci
pulumi stack select admin
pulumi config set digitalocean:token --secret
pulumi config set domain admin.substratum.cloud
pulumi config set opsApiUpstream http://127.0.0.1:18280
pulumi config set doProjectId cd1eca0e-c563-4501-9896-0c21db8b3b55
pulumi up

Production hardening (ADR 38 C8): restrict HTTPS to VPN or office CIDRs:

bash
pulumi config set inboundAllowlist '["203.0.113.10/32","203.0.113.0/24"]' --json
pulumi up

2. Deploy ops-api on the host

Run substratum-ops-api on the admin droplet (or a private peer reachable from opsApiUpstream).

Example env (systemd unit or container):

VariableValue
HOST127.0.0.1
PORT18280
DATABASE_URLManaged Postgres connection string
JWT_SECRETProduction secret
PUBLIC_BASE_URLhttps://admin.substratum.cloud
OPS_UI_PUBLIC_BASE_URLhttps://admin.substratum.cloud

Verify internal health:

bash
curl -sS http://127.0.0.1:18280/api/health

3. Build and rsync admin SPA

From repo root:

bash
pnpm install
pnpm nx build admin

DROPLET_IP="$(cd infra/admin && npx pulumi stack output dropletIp)"
KEY_FILE="$(mktemp)"
cd infra/admin && npx pulumi stack output adminSshPrivateKey --show-secrets > "${KEY_FILE}"
chmod 600 "${KEY_FILE}"

ssh -i "${KEY_FILE}" -o StrictHostKeyChecking=accept-new root@"${DROPLET_IP}" \
  'cloud-init status --wait && systemctl is-active --quiet caddy'

rsync -avz --delete \
  -e "ssh -i ${KEY_FILE} -o StrictHostKeyChecking=accept-new" \
  dist/apps/admin/ \
  "root@${DROPLET_IP}:/var/www/admin/"

ssh -i "${KEY_FILE}" root@"${DROPLET_IP}" \
  'chown -R caddy:caddy /var/www/admin && systemctl reload caddy'

rm -f "${KEY_FILE}"

4. Smoke tests

CheckCommand / action
SPAcurl -sI https://admin.substratum.cloud/200
OAuth metadatacurl -sS https://admin.substratum.cloud/.well-known/ops-oauth-client-metadata.json → JSON
Ops health (via proxy)curl -sS https://admin.substratum.cloud/internal/v1/ops/me401 without cookie
Staff loginBrowser → PDS OAuth → Lookup page loads

CI (Spindle)

.tangled/workflows/admin.yml — on main push (or manual):

  1. pulumi up (admin stack)
  2. pnpm nx build admin
  3. rsync to droplet + reload Caddy

Secrets: PULUMI_ACCESS_TOKEN, DIGITALOCEAN_TOKEN, optional ADMIN_DOMAIN, ADMIN_SSH_HOST, ADMIN_OPS_API_UPSTREAM (default http://127.0.0.1:18280 — ops-api on same host as Caddy).

Troubleshooting

SymptomLikely cause
502 on /internal/…ops-api not running or wrong opsApiUpstream
OAuth redirect to :18280PUBLIC_BASE_URL not set to admin.*
Let's Encrypt failureDNS A not pointing at reserved IP yet
403 on grant after loginMissing operator_role row for staff DID