Production Deployment
The supported production topology is a three-service Docker Compose stack on a single host:
Internet ──443──> nginx (TLS, security headers, rate limiting)
│ proxied (HTTP + WebSocket upgrade)
▼
ava (Next.js 16 + Socket.io + SQLite, one process)
certbot (always-on Let's Encrypt renewal loop)
Two repo-tracked templates define it:
| Template | Copy to | What you replace |
|---|---|---|
docker-compose.production.template.yml | docker-compose.production.yml on your host | every ${YOUR_DOMAIN}, the config-mount host path |
nginx/default.conf.template | nginx/default.conf | every YOUR_DOMAIN |
Copy the templates rather than editing them in place — your copies survive future git updates of the repo. Keep the security headers in the nginx template intact (they ship pre-hardened; see below).
This page assumes you've completed quickstart.md — credentials verified, picklists discovered, the app boots. Prerequisites for the host itself are in requirements.md §5.
1. The host config file (runtime secrets)
The container image never contains secrets. All runtime configuration — the same variables you set in .env.local during the quickstart — lives in a single file on the Docker host, mounted read-only into the container as /app/.env.local:
sudo mkdir -p /opt/ava-config
sudo cp .env.local /opt/ava-config/env-production.conf # or author it fresh
sudo chmod 600 /opt/ava-config/env-production.confThe compose template mounts it:
volumes:
- /opt/ava-config/env-production.conf:/app/.env.local:roPoints worth knowing:
- Keep the file outside the repo checkout. It holds live credentials; the checkout gets replaced on upgrades, the config file does not.
chmod 600. The entrypoint warns at boot if the file is group- or world-readable.- Validated at every start.
docker-entrypoint.shrefuses to boot with any required variable missing (the full list is in the quickstart boot checklist), with aNEXTAUTH_SECRETshorter than 32 characters, or with a placeholder-looking secret. A misconfigured container fails fast and loud instead of running half-wired. - Changes need only a container restart, not a rebuild — every non-
NEXT_PUBLIC_*variable is read at call time.
Two values are the exception: NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_SOCKET_URL (and NEXT_PUBLIC_RECAPTCHA_SITE_KEY) are compiled into the browser bundle at build time. They are passed as docker build --build-arg values, and the compose file's environment: block repeats the URL values for the server process. Changing your domain or reCAPTCHA site key therefore means a rebuild.
2. NGINX_TRUSTED_PROXY=1 — required behind the proxy
NGINX_TRUSTED_PROXY=1 — required behind the proxyAdd this line to your host config file:
NGINX_TRUSTED_PROXY=1Why it exists. AVA applies some limits per client IP: the 3-sessions/hour chat-start limit, upload budgets, and related throttles. Behind a reverse proxy the real client IP arrives in X-Forwarded-For, so AVA reads X-Forwarded-For / X-Real-IP only when NGINX_TRUSTED_PROXY is exactly 1. Unset — even in production — the header is ignored.
If you forget it. AVA can't tell visitors apart, so every client shares one set of per-IP limits: after the third chat of the hour from anyone, new chats are refused for everyone until the hour is up. If your helpdesk rate-limits all clients at once, check this variable first.
Why it's correct to set here. In the shipped template the app port is only exposed to the internal Docker network — never published on the host — so all traffic passes through nginx, and the nginx template sets X-Forwarded-For from the real connection address (proxy_set_header X-Forwarded-For $remote_addr;). If you instead terminate at your own load balancer, keep the same arrangement: have your edge set X-Forwarded-For itself rather than passing an inbound value through, and only then set NGINX_TRUSTED_PROXY=1. (When multiple comma-separated hops are present, AVA uses the last — the one appended by the proxy nearest to it.)
3. The compose template, service by service
ava (the app)
ava (the app)mem_limit: 768m # Node targets ~250MB; headroom for spikes, bounded blast radius
cpus: 1.5
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL] # the app needs zero Linux capabilities
read_only: true # root filesystem is immutable…
tmpfs:
- /tmp # …except tmpfs scratch space
volumes:
- ava-data:/app/data # SQLite database — BACK THIS UP
- ava-uploads:/app/uploads # chat file attachments
- type: tmpfs
target: /app/.next/cache # Next.js runtime cache; safe to lose on restart
tmpfs: { mode: 0777 } # explicit mode — a bare tmpfs mounts root-owned 0755,
# unwritable by the non-root runtime userThe container runs as a non-root user against a read-only root filesystem with all capabilities dropped — the app gets only the privileges it needs. Logging uses the json-file driver with rotation (10 MB × 3 files) on all three services, so logs can never fill the disk.
On every start the entrypoint validates the env file (section 1) and runs npx prisma db push — schema changes in a new release apply themselves idempotently before the server accepts traffic.
nginx
nginxTerminates TLS and proxies everything (including WebSocket upgrades) to the app. Its command wraps the normal daemon in a 6-hourly graceful reload loop:
while :; do sleep 6h; nginx -s reload; done & nginx -g "daemon off;"
nginx only reads certificate files at startup/reload, so the periodic reload is what picks up certificates renewed by certbot. The reload is graceful — old workers finish serving established connections, so live chat WebSockets survive it.
certbot
certbotAn always-on renewal loop:
while :; do certbot renew; sleep 12h; done
certbot renew is a no-op until a certificate is within 30 days of expiry, so the 12-hour cadence is rate-limit-safe. Combined with the nginx reload loop, renewal is fully automatic — there is nothing to cron on the host.
Because the service's entrypoint is the loop, manual certbot one-offs must override it:
docker compose -f docker-compose.production.yml run --rm --entrypoint certbot \
certbot renew --dry-run # verify the renewal machinery end-to-end4. The nginx template
Copy nginx/default.conf.template to nginx/default.conf and replace every YOUR_DOMAIN. What's inside, and why to keep it:
- HTTP→HTTPS redirect on port 80, with the Let's Encrypt ACME webroot (
/.well-known/acme-challenge/) carved out so renewals work over HTTP. - TLS 1.2/1.3 only, modern ciphers.
- Security headers — keep these intact:
Strict-Transport-Security(1-year HSTS withincludeSubDomains— dropincludeSubDomainsonly if you serve plain-HTTP siblings under the same parent domain; preload is deliberately not set, as registry submission is effectively irreversible).- A Content-Security-Policy tuned to exactly what the app needs: same-origin by default, reCAPTCHA's Google origins for script/frame,
wss:for the WebSocket,blob:for client-side attachment previews, nounsafe-eval,frame-ancestors 'self'(clickjacking),object-src 'none',base-uri 'self'. X-Frame-Options,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy, andserver_tokens off.
- Per-IP request-rate and connection limits, sized generously for Socket.io reconnects and engineers with several tabs open. Tune them in the template if your traffic needs it.
client_max_body_size 5M— matches the app's upload cap, so oversize uploads are rejected at nginx.- Privacy access-log format — logs the request path only, never the query string, so device hints in URLs stay out of access logs.
- Proxy block — WebSocket upgrade headers, long read timeouts for persistent sockets, buffering off for real-time delivery, and the fresh-
X-Forwarded-Forbehavior that section 2 depends on.
Editing nginx config later: a single-file bind mount pins the file's inode. If your edit replaces the file (a
git checkout, most editors' atomic saves), the running container keeps serving the old content and evennginx -s reloadwon't see the change. After config edits, recreate the container:docker compose -f docker-compose.production.yml up -d --force-recreate nginx(a ~seconds blip).
5. First bring-up (bootstrapping TLS)
There is a chicken-and-egg on the very first start: the full nginx config references certificate files that don't exist yet, so nginx won't start until certbot has issued them — and certbot's webroot challenge needs nginx serving port 80. Bootstrap with a temporary HTTP-only config:
-
Start with an HTTP-only
nginx/default.conf— just the port-80 server with the ACME location and the proxy block:server { listen 80; server_name chat.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { proxy_pass http://ava:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } } -
Build and start the stack:
docker build \ --build-arg NEXT_PUBLIC_APP_URL=https://chat.example.com \ --build-arg NEXT_PUBLIC_SOCKET_URL=wss://chat.example.com \ --build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=<your-site-key> \ -t ava:latest . docker compose -f docker-compose.production.yml up -d -
Issue the certificate (entrypoint override, as always):
docker compose -f docker-compose.production.yml run --rm --entrypoint certbot \ certbot certonly --webroot -w /var/www/certbot \ -d chat.example.com --email [email protected] --agree-tos --no-eff-email -
Swap in the full config (from the template, with your domain) and recreate nginx:
docker compose -f docker-compose.production.yml up -d --force-recreate nginx
From here on, renewal is automatic (section 3).
6. Upgrades — build before swap
The one principle: a failed build must leave production untouched. Never docker compose down before building — build the new image while the old stack keeps serving, then swap.
cd ~/ava-chat
# 0. Back up the SQLite volume first (see section 7).
# 1. Pre-flight: prune build cache and check free disk.
# --no-cache builds accumulate gigabytes of cache; building with a nearly
# full disk fails mid-build. Need >= 8 GB free.
docker builder prune -af && df -h /
# 2. Fetch the release you're deploying.
git fetch --tags --force && git checkout vX.Y.Z
# 3. Build FIRST — the old stack keeps serving throughout.
docker build --no-cache \
--build-arg NEXT_PUBLIC_APP_URL=https://chat.example.com \
--build-arg NEXT_PUBLIC_SOCKET_URL=wss://chat.example.com \
--build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=<your-site-key> \
-t ava:latest .
# 4. Swap. Compose recreates only the containers whose image changed —
# typically just the app, a few seconds of downtime.
docker compose -f docker-compose.production.yml up -d
# 5. Clean up old image layers and watch the boot.
docker image prune -f
docker logs -f ava # "Environment validation passed." -> "Syncing database schema..."Schema migrations are automatic: the entrypoint's prisma db push applies any additive schema change before the server starts.
Rollback is the same flow with the previous version checked out — releases avoid destructive schema changes, so rolling back is a pure code swap (your pre-upgrade database backup is the belt-and-suspenders).
Post-upgrade smoke test: version string in the dashboard footer, an engineer sign-in, and one end-to-end test chat (first-login.md §smoke test).
7. Backups
Two named volumes hold all state:
| Volume | Contents |
|---|---|
ava-data | the SQLite database (sessions, messages, engineers, canned responses, surveys) |
ava-uploads | chat file attachments |
Snapshot them before every upgrade, and on whatever schedule your RPO needs:
docker run --rm -v ava-data:/data -v "$HOME/ava-backups":/backup alpine \
tar czf /backup/data-$(date +%Y%m%d-%H%M%S).tgz -C /data .
docker run --rm -v ava-uploads:/data -v "$HOME/ava-backups":/backup alpine \
tar czf /backup/uploads-$(date +%Y%m%d-%H%M%S).tgz -C /data .Also back up /opt/ava-config/env-production.conf — to somewhere with the same care you'd give a password vault, since it holds every credential.
See also
quickstart.md— getting the app configured and running the first time.first-login.md— engineer onboarding + the end-to-end smoke test.operations.md— day-2 operations once the stack is live.configuration.md— every environment variable.white-label.md— branding the deployment as your own.
Updated 15 days ago