Deployment & Hosting
Atamaia is a .NET 10 API plus a React/Vite SPA and an Astro landing page. PostgreSQL with pgvector is the single source of truth.
This page describes how production actually runs as of 2026-08-14. The 2026-03 three-tier Docker → Kubernetes sketch is not the running topology. There is no application Dockerfile in this repo.
Canonical API: https://api.atamaia.ai. See Architecture for the three-layer map and Getting started for a local run.
What is live
Internet
│
Cloudflare (DNS, TLS, DDoS, CDN)
│
├── atamaia.ai → landing (Astro, /opt/atamaia/site/dist)
│ /api/demo/* and /api/interest → :5000
├── app.atamaia.ai → SPA (Vite, /opt/atamaia/web/dist)
│ aim.atamaia.ai → same SPA; /api /mcp /ws /health /oauth → :5000
├── api.atamaia.ai → Atamaia.Server :5000 only
├── mcp.atamaia.ai → Atamaia.Server :5000 only
├── docs.atamaia.ai → 301 https://atamaia.ai/docs{uri}
└── support.atamaia.ai → 301 https://atamaia.ai/support{uri}
│
Caddy on the public VM (deploy/Caddyfile.prod)
│
systemd unit `atamaia` → 127.0.0.1:5000
│
PostgreSQL + pgvector (data on LUKS volume)
aim.atamaia.ai is not the canonical REST host. OpenAPI servers[0] is https://api.atamaia.ai/. aim and app serve the SPA and reverse-proxy a backend path set so the browser can talk to the same origin. Clients and docs should use api.atamaia.ai for REST.
Live probes (2026-08-14):
| URL | Result |
|---|---|
GET https://api.atamaia.ai/health |
200 {"status":"ok",…} |
GET https://aim.atamaia.ai/health |
200 same JSON |
GET https://mcp.atamaia.ai/health |
200 same JSON |
GET https://app.atamaia.ai/ |
200 HTML |
GET https://atamaia.ai/ |
200 HTML |
GET https://docs.atamaia.ai/overview |
301 → https://atamaia.ai/docs/overview |
GET https://api.atamaia.ai/mcp |
401 (endpoint present) |
GET https://aim.atamaia.ai/mcp |
200 |
Response Via |
1.1 Caddy + server: cloudflare |
X-Correlation-Id is present on /health. See Logging.
How a release is shipped
deploy-prod.sh runs on the dev box. It does not build on prod.
- Optional
dotnet test(--with-tests). dotnet publishofAtamaia.Serverinto a staging dir (never the live publish folder).- Generate an idempotent EF SQL script (
migrate.sql). - Build landing (
site/) and SPA (Atamaia.Web) unless--no-web. - Optionally rebuild the code graph and sync those two tables (
--no-graphto skip). Graph rows are a derived index of the source tree, not application state — the only data that is allowed to flow dev → prod. - SSH to prod:
pg_dumpfirst (plaintext dump lands on the LUKS volume, not the root disk). - Apply
migrate.sqlwithpsql. - Rsync the published binaries to
/opt/atamaia/publish. - Restart systemd
atamaia. - Smoke-test prod
/health.
Invariant: schema flows dev → prod. Application data never does. Prod is canonical for identity, memory, facts, chat, agent runs. A dev→prod data restore would clobber live memory. The script will not do one.
Prod stays minimal: .NET runtime only. No SDK, no source tree, no dotnet-ef. That is why migrations travel as SQL.
Rollback: the pre-migration dump is left in place. The script prints the restore command and does not auto-restore.
Edge (Caddy)
Source of truth: deploy/Caddyfile.prod. TLS is DNS-01 via Cloudflare (CF_API_TOKEN). Security headers: HSTS, nosniff, SAMEORIGIN, Referrer-Policy, -Server.
SPA rules that exist because they failed in production:
index.htmlisCache-Control: no-cache(#403). Cloudflare holding a day-old index that pointed at a missing hashed bundle served the previous build./assets/*404s rather than falling through toindex.html. Serving HTML as JavaScript is how a missing file becomes a syntax error instead of a visible 404.
Brand aliases (atamaia.nz, atamaia.io, www.atamaia.ai, …) 301 to https://atamaia.ai{uri}.
The older root Caddyfile (Origin CA, aim as the API host, Vite :5174) is not what Caddyfile.prod deploys.
Configuration
Secrets live in /etc/atamaia/atamaia.env (mode 600, outside /home backups). They do not live in committed JSON. Double underscore maps to colon: Encryption__Key → Encryption:Key.
| Variable | Role | Do not use |
|---|---|---|
ConnectionStrings__Atamaia |
PostgreSQL | ConnectionStrings__DefaultConnection |
Encryption__Key |
AES-256 root key, base64 32 bytes | Encryption__MasterKey |
Jwt__Key |
HMAC signing key (Jwt:Key) |
Jwt__SecretKey |
Jwt__Issuer, Jwt__Audience |
Token validation | |
CF_API_TOKEN |
Caddy DNS-01 | |
PG* |
psql / dump, as required by the backup units |
EncryptionService still accepts a legacy Encryption:MasterKey spelling so an old env file keeps working. The name the codebase sets and documents is Encryption__Key. A known-compromised key is refused at startup.
appsettings.json in git has empty OAuth placeholders and a loud warning. Putting a live key there is how one reached a fine-tune dataset (#331).
Kestrel listens http://0.0.0.0:5000 (loopback-only from the internet’s point of view — Caddy binds 443). CORS origins in committed config include https://app.atamaia.ai and https://mcp.atamaia.ai.
There is no ConnectionStrings__Redis in the running app. A comment on the OAuth in-memory store says “move to DB/Redis for production scaling.” That move has not happened.
Backups and failure noise
Documented on the box as of 2026-08-12 (deploy/systemd/README.md):
| Unit | Schedule | Notes |
|---|---|---|
atamaia-backup.timer |
daily 02:30 | age-encrypted dumps, 30-day retention, min 7 kept |
atamaia-backup-verify.timer |
Sunday 03:30 | decrypts and restores; the writer cannot decrypt |
[email protected] |
OnFailure= |
writes system_logs level Critical, domain Data |
The writer is not meant to hold the decryption identity. “The backup succeeded” and “the backup restores” are different facts.
A Critical row is not an alert until a log subscription matches that domain and level. Hydration systemHealth is subscription-driven: zero matching subscriptions → the section is null. Subscribe in the same change as any new alerter domain. See Logging.
Dumps go to /srv/pgdata/backups on the LUKS volume. pg_dump output is plaintext at creation. Writing it to /var/backups on the root disk would be an unencrypted copy of the database.
Sketched but not running
The 2026-03 page specified:
- Docker Compose with
pgvector/pgvector:pg16and a multi-stageDockerfilethat is not in this repo - Redis for sessions and rate limiting
- Kubernetes Deployment + HPA
- GitHub Actions deploy pipeline
- Host
aim.atamaia.aias the sole API origin - Env names
Jwt__SecretKeyandEncryption__MasterKey
Those are design sketches. Reproducing them here would re-teach the wrong topology. If a containerised Postgres pin lands, it is task #414 (17.10 / pgvector 0.8.2 by digest) — and that task exists because prod and dev are not there yet.
SearXNG is the one Compose file that does exist (deploy/searxng/). That is the web-search sidecar, not the API. See Web search.
Security checklist (live names)
- Cloudflare in front; origin not advertised
-
Encryption__Keyset, not the compromised legacy value, not in git -
Jwt__Keyis a long random value, not in git -
ConnectionStrings__Atamaiaonly in/etc/atamaia/atamaia.env - PostgreSQL not reachable from the public internet
- CORS is the committed origin list, not
* - Soft delete only (D15)
- Backup verify timer has actually run;
OnFailure=is wired - A log subscription exists for every domain the alerter writes