Skip to content

Deploy with Docker Compose

This page covers bringing up the complete CTFHive stack with docker compose on a single host. This is the standard deployment method for a single-event or self-hosted setup. For automated multi-tenant SaaS provisioning, see Provisioning tenants.


Services

Three containers make up the compose stack (docker-compose.yml):

Service Image Role Exposes
ctf-app Built from Dockerfile (Python 3.13-slim + gunicorn) Flask/Gunicorn app 127.0.0.1:5000 only
redis redis:7-alpine Session cache, rate-limit counters, ARQ queue Internal only
nginx nginx:1.27-alpine TLS termination, reverse proxy, rate limiting 0.0.0.0:80, 0.0.0.0:443

The app is bound to 127.0.0.1:5000 inside the compose network and is not reachable directly from outside the host — nginx is the only public entry point.


Ports and volumes

Container Host binding Notes
nginx 0.0.0.0:80, 0.0.0.0:443 Public HTTP/HTTPS
ctf-app 127.0.0.1:5000 Internal only; nginx proxies here
redis none Internal bridge network only
Volume / bind-mount Purpose
./data/app/data SQLite database (ctfapp.db)
./uploads/app/uploads Challenge file uploads
./challenges/app/challenges (read-only) Challenge YAML packs
./nginx/ctf-docker.conf Active nginx site config
./nginx/ctf-proxy.conf Shared proxy headers snippet
./certbot/conf Let's Encrypt certificate tree (read-only in nginx)
./certbot/www ACME challenge webroot
/var/run/docker.sock/var/run/docker.sock Docker socket (see warning below)
redis-data (named) Redis AOF/RDB persistence

Direct Docker socket mount

The app container mounts the host Docker socket (/var/run/docker.sock) directly. This is how the lab subsystem spawns and manages per-player challenge containers without a separate sidecar process.

Security trade-off: Any code running inside the ctf-app container has full Docker API access equivalent to root on the host. The socket is restricted to the docker group — docker-compose.yml uses group_add: ["${DOCKER_GID:-0}"] to add the container's ctf user to that group. You must set DOCKER_GID in .env to the actual GID of the socket on your host (see Environment variables below).

CLAUDE.md describes a planned forge-dockerd-proxy that would restrict the Docker API surface via mTLS. That proxy does not exist yet. Until it is built, the direct socket mount is the mechanism in use.


Environment variables

Copy .env.example to .env and fill in every value before starting. The compose file reads .env with env_file: .env. Key variables:

Variable Required Description
SECRET_KEY Yes Flask secret key — set a long random value in production
ADMIN_KEY Yes Used by the flag derivation HMAC — must match SECRET_KEY or be independently set
DATABASE_URL Yes Set to sqlite:////app/data/ctfapp.db in compose (path inside container)
REDIS_URL Yes Set to redis://redis:6379/0 in compose
SITE_URL Yes Full public URL, e.g. https://ctf.example.com
DOCKER_GID Yes GID of /var/run/docker.sock on the host. Find it with stat -c '%g' /var/run/docker.sock
LAB_ENABLED Yes Set true to enable dynamic challenge container spawning
REGISTRY_HOST If using labs Hostname of the private image registry
REGISTRY_USER If using labs Registry username
REGISTRY_PASS If using labs Registry password
APP_ENV Set by docker-compose.yml to production — do not override

The APP_ENV variable selects the Flask config class. The compose file forces production; do not override this in .env.


Quick start

Prerequisites

  • Docker Engine 24+ and the compose plugin (docker compose).
  • DOCKER_GID set correctly (see above).
  • A valid .env file.

First run

# 1. Resolve DOCKER_GID
export DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
echo "DOCKER_GID=${DOCKER_GID}" >> .env

# 2. Create persistent data directories
mkdir -p data uploads

# 3. Build the app image and start all services
make docker-up
# equivalent: docker compose up -d

Makefile shortcuts

Command What it does
make docker-up docker compose up -d — start all services detached
make docker-down docker compose down — stop and remove containers
make docker-build docker compose build ctf-app — rebuild only the app image
make docker-logs docker compose logs -f ctf-app — tail app logs
make docker-shell docker compose exec ctf-app bash — shell into the app container

Bootstrap the database and first admin

The database schema is auto-created on first startup (SQLAlchemy create_all). To create the initial admin user run the CLI against the running container:

docker compose exec ctf-app uv run python cli.py admin bootstrap \
  --username admin \
  --email admin@example.com \
  --password 'ChangeMe123!' \
  --app-env production

Or from the host if you have the virtualenv active:

uv run python cli.py admin bootstrap \
  --username admin \
  --email admin@example.com \
  --password 'ChangeMe123!' \
  --app-env production

Nginx and TLS

Nginx handles all public traffic. The active config is nginx/ctf-docker.conf, which is bind-mounted into the container.

During initial setup (no TLS certificates yet): the config proxies HTTP directly to the app and serves ACME challenge tokens from /var/www/certbot. The HTTPS server block is present but commented out.

After obtaining certificates: run scripts/setup-tls.sh (or use certbot directly) to obtain a Let's Encrypt cert, then uncomment the HTTPS server block in nginx/ctf-docker.conf and reload nginx:

docker compose exec nginx nginx -s reload

The HTTPS block enforces TLSv1.3 only, HSTS with includeSubDomains, and rate limits on /auth/login (5 r/s), /challenges/ (10 r/s), and all other routes (30 r/s).


Where data lives

Data Host path Notes
SQLite database ./data/ctfapp.db Back this up before upgrades
File uploads ./uploads/ Challenge attachment files
Redis data Docker named volume redis-data Ephemeral in development is fine; persist in production
TLS certs ./certbot/conf/ Managed by certbot

Health checks

The compose file defines health checks for both ctf-app and redis. The nginx service starts only after ctf-app is healthy. Check status:

docker compose ps

To run the full operational health check via the CLI:

uv run python cli.py health all --app-env production --json

Upgrading

  1. Pull the new code.
  2. Back up ./data/ctfapp.db.
  3. Rebuild and restart:
    make docker-build
    make docker-up
    
    SQLAlchemy's create_all is additive — it adds new tables but will not drop existing ones. For destructive schema changes you will need to run an Alembic migration (not yet integrated; track issue).

Production checklist

  • [ ] SECRET_KEY and ADMIN_KEY set to long random values (not defaults)
  • [ ] DOCKER_GID matches the actual host socket GID
  • [ ] ./data and ./uploads directories exist and are writable
  • [ ] TLS certificates in place and HTTPS nginx block uncommented
  • [ ] Firewall allows only ports 80 and 443 externally
  • [ ] ./data/ctfapp.db included in your backup schedule