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:

TemplateCopy toWhat you replace
docker-compose.production.template.ymldocker-compose.production.yml on your hostevery ${YOUR_DOMAIN}, the config-mount host path
nginx/default.conf.templatenginx/default.confevery 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.conf

The compose template mounts it:

volumes:
  - /opt/ava-config/env-production.conf:/app/.env.local:ro

Points 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.sh refuses to boot with
    any required variable missing (the full list is in the
    quickstart boot checklist),
    with a NEXTAUTH_SECRET shorter 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

Add this line to your host config file:

NGINX_TRUSTED_PROXY=1

Why it exists. AVA identifies clients by IP for its per-IP protections:
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 — but
that header is trivially forgeable by anyone who can reach the app directly.
So trust is opt-in only: AVA reads X-Forwarded-For / X-Real-IP only
when NGINX_TRUSTED_PROXY is exactly 1. Unset — even in production — it
never trusts the header.

The consequence of forgetting it. Without the variable, every request's
IP resolves to "unknown", so all clients share one rate-limit bucket:
after the third chat of the hour from anyone, everyone is locked out for
the hour. If your helpdesk mysteriously rate-limits all clients at once, check
this variable first.

Why it's safe 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 replaces any
client-supplied X-Forwarded-For with the real socket address
(proxy_set_header X-Forwarded-For $remote_addr;). The chain is
spoof-proof end-to-end. If you instead terminate at your own load balancer,
preserve that property: strip inbound X-Forwarded-For at your edge and set
it fresh, and only then set NGINX_TRUSTED_PROXY=1. (When multiple
comma-separated hops are present, AVA trusts the last — the one appended
by the proxy nearest to it.)


3. The compose template, service by service

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 user

The container runs as a non-root user against a read-only root filesystem with
all capabilities dropped — a compromise of the app process has very little
host surface to work with. 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

Terminates 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

An 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-end

4. 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 with includeSubDomains — drop
      includeSubDomains only 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, no
      unsafe-eval, frame-ancestors 'self' (clickjacking), object-src 'none', base-uri 'self'.
    • X-Frame-Options, X-Content-Type-Options: nosniff, Referrer-Policy,
      Permissions-Policy, and server_tokens off.
  • Flood control — a per-IP request rate zone (30 r/s, burst 60) and a
    per-IP connection cap (30), sized generously for Socket.io reconnect storms
    and multi-tab engineers while blocking a single-source flood.
  • client_max_body_size 5M — matches the app's upload cap, so oversize
    uploads die at nginx before the Node process ever buffers them.
  • Privacy access-log format — logs the request path only, never the
    query string, so tokens or device hints in URLs can never land in access
    logs.
  • Proxy block — WebSocket upgrade headers, long read timeouts for
    persistent sockets, buffering off for real-time delivery, and the
    fresh-X-Forwarded-For behavior 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
even nginx -s reload won'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:

  1. 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;
        }
    }
  2. 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
  3. 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
  4. 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:

VolumeContents
ava-datathe SQLite database (sessions, messages, engineers, canned responses, surveys)
ava-uploadschat 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


Did this page help you?