Skip to main content

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:

ServiceImage / buildExposesRole
backendbuilt from backend/Dockerfile (python:3.11-slim + ffmpeg)8000 (internal)FastAPI via uvicorn. Has a /health healthcheck.
frontendbuilt from frontend/Dockerfile (node:20, standalone)3000 (internal)Next.js via node server.js. Waits for backend health.
nginxnginx:alpine80 (internal)Reverse proxy: /api/* and /docs to backend, everything else to frontend.
cloudflaredcloudflare/cloudflared:latestnoneConnects to Cloudflare and routes your domain to nginx:80.

Key details:

  • No host ports are published. Services use expose (container-to-container) only; cloudflared is the sole ingress.
  • Volumes: uploads and storage are named Docker volumes. The SQLite database lives in storage, so it survives container rebuilds.
  • Healthcheck: the backend's GET /health gates 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_ENV must be set per environment: the stage compose sets it to stage and prod to production. It is not cosmetic. It also gates the imgproxy thumbnail CDN, which is a production-only optimization (the CDN fetches source photos from contentsvision.com, which only serves production). If a stage build inherits the Dockerfile default of production, 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 2048m and 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: no equivalent via proxy_buffering off).
  • proxy_request_buffering off so 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 .env has the AI keys, ADJUSTSQUARE_JWT_SECRET, CLOUDFLARE_TUNNEL_TOKEN, and the Adjust Square base URL. See the configuration reference.
  • Verify GET /health returns {status: "ok"} (the healthcheck and a quick manual curl through the tunnel).
  • Watch docker compose logs -f backend during the first requests after a deploy.
  • Back up the storage volume (it holds the SQLite database and all per-job files).