Add production deployment tooling: Nginx, Gitea, prod Dockerfiles

Sets up everything needed to deploy behind Cloudflare with a self-hosted
git server: multi-stage prod Dockerfiles (non-root), docker-compose.prod.yml
(Postgres/Redis with no host ports, Nginx reverse proxy, Gitea with
public-read/admin-write access control), scripts/bootstrap-env.sh to
auto-generate required secrets on first clone, and DEPLOYMENT.md covering
the full runbook. Provider API keys (Anthropic/Brave/NinjaPear/USPTO/
Turnstile) are deliberately kept out of .env in favor of the existing
DB-backed Settings UI, so the public repo stays safe to expose.

Also fixes two bugs only surfaced by live-testing the prod stack: Celery
beat couldn't write its schedule file as a non-root user, and Gitea's
embedded SSH server conflicted with the base image's own sshd on port 22.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-05 12:59:41 -04:00
co-authored by Claude Sonnet 5
parent 1a4c80958f
commit 086ca1f13f
14 changed files with 644 additions and 1 deletions
+172
View File
@@ -0,0 +1,172 @@
# 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:-}
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
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
- web
- api
- gitea
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: