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:
@@ -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:
|
||||
Reference in New Issue
Block a user