The purpose of this repository is to propose a simple setup and challenge management tool for CTFd infrastructures. It has been tested and validated on PolyCyber's CTFd infrastructures (PolyPwn2025, PolyPwn2026, as well as the internal CTFd for PolyCyber).
Bash script that automates the installation and configuration of a CTFd server using the Zync plugin and its dedicated instancer Galvanize.
Bash script for building, ingesting, and synchronizing CTF challenges with support for Docker containers and Docker Compose.
- Operating System: Tested and verified on:
- Ubuntu Server 24
- Ubuntu Server 25
- Debian 12
- Privileges: The script must be executed as root (automatically uses sudo if necessary)
- Docker: Installed and functional
- curl, jq, yq: For API calls and YAML/JSON processing (automatically verified)
- Challenge repository: Folder structure with
challenge.ymlfiles
-
Clone this repository:
git clone https://github.com/CorentinFelten/infra cd infra -
Run the installation script and follow the instructions:
./setup.sh --domain <your-domain.com>
-
Go to the configured server URL
- Configure the CTF event
- Navigate to the admin configuration panel:
Admin Panel-->Plugins-->Zync Config - Enter your Galvanize instancer's URL and JWT secret generated by the setup
JWT secret: if you use the provided setup workflow, the generated JWT secret is injected automatically into the CTFd container as the
ZYNC_JWT_SECRETenvironment variable (fromdeploy/.env), and the same secret is written to the Galvanize config. You therefore do not need to enter the JWT secret manually in the Zync Config panel.
| Option | Description | Required |
|---|---|---|
--domain domain/IP |
URL/domain of your CTFd server | ✅ Yes |
--working-folder DIR |
Working directory (default: /home/$USER) |
❌ No |
--theme DIR/URL |
Enables the use of a personalised theme | ❌ No |
--backup-schedule TYPE |
Database backup frequency (daily (default), hourly, 10min) |
❌ No |
--instancer-url URL |
Use an external Galvanize instancer instead of deploying one locally | ❌ No |
--no-instancer |
Skip Galvanize setup entirely (deploy it separately later) | ❌ No |
--dns-provider NAME |
DNS provider for wildcard TLS certs (default: cloudflare) |
❌ No |
--acme-email EMAIL |
Email address for Let's Encrypt certificates (required for HTTPS) | ✅ HTTPS |
--no-https |
Deployment without HTTPS (automatically enabled for IP addresses) | ❌ No |
--help |
Display help | ❌ No |
--instancer-urland--no-instancerare mutually exclusive.
# Basic installation with domain (includes local Galvanize instancer by default)
./setup.sh --domain example.com --acme-email admin@example.com
# Basic installation with IP address - automatically uses the --no-https option
./setup.sh --domain 192.168.123.123
# Installation with custom directory
./setup.sh --domain example.com --working-folder /opt/ctfd
# Installation with custom theme
./setup.sh --domain example.com --theme /home/user/my-custom-theme
# Installation with custom theme downloading a theme directly from github
./setup.sh --domain example.com --theme https://github.com/user/theme.git
# Custom ACME email for Let's Encrypt
./setup.sh --domain example.com --acme-email admin@example.com
# Hourly backup
./setup.sh --domain example.com --backup-schedule hourly
# Backup every 10 minutes
./setup.sh --domain example.com --backup-schedule 10min
# Use an external Galvanize instancer
./setup.sh --domain example.com --instancer-url https://instancer.example.com
# Skip Galvanize entirely (deploy it independently later)
./setup.sh --domain example.com --no-instancer
# Display help
./setup.sh --helpIf you use the --theme option, the script will automatically mount the custom theme folder in the docker-compose.yml.
By default, setup.sh deploys Galvanize as part of the CTFd Docker Compose stack. Two alternatives are available:
--instancer-url URL— point CTFd at an already-running Galvanize instance; no local container is started.--no-instancer— skip Galvanize entirely during setup. You can deploy it independently later by running the instancer service manually with its own config (seeconfig/galvanize/config.yamlfor the template).
After running setup.sh, the following layout is created under <working-folder>/deploy/:
deploy/
├── docker-compose.yml # Active compose file (copied from repo)
├── .env # Environment variables and generated secrets
├── .secrets # Plaintext copy of generated secrets (chmod 600)
├── traefik-config/ # Traefik static & dynamic configs, letsencrypt storage
├── ctfd/ # CTFd Dockerfile and custom entrypoint
│ └── plugins/zync/ # CTFd instancer plugin clone
├── ansible-ssh/ # Ansible SSH key pair (local instancer only)
├── data/ # Runtime data (database, uploads, galvanize)
│ ├── CTFd/
│ ├── mysql/
│ ├── redis/
│ └── galvanize/ # Galvanize config and SQLite DB (local instancer only)
└── cron_backup.log # Backup cron job log
Challenges are described with the same challenge.yml format used by CTFd's
official ctfcli. Existing ctfcli-compatible
challenge repositories therefore work here unchanged, and challenges authored
for this tool stay portable back to ctfcli.
What this tool does not do is wrap or depend on ctfcli. Ingestion and
synchronization are implemented natively in Bash against the CTFd REST API
(curl + jq). This keeps the tool dependency-light (no Python/pip environment
to manage) and lets a single run build the Docker images, register the
challenge in CTFd, and wire up the Galvanize/Zync
deployment together.
The following challenge.yml fields are honored: name, category,
description, value, type (ctfcli's standard/dynamic, plus the
Galvanize-specific zync extension), state, connection_info, attempts,
attribution, the dynamic-scoring initial/minimum/decay values, flags,
files, hints (including title, cost, and key-based prerequisite
gating), tags, topics, requirements (either a bare list of prerequisites
or the {prerequisites, anonymize} object form), and an extra map passed
through verbatim for CTFd plugin fields.
The first time you run an action that talks to CTFd (ingest, sync, or the
default all), the tool prompts for your CTFd instance URL and an admin
Access Token. Generate this token beforehand from the CTFd web UI, logged in
as an administrator: Settings → Access Tokens → Generate. The account must
have admin rights, since ingesting and managing challenges uses admin-only API
endpoints.
The URL and token are saved to <working-folder>/.ctfd/config and reused on
subsequent runs, so you are only prompted once.
| Action | Description |
|---|---|
all |
Build + ingest (default) |
build |
Build Docker images only |
ingest |
Ingest challenges into CTFd |
sync |
Synchronize existing challenges |
status |
Display status and statistics |
cleanup |
Stop compose stacks and clean up Docker images (respects --categories/--challenges filters) |
| Option | Description | Required |
|---|---|---|
--repo REPO |
Name of the challenge repository present in the working directory | ✅ Yes |
--action ACTION |
Action to perform (all (default), build, ingest, sync, status, cleanup) | ❌ No |
--working-folder DIR |
Working directory (default: /home/$USER) |
❌ No |
--config FILE |
Load configuration from a file | ❌ No |
| Option | Description |
|---|---|
--categories LIST |
List of categories to process (comma-separated) |
--challenges LIST |
List of specific challenges to process (comma-separated) |
| Option | Description |
|---|---|
--dry-run |
Simulation mode (shows actions without executing them) |
--force |
Force operations (rebuild, overwrite) |
--parallel-builds N |
Number of parallel builds (default: 4) |
| Option | Description |
|---|---|
--debug |
Enable debug output |
--skip-docker-check |
Skip Docker daemon check |
--help |
Display help |
--version |
Display version information |
# Full configuration (build + ingest)
./challenges.sh --repo challenge_repo
# Build only for certain categories
./challenges.sh --action build --repo challenge_repo --categories "web,crypto"
# Synchronization with forced update
./challenges.sh --action sync --repo challenge_repo --force
# Simulation mode to see planned actions
./challenges.sh --repo challenge_repo --dry-run
# Processing specific challenges
./challenges.sh --action build --repo challenge_repo --challenges "web-challenge-1,crypto-rsa"
# Parallel build with 8 threads
./challenges.sh --action build --repo challenge_repo --parallel-builds 8
# Display status
./challenges.sh --action status --repo challenge_repo
# Clean up Docker images
./challenges.sh --action cleanup --repo challenge_repoCreate a .env file with KEY=VALUE pairs:
REPO=challenge_repo
WORKING_DIR=/opt/ctf
PARALLEL_BUILDS=8
FORCE=true
DEBUG=falseUsage:
./challenges.sh --config .env- Update system packages
- Install dependencies
- Add official Docker repository
- Install Docker CE, Docker Compose, etc.
- Enable Docker service to start on boot
- Configure user groups
If the --theme flag is used:
- Mounts the
theme/custom/folder in the CTFd container - Enables the use of custom themes
- Verify Docker and daemon availability
- Check for required system tools (curl, jq, yq)
- Analyze the challenge repository structure
- Identify Docker and static challenges
- Sequential or parallel image building
- Support for
--forcemode for complete rebuild - Error handling with detailed reports
- Installation via the CTFd REST API into the CTFd instance
- Challenges are installed in dependency order:
requirementsare topologically sorted so a prerequisite is always created before the challenge that needs it. Circular dependencies are detected and reported. - Atomic install with rollback: if attaching flags, files, hints, tags, topics, or requirements fails, the partially-created challenge is removed so a failed ingest leaves no half-registered challenge behind.
- Docker Compose image tag validation before ingest, and duplicate detection (existing challenges are skipped — use
syncto update them).
- Updates existing challenges in place: the challenge is PATCHed, so its CTFd ID never changes.
- Re-pushes owned sub-resources — flags, hints, tags, topics, and files — by clearing and recreating them from
challenge.yml, so edits to any of them actually propagate. - Requirements are resolved in a second pass, after every challenge has been synced, so a prerequisite referenced by name resolves correctly regardless of processing order.
- Option to backup before synchronization, and
--forcemode for overwriting.
- CTFd stores requirement prerequisites as raw challenge IDs and does not cascade-clean them: deleting and recreating a challenge would give it a new ID and silently orphan every challenge that required it.
- To avoid this, sync never deletes-and-recreates a challenge (it patches in place), and the tool refuses to delete a challenge that other challenges list as a prerequisite unless explicitly forced.
- Remove Docker images associated with challenges
- Dry-run mode available
challenge_repo/
├── challenges/ # (optional, detected automatically)
│ ├── web/
│ │ ├── challenge-1/
│ │ │ ├── challenge.yml # Challenge configuration
│ │ │ ├── Dockerfile # Docker image (for type: zync)
│ │ │ ├── src/ # Source code
│ │ │ └── files/ # Challenge files
│ │ └── challenge-2/
│ ├── crypto/
│ └── pwn/
name: "MyChallenge"
author: Challenge_Author
category: AI
description: |-
## Description (French)
Petite description en français
## Description (English)
Short description in English
flags:
- flag{flag_to_find}
tags:
- AI
- A:Challenge_Author
# Prerequisites: a bare list of challenge names/IDs...
requirements:
- "Rules"
# ...or the object form, to show locked challenges as "???" instead of hiding them:
# requirements:
# prerequisites:
# - "Rules"
# anonymize: true
# If files needed
files:
- "files/hello_world.txt"
# If hints needed, choose the cost
hints:
- Interesting hint
- {
cost: 10,
content: "Interesting payed hint"
}
# Gated hint: stays hidden until the player unlocks the hints it requires.
# Give the prerequisite hints a `key` and reference those keys in `requirements`.
- key: step1
cost: 5
content: "First step"
- cost: 20
content: "Second step (only revealed after step1 is unlocked)"
requirements:
- step1
value: 5
type: zync # or type: dynamic / static
# Following options are for type: zync only. See https://github.com/28Pollux28/galvanize/blob/master/data/challenges/example/challenge.yml for up-to-date config
playbook_name: http # Use 'http' for web challenges, 'tcp' for TCP challenges, or 'custom_compose' for custom Docker Compose setups
deploy_parameters:
image: nginx:alpine # Docker image to deploy
unique: false # Set to true if there needs to be a unique instance for all players
published_ports: # Ports to expose from the container (Only for 'tcp' playbooks)
- 80 # Port to expose
compose_definition: |- # Docker Compose definition (Only for 'custom_compose' playbooks)
version: '3'
services:
web:
image: nginx:alpine
ports:
- "80:80"
env: # Environment variables passed to the container
FLAG: "flag{flag_to_find_in_env}"
TZ: Europe/Zurich
resource_limits: # Override default resource limits (optional)
cpus: "1"
memory: "512M"
pids_limit: 256The setup script automatically generates:
- CTFd secret key (32 characters)
- Database password (16 characters)
- Database root password (16 characters)
- Galvanize JWT secret (48 characters)
All secrets are written to <deploy-dir>/.secrets (chmod 600) and to .env.
Re-running setup is safe: if secrets already exist in
.env, they are preserved. Only missing secrets are generated, so runningsetup.shagain will not break existing containers.
This project is licensed under the MIT License - see the LICENSE file for details.
These scripts were initially developed for the PolyCyber team to automate the installation and management of CTFd servers. They were built to work specifically with the Galvanize instancer and Zync plugin.