# ============================================================ # FreeFrame — Production Docker Compose # ============================================================ # Usage: # cp .env.example .env.prod # configure your settings # docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build # # SSL: This file alone serves HTTP only (port 80) and requests no # certificate. For automatic Let's Encrypt certificates, set DOMAIN # and ACME_EMAIL in .env.prod and add -f docker-compose.https.yml. # ============================================================ services: traefik: image: traefik:v2.11 command: - --api.dashboard=false - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 # Let's Encrypt resolver; certificates are requested only with -f docker-compose.https.yml - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-} - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web ports: - "80:80" - "443:443" volumes: - ${DOCKER_SOCK:-/var/run/docker.sock}:/var/run/docker.sock:ro - letsencrypt:/letsencrypt restart: unless-stopped postgres: image: postgres:15-alpine env_file: - .env.prod volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-freeframe}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped redis: image: redis:7-alpine command: > sh -c "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-server --requirepass \"$$REDIS_PASSWORD\" --appendonly yes; else redis-server --appendonly yes; fi" env_file: - .env.prod volumes: - redisdata:/data healthcheck: test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" --no-auth-warning ping || redis-cli ping"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped # ─── Optional: self-hosted object storage (Silo, a MinIO fork) ────────────── # FreeFrame has NO local-filesystem storage backend — it speaks only the S3 API. # By default, point it at external S3-compatible storage (AWS S3, R2, B2, Spaces) # via the S3_* vars in .env.prod. To instead keep media on THIS host, run your # own S3 server by uncommenting the two services below, then: # 1. add `miniodata:` under the top-level `volumes:` (see bottom of file); # 2. add `depends_on: { minio: { condition: service_healthy } }` to # api / worker / email_worker / maintenance_worker / beat; # 3. in .env.prod set: # S3_STORAGE=minio # S3_ENDPOINT=http://minio:9000 # S3_PUBLIC_ENDPOINT=https://storage.${DOMAIN} # MUST be browser-reachable # S3_ACCESS_KEY / S3_SECRET_KEY = strong values # S3_BUCKET = lowercase letters, digits and hyphens only (no underscores) # Presigned URLs are handed to the browser, so the storage server must be # reachable from OUTSIDE the compose network — that is what the Traefik router # below provides (point a `storage.` DNS record at this host). # The bucket can stay private. On startup the app sets the bucket's CORS rules # from FRONTEND_URL and CORS_ALLOW_ORIGINS, and Silo enforces them, so # FRONTEND_URL must be the exact https:// origin people browse to. # An existing miniodata volume from the old minio/minio image opens as is. # # minio: # image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z # maintained MinIO fork; minio/minio is no longer pullable (#461) # command: server /data --console-address ":9001" # environment: # MINIO_ROOT_USER: ${S3_ACCESS_KEY} # MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} # volumes: # - miniodata:/data # healthcheck: # test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] # interval: 10s # timeout: 5s # retries: 5 # restart: unless-stopped # labels: # - traefik.enable=true # - traefik.http.routers.minio.rule=Host(`storage.${DOMAIN}`) # - traefik.http.routers.minio.entrypoints=websecure # - traefik.http.routers.minio.tls.certresolver=letsencrypt # - traefik.http.services.minio.loadbalancer.server.port=9000 # # minio-init: # one-shot: create the bucket (idempotent) # image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z # env_file: # - .env.prod # the S3_* vars below are read inside the container # depends_on: # minio: # condition: service_healthy # entrypoint: > # /bin/sh -c " # mc alias set local http://minio:9000 $${S3_ACCESS_KEY} $${S3_SECRET_KEY} && # mc mb --ignore-existing local/$${S3_BUCKET} # " api: build: context: . dockerfile: apps/api/Dockerfile.prod env_file: - .env.prod environment: - DISABLE_DOCS=true depends_on: postgres: condition: service_healthy redis: condition: service_healthy command: > sh -c "cd /workspace/apps/api && alembic upgrade head && cd /workspace && gunicorn apps.api.main:app -w $${API_WORKERS:-4} -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --timeout 120 --graceful-timeout 30 --access-logfile -" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 5s start_period: 30s retries: 3 labels: - traefik.enable=true # HTTP router (always active) - traefik.http.routers.api.rule=PathPrefix(`/api`) - traefik.http.routers.api.entrypoints=web - traefik.http.routers.api.middlewares=strip-api-prefix # HTTPS router (active when SSL is configured) - traefik.http.routers.api-secure.rule=PathPrefix(`/api`) - traefik.http.routers.api-secure.entrypoints=websecure - traefik.http.routers.api-secure.tls.certresolver=letsencrypt - traefik.http.routers.api-secure.middlewares=strip-api-prefix - traefik.http.routers.api-secure.service=api # Shared - traefik.http.middlewares.strip-api-prefix.stripprefix.prefixes=/api - traefik.http.services.api.loadbalancer.server.port=8000 restart: unless-stopped worker: build: context: . dockerfile: apps/api/Dockerfile.prod env_file: - .env.prod depends_on: postgres: condition: service_healthy redis: condition: service_healthy command: sh -c "celery -A apps.api.tasks.celery_app worker -Q transcoding -c $${TRANSCODING_CONCURRENCY:-2} --loglevel=$${CELERY_LOGLEVEL:-info}" restart: unless-stopped email_worker: build: context: . dockerfile: apps/api/Dockerfile.prod env_file: - .env.prod depends_on: redis: condition: service_healthy command: sh -c "celery -A apps.api.tasks.celery_app worker -Q email_high,email_low -c $${EMAIL_CONCURRENCY:-2} --loglevel=$${CELERY_LOGLEVEL:-info}" restart: unless-stopped # Housekeeping: retention GC, stale-upload reaper, orphan sweep. (Watermarking # runs on the transcoding worker, see apps/api/tasks/celery_app.py.) # Kept off the transcoding worker (these hold a slot for minutes while a video # waits) and off the email worker (a magic code queued behind a full bucket # listing is a login someone is waiting on). maintenance_worker: build: context: . dockerfile: apps/api/Dockerfile.prod env_file: - .env.prod depends_on: postgres: condition: service_healthy redis: condition: service_healthy command: sh -c "celery -A apps.api.tasks.celery_app worker -Q maintenance -c $${MAINTENANCE_CONCURRENCY:-1} --loglevel=$${CELERY_LOGLEVEL:-info}" restart: unless-stopped beat: build: context: . dockerfile: apps/api/Dockerfile.prod env_file: - .env.prod depends_on: redis: condition: service_healthy command: sh -c "celery -A apps.api.tasks.celery_app beat --loglevel=$${CELERY_LOGLEVEL:-info} -s /tmp/celerybeat-schedule" restart: unless-stopped web: build: context: ./apps/web dockerfile: Dockerfile.prod args: NEXT_PUBLIC_API_URL: /api NEXT_PUBLIC_BASE_PATH: ${NEXT_PUBLIC_BASE_PATH:-} env_file: - .env.prod environment: # Server-to-server, for the branding Next renders into the first byte. # It cannot reuse NEXT_PUBLIC_API_URL here: that is `/api`, a path the # browser resolves against the current origin and Traefik routes. A fetch # from inside this container has no origin to resolve it against. - API_INTERNAL_URL=${API_INTERNAL_URL:-http://api:8000} depends_on: - api healthcheck: # Probe a public page under the prefix: with NEXT_PUBLIC_BASE_PATH set, # the app root `/` answers 404 and the container would never go healthy. # Compose interpolates the same value the build arg gets. test: [ "CMD-SHELL", "wget -q --spider \"http://127.0.0.1:3000${NEXT_PUBLIC_BASE_PATH:-}/login\"", ] interval: 30s timeout: 5s start_period: 15s retries: 3 labels: - traefik.enable=true # HTTP router (always active) - traefik.http.routers.web.rule=PathPrefix(`/`) - traefik.http.routers.web.entrypoints=web - traefik.http.routers.web.priority=1 # HTTPS router (active when SSL is configured) - traefik.http.routers.web-secure.rule=PathPrefix(`/`) - traefik.http.routers.web-secure.entrypoints=websecure - traefik.http.routers.web-secure.tls.certresolver=letsencrypt - traefik.http.routers.web-secure.priority=1 - traefik.http.routers.web-secure.service=web - traefik.http.services.web.loadbalancer.server.port=3000 restart: unless-stopped volumes: pgdata: redisdata: letsencrypt: # miniodata: # uncomment if you enable the optional self-hosted MinIO service above