docker-compose.prod.yml
Rama main · réplica de lectura del repositorio original.
# ============================================================
# 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.<your-domain>` 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