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
| Component | Role |
|---|---|
infra/admin Pulumi stack | Droplet, reserved IP, firewall, DNS A for admin.* |
| Caddy (cloud-init) | TLS, SPA from /var/www/admin, proxy /internal + /.well-known → ops-api |
substratum-ops-api | Internal :18280 (or VPC address) — not a public hostname |
apps/admin build | Vite 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)
| Purpose | Example | Where it comes from |
|---|---|---|
| Public browser origin | https://admin.substratum.cloud | pulumi stack output adminPublicBaseUrl — set PUBLIC_BASE_URL and OPS_UI_PUBLIC_BASE_URL on ops-api |
| Caddy upstream (internal) | http://127.0.0.1:18280 | opsApiUpstream / 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
dataprovisioned — data deployment - Pulumi stack
adminprovisioned — seeinfra/admin/AGENTS.md - Staff
operator_rolerows in Postgres - Ops OAuth client registered for
https://admin.<domain>
1. Provision infrastructure
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 upProduction hardening (ADR 38 C8): restrict HTTPS to VPN or office CIDRs:
pulumi config set inboundAllowlist '["203.0.113.10/32","203.0.113.0/24"]' --json
pulumi up2. 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):
| Variable | Value |
|---|---|
HOST | 127.0.0.1 |
PORT | 18280 |
DATABASE_URL | Managed Postgres connection string |
JWT_SECRET | Production secret |
PUBLIC_BASE_URL | https://admin.substratum.cloud |
OPS_UI_PUBLIC_BASE_URL | https://admin.substratum.cloud |
Verify internal health:
curl -sS http://127.0.0.1:18280/api/health3. Build and rsync admin SPA
From repo root:
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
| Check | Command / action |
|---|---|
| SPA | curl -sI https://admin.substratum.cloud/ → 200 |
| OAuth metadata | curl -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/me → 401 without cookie |
| Staff login | Browser → PDS OAuth → Lookup page loads |
CI (Spindle)
.tangled/workflows/admin.yml — on main push (or manual):
pulumi up(adminstack)pnpm nx build admin- 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
| Symptom | Likely cause |
|---|---|
502 on /internal/… | ops-api not running or wrong opsApiUpstream |
OAuth redirect to :18280 | PUBLIC_BASE_URL not set to admin.* |
| Let's Encrypt failure | DNS A not pointing at reserved IP yet |
403 on grant after login | Missing operator_role row for staff DID |