Deploy with Docker
This page is a complete walkthrough of standing up a real event on the Docker container backend using the gzctf-platform-template repo. That template is now the supported way to run GZCTF: it ships a published image (no building from source), an interactive wizard that generates every secret for you, a Traefik front-end with automatic Let's Encrypt TLS, and all platform config in a single appsettings.json.
Docker is the simplest provider to operate: challenge instances spawn as host Docker containers, the A&D /flag is delivered as a read-only bind-mount, and you get precise docker diff plus post-game snapshot tarballs with no Kubernetes cluster to babysit.
If you are weighing Docker against Kubernetes, read /guide/deployment/provider first. For the exhaustive list of configuration keys, see /config/appsettings.
Quick start
The wizard prints the auto-generated admin password once, at the end — copy it before you close the terminal. Then browse to https://PUBLIC_ENTRY, log in as user Admin with that password, and change it from the profile menu.
There is no build step and no migration step. The image is pulled (dimasmaualana/gzctf:develop), and database migrations run automatically on first boot — the schema is created on launch, and later boots apply any new migrations before serving traffic.
Repository layout
Everything lives under two directories: compose/ (the docker-compose stack) and scripts/ (the wizard + config renderer). The k8s/ directory is the Kubernetes alternative — see Kubernetes below.
The make targets always run docker-compose from inside compose/ with the right overlay combination, so you never compose the -f flags by hand.
The wizard and config rendering
Two scripts own all the secret-handling. You normally only run the wizard once.
make wizard (scripts/wizard.sh)
Interactive first-time setup. It prompts for:
PUBLIC_ENTRY— the hostname participants type into their browser. No scheme, no path (e.g.ctf.example.com); the wizard rejects a value without a dot.ACME_EMAIL— where Let's Encrypt sends cert-expiry warnings.- SMTP relay (optional) — host / port / sender / username / password, enabling email verification + password reset. Skippable; you can set it later under
/admin/settings→ Email. - Cloudflare Turnstile captcha (optional) — site key + secret key, to slow down account-creation bots. Skippable; also settable later under
/admin/settings→ Captcha.
Everything else is auto-generated:
The Aa1 prefix on the generated passwords is deliberate: ASP.NET Identity's default password policy requires an uppercase letter, a lowercase letter, and a digit. Raw hex is all-lowercase and would silently fail UserManager.CreateAsync, leaving you with no Admin user at all.
The wizard writes compose/.env and compose/appsettings.json (rendering the latter from appsettings.example.json via sed), chmod 600s both, and refuses to overwrite an existing appsettings.json — delete it first if you really want to re-run.
make init-config (scripts/init-config.sh)
This is the non-interactive renderer, and it runs automatically as a prerequisite of make platform-up. If appsettings.json already exists it does nothing (idempotent). If .env is missing it copies .env.example for you and stops so you can fill in PUBLIC_ENTRY + ACME_EMAIL. On the edit-.env-by-hand path it generates any still-missing secrets — including AD_SSH_INTERNAL_SECRET (openssl rand -hex 32, written to both .env for the ssh-jump sidecar and appsettings.json's Ad.Ssh.InternalSecret for gzctf) — substitutes the {{.AdSshInternalSecret}} / {{.AdSshPublicPort}} placeholders, persists everything back to .env, and prints any freshly-generated admin password once.
What .env holds vs. appsettings.json
compose/.envholds the values docker-compose itself needs:WORKSPACE(project name),PUBLIC_ENTRY+ACME_EMAIL(Traefik routing + ACME),POSTGRES_PASSWORD(passed to thedbcontainer'sPOSTGRES_PASSWORD),AD_SSH_INTERNAL_SECRET(passed to thessh-jumpsidecar'sINTERNAL_SECRET), andADMIN_PASSWORD(passed to gzctf's first-boot seed). It is shell-styleKEY=VALUE.compose/appsettings.jsonholds the platform's full runtime configuration — the samePOSTGRES_PASSWORD,XorKey, andAdSshInternalSecretare substituted in here too, alongsideContainerProvider,Ad,HoneypotConfig,ForwardedOptions, and everything else (see Configuration).
The secret that appears in both files (POSTGRES_PASSWORD, AD_SSH_INTERNAL_SECRET) is written from a single source so the two copies can't diverge.
Do not rotate XorKey or POSTGRES_PASSWORD after first boot.
XorKey encrypts repo-binding tokens and registry passwords at rest — change it after data exists and every encrypted value in the DB becomes undecryptable. POSTGRES_PASSWORD is consumed by the db container only on its very first init; changing it later requires an ALTER USER inside the running database container plus a matching edit to appsettings.json's ConnectionStrings.Database. Set both once, before the first make platform-up, and leave them alone.
The services
compose.yml defines five services; the traefik overlay adds a sixth. Two of them (ssh-jump, wireguard) are the A&D engine's networking sidecars; the rest are the platform, its datastores, and the front-end.
Note that the gzctf service does not publish 8080 to the host in TLS mode — Traefik reaches it over the internal traefik network and terminates TLS for Host(PUBLIC_ENTRY) on the websecure entrypoint. The only host ports gzctf itself publishes are the honeypot listeners (see below). In standalone mode the overlay re-exposes 8080.
The honeypot ports
The seven ports gzctf publishes — 2222 (ssh), 3306 (mysql), 5432 (postgres), 6379 (redis), 11211 (memcached), 27017 (mongo), 9200 (elastic) — are honeypot listeners, not real services. Each one matches an entry in appsettings.json → HoneypotConfig.Ports. A team that connects to one of these from inside a challenge network is logged, and the chain-detector flags repeated probing. Drop any port mapping in compose.yml that you don't want exposed, or disable the matching entry in HoneypotConfig.Ports.
The gzctf service in detail
gzctf joins three networks — traefik (front-end reach), app_net (control-plane reach to db + cache), and challenges (so PlatformProxy mode can reach challenge container IPs). It has a :8080 healthcheck and a deploy.resources cap of 2 CPUs / 2 GB.
Mounting /var/run/docker.sock gives the gzctf container full control of the host Docker daemon — effectively root on the host. This is inherent to the Docker provider (it needs the daemon to manage challenge containers). Run this host as a dedicated, isolated event box. If that boundary is unacceptable, use the Kubernetes provider instead — see /guide/deployment/provider.
The A&D sidecars
The A&D / KotH engine needs two pieces of network plumbing that the jeopardy platform does not.
wireguard— the per-team VPN endpoint. It needscap_add: NET_ADMIN, SYS_MODULE,/dev/net/tun, and thesrc_valid_mark/ip_forwardsysctls to bring up the interface and route VPN traffic into the challenge networks. It shares thewg-configvolume withgzctf(/config↔/wg-config): gzctf renderswg0.conf, the sidecar's inotify loop applies it. It sits on a lonelywg-bootstrapbridge with no interface onapp_net, so a VPN client has no route to the control plane;AdVpnTopology(inside gzctf) attaches it to the challenge networks at runtime over the docker socket. The published51820:51820/udpmust matchAd.Vpn.ServerEndpointinappsettings.json.ssh-jump— the A&D SSH bastion. Players runssh <challenge-id>@PUBLIC_ENTRY -p 22022to land a shell inside their own challenge container; the username is the challenge id and the registered SSH key identifies the team. The bastion has no shell accounts — it proxies stdio todocker execin the resolved container through gzctf's internal endpoints, reaching gzctf atGZCTF_URL=http://gzctf:8080overapp_netand authenticating withINTERNAL_SECRET(which must equalAd.Ssh.InternalSecret—init-configwrites both from the same${AD_SSH_INTERNAL_SECRET}).
If you are running a jeopardy-only event with no A&D or KotH challenges, you can remove both sidecars: delete the wireguard and ssh-jump services, the gzctf wg-config volume mount, the wg-bootstrap network, and the wg-config volume.
Networks and volumes
make setup is what creates the two external networks (traefik, challenges) idempotently — they must exist before make platform-up, including in standalone mode, because the gzctf service attaches to both.
Configuration
All platform settings live in compose/appsettings.json, rendered from appsettings.example.json. This is standard ASP.NET Core JSON config — nested objects, not docker-compose __ env vars. The full key reference is at /config/appsettings; the load-bearing blocks for a Docker deploy are below.
ContainerProvider
The KubernetesConfig block is present but inert under Docker.
Ad (A&D / KotH engine)
Other blocks in appsettings.example.json worth knowing: HoneypotConfig (the seven listener ports + chain detection), FlagEgressConfig, CheatDetectionConfig, EmailConfig / CaptchaConfig (the wizard fills these if you opt in), RegistryConfig (private image registry creds, also settable from /admin/settings), and ForwardedOptions — which is already tuned for running behind Traefik (ForwardedHeaders: 5, one proxy hop, trusted private nets), so you don't need to touch it for the default stack.
Make-target reference
Do not run make platform-clean on a live event. It runs docker compose down -v, which deletes the postgres-data, gzctf-files, and wg-config volumes — wiping the database, uploaded files, and the WireGuard config. Use make platform-down to stop without data loss, and take a Postgres dump before any risky operation.
Updating the platform image
The stack runs a published image, so upgrades are a pull + recreate — no source checkout, no rebuild.
Migrations apply automatically on the new container's startup. make update-gzctf only touches gzctf. To refresh every image (including traefik/postgres/redis) and recreate any container whose digest changed, use make update (or make update-no-traefik in standalone mode). If you'd rather pull first and recreate later, make pull-gzctf / make pull download images without restarting anything.
No-TLS / local runs
For a workstation or an internal box without a public hostname:
This loads compose.standalone.yml instead of the Traefik overlay. The external traefik + challenges networks still need to exist (make setup creates them); the traefik network membership is harmless when no Traefik container is using it.
appsettings.json → ContainerProvider.PublicEntry was rendered from .env's PUBLIC_ENTRY as an HTTPS hostname. For standalone use, edit it to e.g. http://<host>:8080 so emails and scoreboard links resolve to the right place.
Importing example challenges
The companion TCP1PADTesting repo ships four ready-to-run challenges (two A&D, two KotH; OWASP web + heap pwn) you can import to populate a game and exercise the engine end to end. You import it via a repo binding — gzctf clones the repo itself and globs .gzevent recursively:
- Log in as
Admin, go to Repo Bindings → Add. - Set
RepoUrltohttps://github.com/TCP1P/TCP1PADTesting, leave the ref empty, setIntervalSecondsto60, and add no token (the repo is public). - Scan now, then watch the eight images build under admin → Builds (each challenge ships an auto-built service + checker, neither pinned).
Challenges import hidden; set the start/end times and unhide them in admin → game → Info. The A&D round settings on that Info page are covered in /guide/deployment/provider and the appsettings reference.
Host tuning for many challenge containers
A&D spins up one challenge container per team per challenge, plus checker containers. On a large event that is hundreds of containers on one host. The default kernel limits for inotify watches/instances and open file handles are tuned for a workstation, not a container farm, and you will hit too many open files or inotify-exhaustion failures (containers failing to start, file-watch features going silent) long before you run out of RAM or CPU.
Raise them on the host (these affect the daemon and all containers):
Persist higher limits in /etc/sysctl.d/99-gzctf.conf:
Apply without rebooting:
Also raise the open-files limit for the Docker daemon's process (the nofile ulimit). With systemd-managed Docker:
Network capacity ceilings. A single Docker bridge tops out around 1023 containers before the kernel returns "address space exhausted", and an egress /24 gives roughly 254 usable IPs. Host RAM typically allows a few thousand containers before either of those matters — so for very large fields, scale by sharding across additional challenge bridges or moving to macvlan, not by adding RAM. Plan bridge/subnet count against teams × challenges.
After raising limits, sanity-check while a game is running:
Kubernetes
The template also ships a k8s/ directory for running the same platform on k3s (or any other distribution), swapping ContainerProvider.Type to Kubernetes so challenge instances spawn as pods in the gzctf-challenges namespace. Apply the manifests in order:
Secrets are supplied via the gzctf-secrets Secret (postgres password, xor-key, admin password — generate them with openssl rand exactly as the compose path does, and don't rotate xor-key after first boot). The platform's appsettings.json lives in a ConfigMap (30-gzctf-config.yaml), which also grants the gzctf ServiceAccount RBAC scoped to the gzctf-challenges namespace. See k8s/README.md in the template, and /guide/deployment/provider for what the Kubernetes backend gives the A&D engine versus Docker.
Where to go next
- /guide/deployment/provider — Docker vs. Kubernetes: the trade-offs, and how to flip
ContainerProvider.Typebetween them. - /config/appsettings — the complete configuration key reference (every
ContainerProvider,Ad,Honeypot, andForwardedOptionskey, with defaults).