# Production stack for ciagent.org - a standalone file, not merged with # docker-compose.yml (the dev file). The two differ enough (nginx + gitea # added, Postgres/Redis no longer host-published, prod Dockerfile targets, # ${POSTGRES_PASSWORD} instead of a hardcoded literal) that an # override-merge risked someone running plain `docker compose up` on the # box and silently getting the dev config instead. See DEPLOYMENT.md for # the full first-boot runbook. # # Run `scripts/bootstrap-env.sh` first so POSTGRES_PASSWORD/JWT_SECRET/ # API_KEY_ENCRYPTION_SECRET are real values, not .env.example's dev # placeholders, before bringing this up. name: ci-agent-prod services: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-ciagent} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set - run scripts/bootstrap-env.sh} POSTGRES_DB: ${POSTGRES_DB:-ciagent} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ciagent} -d ${POSTGRES_DB:-ciagent}"] interval: 5s timeout: 5s retries: 10 logging: &default-logging driver: json-file options: max-size: "10m" max-file: "3" redis: image: redis:7-alpine restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 5s retries: 10 logging: *default-logging # No ports published to the host for api/worker/beat/web/gitea below - # nginx is the only internet-facing service; everything else is reached # over the internal Compose network by service name. api: build: context: . dockerfile: infrastructure/docker/api.Dockerfile.prod restart: unless-stopped env_file: .env environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-ciagent}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ciagent} REDIS_URL: redis://redis:6379/0 depends_on: postgres: condition: service_healthy redis: condition: service_healthy logging: *default-logging worker: build: context: . dockerfile: infrastructure/docker/api.Dockerfile.prod restart: unless-stopped env_file: .env environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-ciagent}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ciagent} REDIS_URL: redis://redis:6379/0 depends_on: postgres: condition: service_healthy redis: condition: service_healthy command: celery -A app.tasks.celery_app worker --loglevel=INFO -Q default,collection,analysis,notifications,maintenance,enrichment logging: *default-logging beat: # Must stay exactly one instance, always - duplicate scheduled runs # otherwise. Don't `docker compose up --scale beat=N`. build: context: . dockerfile: infrastructure/docker/api.Dockerfile.prod restart: unless-stopped env_file: .env environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-ciagent}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ciagent} REDIS_URL: redis://redis:6379/0 depends_on: postgres: condition: service_healthy redis: condition: service_healthy command: celery -A app.tasks.celery_app beat --loglevel=INFO logging: *default-logging web: build: context: . dockerfile: infrastructure/docker/web.Dockerfile.prod args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_GIT_REPO_URL: ${NEXT_PUBLIC_GIT_REPO_URL:-} NEXT_PUBLIC_DB_VIEWER_URL: ${NEXT_PUBLIC_DB_VIEWER_URL:-} restart: unless-stopped depends_on: - api logging: *default-logging gitea: image: gitea/gitea:1 restart: unless-stopped environment: GITEA__database__DB_TYPE: sqlite3 GITEA__database__PATH: /data/gitea/gitea.db GITEA__server__DOMAIN: git.ciagent.org GITEA__server__ROOT_URL: https://git.ciagent.org/ GITEA__server__SSH_DOMAIN: git.ciagent.org GITEA__server__SSH_PORT: 2222 # Deliberately NOT setting GITEA__server__START_SSH_SERVER=true - that # flag is for the rootless image variant only. This standard image # already bundles its own system sshd for SSH clone/push, configured # via the plain (unprefixed) SSH_PORT below - setting START_SSH_SERVER # here makes Gitea's own embedded SSH server also try to bind the same # port the system sshd already holds, crash-looping the container # (confirmed live: "listen tcp :22: bind: address already in use"). # The system sshd binds container-internal port 22 by default, which # is exactly what the "2222:22" host port mapping below expects. SSH_PORT: 2222 # No public sign-ups - only the CLI-created admin account exists. GITEA__service__DISABLE_REGISTRATION: "true" # Allows anonymous browsing - actual visibility still comes from # each repo's own public/private flag, so this alone exposes # nothing by itself. See DEPLOYMENT.md. GITEA__service__REQUIRE_SIGNIN_VIEW: "false" # Skips the web install wizard from the very first boot - never # leaves a window where an unauthenticated visitor could complete # first-time setup themselves. GITEA__security__INSTALL_LOCK: "true" volumes: - gitea-data:/data ports: # SSH push (optional, secondary to HTTPS+token push) - Cloudflare's # proxy only speaks HTTP(S), so this rides straight to the origin. # Firewall this port to your own IP only (see DEPLOYMENT.md). - "2222:22" logging: *default-logging # Settings -> Database viewer: internal-only, reached through nginx's # db.ciagent.org block (app/api/v1/db_viewer.py issues the credential - # see that module's docstring). No host port published. adminer: image: adminer:4.8.1-standalone restart: unless-stopped environment: ADMINER_DEFAULT_SERVER: postgres depends_on: postgres: condition: service_healthy logging: *default-logging nginx: image: nginx:1.27-alpine restart: unless-stopped depends_on: - web - api - gitea - adminer volumes: - ./infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Cloudflare Origin CA cert/key, generated once via the Cloudflare # dashboard - kept outside the repo entirely, never committed. - /etc/ci-agent/certs:/etc/nginx/certs:ro ports: - "80:80" - "443:443" logging: *default-logging volumes: postgres-data: gitea-data: