Deployment
Production runs as four Docker containers behind a Cloudflare Tunnel. Nothing listens on a public port: all ingress arrives through the tunnel into nginx, which routes to the backend and frontend. This page covers the topology, the one-time server setup, and how to deploy and update.
Topology
The four services
Defined in docker-compose.prod.yml:
| Service | Image / build | Exposes | Role |
|---|---|---|---|
backend | built from backend/Dockerfile (python:3.11-slim + ffmpeg) | 8000 (internal) | FastAPI via uvicorn. Has a /health healthcheck. |
frontend | built from frontend/Dockerfile (node:20, standalone) | 3000 (internal) | Next.js via node server.js. Waits for backend health. |
nginx | nginx:alpine | 80 (internal) | Reverse proxy: /api/* and /docs to backend, everything else to frontend. |
cloudflared | cloudflare/cloudflared:latest | none | Connects to Cloudflare and routes your domain to nginx:80. |
Key details:
- No host ports are published. Services use
expose(container-to-container) only;cloudflaredis the sole ingress. - Volumes:
uploadsandstorageare named Docker volumes. The SQLite database lives instorage, so it survives container rebuilds. - Healthcheck: the backend's
GET /healthgates the frontend's startup (depends_on: condition: service_healthy). - Build args: the frontend bakes in
NEXT_PUBLIC_APP_ENV(production / stage), the optional PostHog keys (NEXT_PUBLIC_POSTHOG_KEY/NEXT_PUBLIC_POSTHOG_HOST), and optional commit metadata, at build time. Because the PostHog keys are baked in, turning analytics on or off needs a frontend rebuild (see analytics).NEXT_PUBLIC_APP_ENVmust be set per environment: the stage compose sets it tostageand prod toproduction. It is not cosmetic. It also gates the imgproxy thumbnail CDN, which is a production-only optimization (the CDN fetches source photos fromcontentsvision.com, which only serves production). If a stage build inherits the Dockerfile default ofproduction, every thumbnail that routes through imgproxy (e.g. the photo-grouping modal) breaks, because the CDN cannot reach stage-only photos. Stage serves thumbnails raw, exactly like local.
nginx
nginx/nginx.conf is tuned for this workload:
client_max_body_size 2048mand long body timeouts for large video uploads.- Long proxy read/send timeouts and disabled buffering on
/api/so the SSE progress stream flows through immediately (X-Accel-Buffering: noequivalent viaproxy_buffering off). proxy_request_buffering offso large uploads stream straight to the backend.- Upstream retries so a brief container restart does not surface as a 502.
Cloudflare Tunnel
The cloudflared service runs tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}. Get the token from Cloudflare Zero Trust: Networks → Tunnels → Create tunnel → copy token, point the tunnel's public hostname at nginx:80, and put the token in .env as CLOUDFLARE_TUNNEL_TOKEN.
One-time server setup
setup.sh provisions a fresh Ubuntu 22.04 / 24.04 server:
git clone https://bitbucket.org/adjustsquare/contentsvision.git
cd contentsvision
sudo bash setup.sh
It installs Docker and FFmpeg, configures the UFW firewall to allow only SSH (the tunnel handles all inbound web traffic), prompts for API keys and writes .env, then builds and starts the stack.
Deploying and updating
# from the project root on the server
docker compose -f docker-compose.prod.yml up -d --build
To ship a new version:
git pull
docker compose -f docker-compose.prod.yml up -d --build
Because the database and uploaded files live in named volumes, a rebuild keeps all data. Schema changes are applied automatically at startup by init_db() (see data model).
Environments
The APP_ENV value (production, stage, or local) flows into the frontend as NEXT_PUBLIC_APP_ENV and drives the environment badge in the sidebar (hidden in production, amber for stage, violet for local) and the page title prefix. Staging and production are the same stack with different .env values and tunnels.
Operational checklist
- Confirm
.envhas the AI keys,ADJUSTSQUARE_JWT_SECRET,CLOUDFLARE_TUNNEL_TOKEN, and the Adjust Square base URL. See the configuration reference. - Verify
GET /healthreturns{status: "ok"}(the healthcheck and a quick manual curl through the tunnel). - Watch
docker compose logs -f backendduring the first requests after a deploy. - Back up the
storagevolume (it holds the SQLite database and all per-job files).