Skip to content

Deployment

NexCTF ships as a single application image plus three supporting services. This page covers a production Docker Compose deployment: what runs, what you must configure, TLS, backups, and upgrades.

What runs

The stack is four containers:

Service Image Role
app ghcr.io/nexctf/nexctf The application.
db postgres All persistent data.
cache valkey Sessions cache, rate limits, short-lived tokens, live updates.
s3 maxio S3-compatible object storage for challenge files and uploads.

The app container is not just the API. A supervisor inside it runs three processes:

  • caddy: terminates TLS, serves the built React frontend, proxies /api/* to the backend.
  • backend: the FastAPI application (WORKERS uvicorn workers).
  • worker: the background task runner. It fires scheduled jobs, runs plugin tasks, and does routine maintenance. Its queue lives in PostgreSQL tables, not in the cache, and Admin → Task queue shows it live.

The frontend is not a separate container

It is built at image build time and served as static files by Caddy from inside the app container. There is nothing to deploy separately.

Getting the compose file

curl -O https://raw.githubusercontent.com/nexctf/nexctf/main/compose.yml

The published file pins an exact image tag. Keep it pinned; see Upgrades.

Configuration

Every setting is an environment variable read by the app container. The compose file gives each one a default via ${VAR:-default}, so you override them from a .env file next to compose.yml, or from your orchestrator.

Application

Variable Default Notes
DOMAIN localhost The hostname Caddy serves, and the one everything else is derived from: the S3 hostname s3.<DOMAIN>, and the three URLs below.
SECRET_KEY (none) Signs session cookies. Required in production (see below).
ENVIRONMENT production development, staging or production. Anything but development enforces the SECRET_KEY check and marks cookies Secure.
FRONTEND_HOST (from DOMAIN) Public URL of the site. Used to build email links, OAuth redirects, and the CORS allow-list.
BACKEND_HOST (from DOMAIN) Public URL of the API. Used to build the OAuth redirect URI and the OAuth server issuer.
WORKERS 4 Backend worker processes.
BACKEND_CORS_ORIGINS (empty) Comma-separated extra origins. FRONTEND_HOST is always allowed.
TRUSTED_PROXY_COUNT 1 Number of proxies in front of the app. See Client IP addresses.
TLS_TERMINATED_UPSTREAM (empty) true when a proxy in front already terminates TLS. See TLS.
DEMO_DATA (empty) true seeds example challenges, teams and submissions. Never on a real event.
NEXCTF_PLUGINS (empty) Comma-separated plugin specifiers. See Installing plugins.
NEXCTF_DISABLED_PLUGINS (empty) Comma-separated plugin package names kept installed but not loaded. See Disabling a plugin.
METRICS_ENABLED true Serve Prometheus metrics. See Metrics.
METRICS_TOKEN (none) Bearer token a scraper must send. Outside development, metrics are only served once it is set.

SECRET_KEY is mandatory outside development

With ENVIRONMENT set to anything other than development, the application refuses to start unless SECRET_KEY is set explicitly in the environment. The compose file ships a placeholder default, so replace it. Anyone with that value can forge session cookies for any account.

python -c "import secrets; print(secrets.token_urlsafe(32))"

Changing it later invalidates every active session.

Setting DOMAIN is normally all you need:

.env
DOMAIN=ctf.example.com

FRONTEND_HOST, BACKEND_HOST and S3_PUBLIC_URL are each filled in from it (https://<DOMAIN>, and https://s3.<DOMAIN> for storage) whenever you leave them empty, matching the Caddyfile the container generates.

Override the derived URLs when your public URL is not https://<DOMAIN>

Derivation assumes HTTPS on the default port. If you reach the instance some other way, for example through the compose file's 8443 port mapping or behind a proxy that serves it under a different name, the derived values are wrong and everything that hands a URL to a browser or a third party breaks:

  • password-reset and email-verification links point at the wrong host
  • the OAuth redirect URI sent to external providers does not match what you registered
  • the OAuth server's discovery metadata advertises the wrong issuer

Set the ones that differ explicitly:

.env
DOMAIN=ctf.example.com
FRONTEND_HOST=https://ctf.example.com:8443
BACKEND_HOST=https://ctf.example.com:8443

Leaving DOMAIN at its localhost default is the other trap: the derived URLs become https://localhost, which works for a local trial and for nothing else.

Database

Variable Default Notes
POSTGRES_SERVER db
POSTGRES_PORT 5432
POSTGRES_USER nexctf
POSTGRES_PASSWORD nexctf Change it.
POSTGRES_DB nexctf
POSTGRES_TLS false Adds ssl=require to the connection.
POSTGRES_POOL_SIZE 20 Connections held open per backend worker.
POSTGRES_MAX_OVERFLOW 40 Extra connections allowed above the pool.
POSTGRES_POOL_TIMEOUT 10 Seconds to wait for a free connection.
POSTGRES_AUTH_POOL_SIZE 5 Connections held open per backend worker for authenticating requests, kept apart from the main pool.
POSTGRES_AUTH_MAX_OVERFLOW 10 Extra connections allowed above the authentication pool.

Cache

Variable Default Notes
REDIS_HOST cache
REDIS_PORT 6379
REDIS_PASSWORD nexctf Change it.
REDIS_DB 0
REDIS_TLS false

Object storage

Variable Default Notes
S3_HOST s3 Internal hostname the backend talks to.
S3_PORT 9000
S3_BUCKET nexctf
S3_ACCESS_KEY admin Change it.
S3_SECRET_KEY admin Change it.
S3_REGION us-east-1
S3_TLS false Connect to the storage over HTTPS.
S3_PUBLIC_URL (from DOMAIN) Browser-facing URL used inside presigned download links.

With an external S3, point S3_HOST, S3_PORT, S3_TLS and S3_PUBLIC_URL at it. The bundled s3 service is then unused: remove it from compose.yml, along with the app service's depends_on entry for it.

s3.<DOMAIN> needs its own DNS record

Challenge files are served through presigned URLs that the browser fetches directly, so the address inside them has to resolve publicly. S3_PUBLIC_URL is derived as https://s3.<DOMAIN>, which is where Caddy already publishes the storage service, but that subdomain still needs a DNS record of its own and a certificate covering it, like the main one. Without them every download fails for players.

Override it only when storage is reachable somewhere else:

.env
S3_PUBLIC_URL=https://files.example.net

With DOMAIN left at localhost, the derived value only resolves on your own machine.

TLS

Caddy inside the container generates its own certificate by default. It does not use Let's Encrypt or any other ACME provider. You have two supported options.

The compose file publishes the container's ports on every interface of the host:

ports:
  - "8080:80"
  - "8443:443"

Put a PEM certificate and key in a directory and mount it at /certs. The names are fixed:

compose.yml
services:
  app:
    volumes:
      - ${CERTS_PATH}:/certs:ro
Path Contents
/certs/cert.pem Certificate chain, covering DOMAIN and s3.DOMAIN.
/certs/key.pem Private key.

Both must be present. If either is missing, Caddy falls back to a self-signed certificate. The startup log line [start] TLS: using custom certificates confirms which path was taken.

Terminate TLS in your existing proxy and tell the container so:

.env
TLS_TERMINATED_UPSTREAM=true
TRUSTED_PROXY_COUNT=2

Caddy then serves plain HTTP for both DOMAIN and s3.DOMAIN, with no certificate of its own, and keeps the X-Forwarded-For your proxy sends from a private address instead of replacing it. The startup log line [start] TLS: terminated upstream, serving plain HTTP confirms it.

Forward both hostnames to the container's HTTP port, 8080 on the host. Caddy routes on the Host header, so the proxy must pass it through unchanged. When the proxy runs on the same host, bind the ports to loopback so nothing reaches the container around it:

ports:
  - "127.0.0.1:8080:80"

TRUSTED_PROXY_COUNT counts your proxies plus the bundled Caddy, so 2 for a single proxy in front.

Client IP addresses

Rate limiting, the event log and session records all record a client IP. TRUSTED_PROXY_COUNT decides where it comes from:

Value Behaviour
0 The direct connection address. X-Forwarded-For is ignored entirely.
N The Nth entry from the right of X-Forwarded-For.

Count every proxy that appends to the header, including the bundled Caddy. The default of 1 is right when clients connect to the container directly. Setting it too high lets a client spoof its own IP and slip past rate limits; too low and every request appears to come from your proxy, so one player's rate limit throttles everyone.

Starting up

docker compose up -d

On every start, before serving traffic, the app container:

  1. Installs anything listed in NEXCTF_PLUGINS.
  2. Runs core database migrations.
  3. Runs plugin database migrations.
  4. Loads fixtures (plus demo data if DEMO_DATA=true).
  5. Creates the default admin account if it does not already exist.
  6. Starts Caddy, the backend and the worker.

Any failure in steps 1 to 3 stops the container rather than starting a half-migrated instance. docker compose logs app has the reason, on a line starting with [start].

The container is healthy once GET /api/v1/info answers.

Logs

Outside development, each process writes JSON lines to the container output and to a rotated file of its own under /var/log/nexctf: api.log for the backend, worker.log for the worker, migrate.log for the migrations run at start. The compose file keeps that directory in the app-logs volume. Every line names the process it came from in its service field, and a line logged during a request carries the signed-in user.

Variable Default Notes
LOG_LEVEL INFO DEBUG, INFO, WARNING, ERROR or CRITICAL.
LOG_FORMAT json json, or console for human-readable lines. console in development.
LOG_FILES true Write the log files. Off in development. Turn it off when your orchestrator already collects the container output.
LOG_DIR /var/log/nexctf Where the files go.
LOG_MAX_BYTES 50000000 Size at which a file is rotated.
LOG_BACKUPS 5 Rotated files kept per process.
LOG_SQL false Log every SQL statement. Verbose; for debugging only.

Health checks the container makes on itself are left out of the access log.

Metrics

The backend serves Prometheus metrics at /metrics on the main hostname, next to /api. They are on by default, but outside development nothing is served until METRICS_TOKEN is set; the backend logs a warning at start to say so.

.env
METRICS_TOKEN=a-long-random-value

The scraper sends it as a bearer token:

prometheus.yml
scrape_configs:
  - job_name: nexctf
    scheme: https
    authorization:
      credentials: a-long-random-value
    static_configs:
      - targets: ["ctf.example.com"]
Metric What it tracks
nexctf_build_info, nexctf_plugin_info Running version, and each plugin with its state.
nexctf_ctf_time_seconds, nexctf_ctf_phase Event start, end and freeze times, and the phase the event is in.
nexctf_users, nexctf_teams, nexctf_challenges, nexctf_active_sessions Registered accounts, teams, challenges, and live sessions.
nexctf_submissions_total, nexctf_challenge_solves, nexctf_hint_unlocks_total Gameplay: submissions by outcome, solves per challenge, hint unlocks.
nexctf_login_failures_total, nexctf_captcha_failures_total, nexctf_rate_limited_total Failed sign-ins by reason, rejected captchas, requests refused by a rate limit.
nexctf_emails_total Emails sent, failed, or dropped because email is not configured.
nexctf_queue_* The task queue: jobs by state, and the count and run times of recently finished ones.

METRICS_ENABLED=false removes the endpoint altogether.

The first admin account

Step 5 above creates one account from these variables, and only if no account with that username exists yet:

Variable Default
DEFAULT_ADMIN_USERNAME admin
DEFAULT_ADMIN_PASSWORD admin
DEFAULT_ADMIN_TOKEN (none)

DEFAULT_ADMIN_TOKEN optionally provisions an API token for that account in the same step, so automation can talk to a fresh instance without a login round-trip. It must start with nexctf_ or the container refuses to start.

Change the admin password at first login

The defaults are admin / admin, and the login page is public.

There is no command-line password reset

manager create-admin only creates the account when it is missing; running it again on an existing account does nothing. If you lose the password, the recovery paths are:

  • another admin issuing a reset link from Admin → Users, or
  • the self-service Forgot password flow, if email is configured.

A single-admin instance with no SMTP has neither. Add a second admin account, or configure email, before the event starts.

Backups

Persistent state lives in named volumes.

Volume Contents Back up?
app-db-data PostgreSQL: users, teams, challenges, submissions, sessions, settings. Yes. This is the event.
app-s3-data Challenge files, uploads, and the database dumps described below. Yes. Not reconstructible.
caddy-data Caddy's certificate store. Optional; regenerated on start.
app-logs The log files. Only if you keep logs.
app-redis-data Cache, rate-limit counters, pending reset tokens, OAuth codes. No.

Redis holds nothing you cannot lose. Sessions themselves are rows in PostgreSQL, so a cache wipe does not sign everyone out; it only drops in-flight password-reset links and rate-limit counters.

Database dumps from the admin panel

Admin → Backups dumps the database without leaving the browser. Backup now runs pg_dump in PostgreSQL's custom format and stores the result in the S3 bucket under the backups/ prefix, named for the moment it was taken, what triggered it, and the schema version it was taken at:

backups/nexctf-20260913T041500Z-manual-d4a9c17e3b52.dump

The trailing component is the Alembic revision, and it is what makes a dump safe to restore later. The one before it is the source, shown in the list's Source column:

Source Taken by
Manual Backup now, or POST /api/v1/admin/backup.
Scheduled A backup_database job.
Pre-restore A restore, of the database it is about to replace.

Dumps taken before NexCTF 0.10.1 carry no source and show an empty cell. Each row in the list can be downloaded to your own machine, restored, or deleted.

Backups cover the database only

Uploaded files live in the S3 bucket, not in the dump. Restoring an older database against a newer bucket leaves challenge attachments pointing at objects that have since been deleted. Back up the app-s3-data volume on its own schedule; the two are only consistent if you take them together.

Marked Beta

The backup screen carries a Beta badge. Treat a restore as something to rehearse on a scratch instance before you need it during an event.

Scheduling them

A backup_database job in the Scheduler takes the same dump on a cron expression, and its keep_last parameter prunes older ones afterwards, so the prefix does not grow without limit. Anything from 1 to 100 is accepted, and it defaults to 7. Manual and scheduled dumps count towards it; pre-restore dumps never do, and are never pruned, so delete those by hand once you no longer need to undo a restore.

Restoring

Restore on a row replaces the entire database with that dump. The platform does the sequence for you:

  1. Stops the backend and the worker.
  2. Dumps the current database as a Pre-restore backup, so the restore itself can be rolled back.
  3. Replaces the database (pg_restore --clean --single-transaction) and flushes the cache.
  4. Runs core and plugin migrations.
  5. Starts the backend and the worker again.

The instance is unavailable for the length of that, and every user is signed out. The outcome of the last restore is reported back on the Backups page, whether it succeeded or not.

Restore refuses a dump from a newer NexCTF

The Alembic revision in the filename is checked against the migrations the running version knows. A dump taken on a newer release is rejected rather than restored into a schema that cannot hold it. Upgrade first, then restore.

Restoring the other way, into a newer NexCTF, is fine: step 4 migrates the restored schema forward.

Outside the bundled image

Orchestrating its own restart is something only the bundled image can do. Anywhere else the button reports as much, and the operator does it by hand: stop the app, then run

manager restore backups/nexctf-20260913T041500Z-manual-d4a9c17e3b52.dump

The host needs the PostgreSQL client binaries (pg_dump, pg_restore) for either path to work at all. The bundled image ships them.

From the command line

Nothing about this is admin-panel-only. A token carrying the admin.backup scope drives the same endpoints, which is the supported way to pull a nightly dump onto storage you control:

$ curl -H "Authorization: Bearer nexctf_..." \
    "https://ctf.example.com/api/v1/admin/backup"
$ curl -H "Authorization: Bearer nexctf_..." -X POST \
    "https://ctf.example.com/api/v1/admin/backup"

Downloading a dump takes two requests. The first exchanges its key for a ticket, which works once and only for 60 seconds; the second fetches the dump with that ticket, and needs no other credential:

$ curl -H "Authorization: Bearer nexctf_..." -X POST \
    "https://ctf.example.com/api/v1/admin/backup/download?key=backups/nexctf-20260913T041500Z-manual-d4a9c17e3b52.dump"
{"status": "SUCCESS", ..., "data": {"ticket": "..."}}
$ curl -o nexctf.dump \
    "https://ctf.example.com/api/v1/admin/backup/download?ticket=..."

Take a dump immediately before every upgrade.

Upgrades

Migrations run automatically at container start, so an upgrade is a tag bump:

  1. Back up the database (Admin → Backups, or POST /api/v1/admin/backup) and the S3 volume.
  2. Edit the app image tag in compose.yml.
  3. docker compose pull app && docker compose up -d app
  4. Watch docker compose logs -f app for the [start] lines.

Pin the image tag

Migrations are applied on start and are not reversible by the platform. Running :latest means an unattended docker compose pull can migrate your database mid-event. Pin an exact version and bump it deliberately.

Scaling

The knobs that matter, and how they interact:

  • WORKERS: backend processes in the app container. Start at the number of CPU cores available to it.
  • POSTGRES_POOL_SIZE / POSTGRES_MAX_OVERFLOW, and their POSTGRES_AUTH_* counterparts: connections per worker. The ceiling is WORKERS × (POOL_SIZE + MAX_OVERFLOW + AUTH_POOL_SIZE + AUTH_MAX_OVERFLOW), and the bundled PostgreSQL is started with max_connections=350. At the defaults that is 4 × 75 = 300, within budget, but raising WORKERS without raising max_connections will exhaust it.
  • The worker is a single process per app container, separate from WORKERS. Jobs are queued in PostgreSQL and claimed with row locks, so a job fires once even if a second copy of the container runs a second worker, though the restore flow still assumes a single container.

The scoreboard is cached and pushed over Server-Sent Events, so a large field of players watching the ranking does not translate into database load.