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.

  1. Optional dotnet test (--with-tests).
  2. dotnet publish of Atamaia.Server into a staging dir (never the live publish folder).
  3. Generate an idempotent EF SQL script (migrate.sql).
  4. Build landing (site/) and SPA (Atamaia.Web) unless --no-web.
  5. Optionally rebuild the code graph and sync those two tables (--no-graph to skip). Graph rows are a derived index of the source tree, not application state — the only data that is allowed to flow dev → prod.
  6. SSH to prod: pg_dump first (plaintext dump lands on the LUKS volume, not the root disk).
  7. Apply migrate.sql with psql.
  8. Rsync the published binaries to /opt/atamaia/publish.
  9. Restart systemd atamaia.
  10. 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.html is Cache-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 to index.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__KeyEncryption: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:pg16 and a multi-stage Dockerfile that is not in this repo
  • Redis for sessions and rate limiting
  • Kubernetes Deployment + HPA
  • GitHub Actions deploy pipeline
  • Host aim.atamaia.ai as the sole API origin
  • Env names Jwt__SecretKey and Encryption__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__Key set, not the compromised legacy value, not in git
  • Jwt__Key is a long random value, not in git
  • ConnectionStrings__Atamaia only 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