Infrastructure & accounts
What is provisioned where, who owns it, and the operational gotchas found the hard way. No credentials here — only where things live and what bit us.
Accounts
| Service | Purpose | Owner | Status |
|---|---|---|---|
GitHub — alex-atlas-media/atlas-portal | Source of truth | Alex | Live, private |
| DigitalOcean | VPS (dev, later prod) | Atlas Media team | Setting up |
| Higgsfield | Image generation | Alex | Subscription live; API wallet unfunded |
| Anthropic | Agent workers | — | Not set up |
Supabase — atlas-portal-dev | Dev database | Atlas Media org (Pro) | Live, Singapore |
Supabase — atlas-portal-prod | Production database | Atlas Media org (Pro) | Live, Singapore, schema applied |
| Cloudflare R2 | Asset storage | — | Not set up |
| Google Drive | Asset ingest | — | Not set up |
Gotchas, and what they cost
DigitalOcean — create the Team before anything else
Droplets cannot be moved between DO Teams. Only snapshots transfer, and IP addresses do not come with them, so migrating later means recreating the droplet and redoing DNS.
Create the Atlas Media team, put billing there, invite the dev, then create the project and droplet inside it. Costs nothing; saves a rebuild the first time someone else needs access.
Team, not Organization. An Organization sits above teams and consolidates billing across them — it holds no resources directly. Droplets live in teams, and you cannot create an Organization until you own a team anyway. Both are free. An Organization becomes worth adding only if we end up running a team per client.
Hetzner — ID verification delays new accounts
New Hetzner accounts are ID-verified before provisioning, which can take hours. Fine if planned around, blocking if not. DigitalOcean signs up instantly with a card. Both have Singapore regions.
Higgsfield — two separate wallets on one login
Subscription credits (the app and MCP) and API platform credits are billed separately. The subscription showed ~2,600 credits while the API returned 403 not_enough_credits on the same account and email. Topping up one does not fund the other.
Measured cost: 2 credits per image at 1k on marketing_studio_image, confirmed with the get_cost preflight flag, which prices a generation without submitting it.
Higgsfield MCP — catalogue and account authenticate differently
Authorization: Bearer <API_SECRET> returns 200 and a working MCP session. initialize, tools/list and models_explore all succeed. Any account-bound call fails with "session expired, re-authorize the connector".
Their discovery document advertises a device_code flow at fnf-device-auth.higgsfield.ai for headless clients — every endpoint under it returns 404. So server-side MCP generation is currently not possible; the REST API is the only server path, and it needs the API wallet funded.
Do not read a 200 on initialize as authentication being solved.
GitHub Pages is not an option for these docs
The repo is private, which needs a paid plan, and Pages sites from private repos are public anyway. The docs contain client-sensitive material — a client's own pricing inconsistency, flagged as an ad compliance risk. Publishing them would expose that.
atlasmedia.sg DNS is not where you'd expect
The domain is registered at Key-Systems GmbH, not GoDaddy, while its DNS is served by GoDaddy nameservers (ns33/ns34.domaincontrol.com). Root and www point at Framer; email is Google Workspace.
WHOIS confirms Alex is the Admin contact, so authority is not the problem — the login is. The registry tech contact is alex@atlasconsulting.tech, a different domain, which is most likely the GoDaddy account holding the zone.
The blocker is a registrar-applied lock, not RegistryLock. GoDaddy shows all 19 records and greys out every edit control, explaining only "This feature is unavailable for this domain because of its status." The status is serverUpdateProhibited.
RegistryLock was the obvious suspect and is not the cause — the SGNIC portal lists no locked domains for this admin contact, and WHOIS shows Registry Lock: blank. The server* prefix means the registry applied the flags, but at the registrar's request rather than through RegistryLock.
Key-Systems is a wholesale registrar, so the domain was bought through a reseller who holds the account. Given the site is on Framer and the domain was created 2025-10-09, Framer is the likely reseller. The purchase receipt will name them.
Route around it. atlasconsulting.tech is registered at Hostinger under Atlas's own control with no locks, so portal.atlasconsulting.tech needs no third party at all. Internal tooling on a separate domain from the public brand site is better practice regardless: different audience, different risk, and a failure in one cannot take out the other.
Resolved by moving to a domain Atlas actually controls. The dev environment now runs at https://app.atlasconsulting.tech — registered at Hostinger, no locks, no third party. sslip.io served as the stopgap while DNS access was in question and is no longer needed.
Keeping internal tooling off the public brand domain is the better arrangement regardless: a failure or lock on one cannot take out the other, which is exactly the situation that caused this.
Supabase backups: Pro gives you dailies, not point-in-time
On Pro, physical backups run automatically and a completed one appears within the hour. pitr_enabled stays false — point-in-time recovery is a further paid add-on, so recovery granularity is a day, not a second. The independent nightly dump in infra/backup-db.sh still matters: it protects against losing account access, which a backup inside the same vendor cannot.
Environments are built from migrations, never cloned
Dev and prod both get the same five migrations applied in order. Cloning would carry across whatever drift dev has accumulated; re-applying is reproducible and is the same procedure that stands up a client environment later.
pg_dump's major version must match the server — Supabase runs Postgres 17, and a 16 client refuses outright. Verify restores with infra/verify-backup.sh rather than trusting that a file exists.
Higgsfield MCP on the box
Claude Code is installed on the droplet (Node 22) with mcp.higgsfield.ai registered as an HTTP MCP server. It authenticates on the box via its own OAuth flow, which prints a URL to approve elsewhere — this is what makes headless MCP work, and it is why extracting a token from a laptop was the wrong approach. Once authenticated, the box has a real session: generation, Marketing Studio and all 86 tools, billed to the subscription balance rather than the separately-funded API wallet.
Provisioning
Provider-agnostic: infra/provision.sh needs only Ubuntu 24.04 and SSH. Hetzner, DigitalOcean, Vultr and Linode are interchangeable. See DEPLOY.md.
Sizing: 4 GB / 2 vCPU minimum. 2 GB thrashes on the Next.js Docker build. Region: Singapore, for team and client latency.
GitHub over port 443 from the droplet
Deploys began failing partway through 21 Aug 2026 with:
kex_exchange_identification: Connection closed by remote host
That is GitHub's per-IP SSH throttle on port 22, hit after a long run of deploy fetches from the droplet in one session. It is not an authentication problem — the giveaway is that it closes during key exchange, before auth. A revoked or wrong key gives Permission denied (publickey) instead, and the same key authenticated immediately on port 443.
~/.ssh/config on the droplet now routes GitHub through their documented alternative endpoint:
Host github.com
Hostname ssh.github.com
Port 443
IdentityFile ~/.ssh/github_deploy
IdentitiesOnly yes
Remote URLs are unchanged — this rewrites the route, not the address, so nothing else needed touching. The previous config is backed up alongside it as config.bak-<epoch>.
Worth knowing when a deploy silently does nothing: git fetch … | tail -2 && echo OK reports success even when the fetch failed, because the pipeline's exit status comes from tail. A deploy that appears to succeed and leaves the box on an old commit will let a stale build serve traffic while you test the new behaviour — which is how seven wrongly attributed rows reached the database before anyone noticed the code fixing that bug was never running.
Which integration credentials exist, and under what names
Recorded because I spent real time reporting Meta and WhatsApp as blocked on the client, having checked variable names I had invented rather than the ones the code reads.
| Integration | Variable(s) | On dev |
|---|---|---|
| Zoom Phone | ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET, ZOOM_CLIENT_LINES | yes |
| WhatsApp (Wassenger) | WASSENGER_API_KEY | yes |
| Google Drive | GOOGLE_SA_KEY_B64, GOOGLE_WORKSPACE_USERS | yes |
| Meta Ads | META_SYSTEM_USER_TOKEN | yes |
| Anthropic | ANTHROPIC_API_KEY | yes |
| Higgsfield | HIGGSFIELD_MCP_TOKENS | yes |
| TranscriptAPI | TRANSCRIPTAPI_KEY | no |
| R2 storage | R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_PUBLIC_URL, R2_ACCOUNT_ID | no (only R2_BUCKET) |
| Offsite backup | ATLAS_BACKUP_S3_BUCKET/_ENDPOINT/_KEY/_SECRET | no |
To check what a box actually holds, list the keys rather than guessing at them:
ssh -i ~/.ssh/atlas_portal root@209.97.171.176 'sed -n "s/^\([A-Z][A-Z0-9_]*\)=.*/\1/p" /srv/atlas-portal-dev/.env | sort'
Note ZOOM_CLIENT_LINE also exists alongside ZOOM_CLIENT_LINES. Only the plural is read — see src/lib/zoom/lines.ts. The singular is a leftover and is inert.
Running the ingestion by hand
scripts/nightly.sh dev runs on the droplet and is idempotent. A full run over 33 clients cost USD 0.36 and produced 40 statements, so a one-off catch-up is cheap; the reason the cron line for it is commented out is that it is *recurring* model spend, not that a single run is expensive.
ssh -i ~/.ssh/atlas_portal root@209.97.171.176 'nohup /srv/atlas-portal-dev/scripts/nightly.sh dev > /var/log/atlas/nightly-manual.log 2>&1 &'