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 (
WORKERSuvicorn 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¶
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.
Changing it later invalidates every active session.
Setting DOMAIN is normally all you need:
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:
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:
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:
Put a PEM certificate and key in a directory and mount it at /certs. The names are
fixed:
| 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:
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:
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¶
On every start, before serving traffic, the app container:
- Installs anything listed in
NEXCTF_PLUGINS. - Runs core database migrations.
- Runs plugin database migrations.
- Loads fixtures (plus demo data if
DEMO_DATA=true). - Creates the default admin account if it does not already exist.
- 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.
The scraper sends it as a bearer token:
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:
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:
- Stops the backend and the worker.
- Dumps the current database as a Pre-restore backup, so the restore itself can be rolled back.
- Replaces the database (
pg_restore --clean --single-transaction) and flushes the cache. - Runs core and plugin migrations.
- 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
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:
- Back up the database (Admin → Backups, or
POST /api/v1/admin/backup) and the S3 volume. - Edit the
appimage tag incompose.yml. docker compose pull app && docker compose up -d app- Watch
docker compose logs -f appfor 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 theappcontainer. Start at the number of CPU cores available to it.POSTGRES_POOL_SIZE/POSTGRES_MAX_OVERFLOW, and theirPOSTGRES_AUTH_*counterparts: connections per worker. The ceiling isWORKERS × (POOL_SIZE + MAX_OVERFLOW + AUTH_POOL_SIZE + AUTH_MAX_OVERFLOW), and the bundled PostgreSQL is started withmax_connections=350. At the defaults that is4 × 75 = 300, within budget, but raisingWORKERSwithout raisingmax_connectionswill exhaust it.- The worker is a single process per
appcontainer, separate fromWORKERS. 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.