Appearance
Backend deployment
The API is a Docker Compose stack on one DigitalOcean droplet, reached through a Cloudflare Tunnel. The authoritative, ground-truth version of this page is app/chemical-safety-assistant-api/deploy/RUNBOOK.md, inside the API's own repo — it's what CI actually runs and what has the real secret names. This page is the narrative walkthrough for someone setting the droplet up the first time.
What lives where (in the API repo)
Dockerfile multi-stage build, ships the whole tree (Prisma needs it at
runtime) — see the API's own CLAUDE.md "Docker" section
deploy/docker-compose.prod.yml postgres + redis + api + nginx + cloudflared
deploy/nginx.conf reverse proxy, no CORS headers (Elysia owns CORS)
deploy/cloud-init.sh DigitalOcean droplet first-boot script
deploy/backup.sh nightly pg_dump, cron'd on the droplet
deploy/RUNBOOK.md the ground-truth setup + secrets + rollback doc
.github/workflows/deploy.yml check → build → deploy, on push to mainOne-time droplet setup
- Create a DigitalOcean droplet. Paste
deploy/cloud-init.shinto the "User data" field — it creates a non-rootcsadeploy user, installs Docker, and adds a swapfile. - In the DO control panel, add a Cloud Firewall allowing inbound TCP 22 only. Don't rely on
ufwon the box itself — Docker writes its own iptables rules that bypass it. - Generate a dedicated SSH keypair for CI (not a personal key) and add its public half to the
csauser'sauthorized_keys. - Hand-write
/opt/csa/.envon the droplet (never committed, never written by CI) — full template in the RUNBOOK. Covers Postgres/Redis credentials,CORS_ORIGIN, andTUNNEL_TOKEN.HOSTstays unset. - Create a Cloudflare Tunnel (Zero Trust → Networks → Tunnels), copy its token into
TUNNEL_TOKEN, and add a Public Hostnameapi.<domain>→http://nginx:80. See Cloudflare Tunnel. - First bring-up:
docker compose -f docker-compose.prod.yml up -d --waitascsa. - Cron
deploy/backup.shnightly.
CI/CD
.github/workflows/deploy.yml, three jobs, push:main (PRs run check only):
- check — real Postgres 16 + Redis 7 service containers,
prisma migrate deploy,lint,tsc --noEmit,bun test. - build —
docker/build-push-action,linux/amd64(droplet is x86_64, no cross-arch needed), pushesghcr.io/<owner>/chem-assistant-api:<sha>and:latest. - deploy — SCPs
docker-compose.prod.yml/nginx.conf/backup.shto/opt/csa(.envis never touched by CI), then over SSH:docker compose pull api→docker compose up -d --wait api nginx.--waitfails the job if the healthcheck (GET /) doesn't pass, and dumps the last 100 log lines on failure.
GitHub Actions secrets
Set in the API repo's Settings → Secrets and variables → Actions:
| Secret | What |
|---|---|
VM_HOST | droplet public IPv4 |
VM_USER | csa |
VM_SSH_KEY | private half of the CI-only deploy keypair |
GHCR_TOKEN | PAT, read:packages — the droplet uses this to pull the image |
Rollback
bash
ssh csa@<droplet> 'cd /opt/csa && export IMAGE_TAG=<previous-sha> && docker compose -f docker-compose.prod.yml up -d --wait api'Code-only. Migrations are forward-only and are never undone.