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_GIDset correctly (see above).- A valid
.envfile.
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:
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:
To run the full operational health check via the CLI:
Upgrading¶
- Pull the new code.
- Back up
./data/ctfapp.db. - Rebuild and restart:
SQLAlchemy's
create_allis 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_KEYandADMIN_KEYset to long random values (not defaults) - [ ]
DOCKER_GIDmatches the actual host socket GID - [ ]
./dataand./uploadsdirectories exist and are writable - [ ] TLS certificates in place and HTTPS nginx block uncommented
- [ ] Firewall allows only ports 80 and 443 externally
- [ ]
./data/ctfapp.dbincluded in your backup schedule