Skip to content

Quickstart — Deploy a CTF in ~5 minutes

This guide gets a working CTFHive instance running on your local machine with a real challenge imported and playable. All commands run from the repo root unless noted otherwise.

Prerequisites

  • Python 3.13+ and uv installed
  • Git clone of this repo
  • (Path B only) Docker and Docker Compose installed

Choose your path

1. Install dependencies

uv sync --all-extras

2. Create a .env file

Copy the example and change the three secrets. The app reads .env automatically via python-dotenv.

cp ctfapp/.env .env

Open .env and set at minimum:

SECRET_KEY=<random-32+-char-string>
ADMIN_KEY=<random-32+-char-string>
ENCRYPTION_KEY=<random-32-char-string>

# Optional — change if you want a different prefix on flags
DEFAULT_FLAG_PREFIX=GRIZZ

Never use the defaults in production

The shipped defaults (change-me-in-production, change-me-admin-key, change-me-encryption-key) will cause validate_production_runtime to raise a RuntimeError when APP_ENV=production. Change them before any public deployment.

3. Bootstrap the database

uv run python cli.py db bootstrap

This creates all tables. On first run the app also seeds a default admin account from ADMIN_USERNAME / ADMIN_PASSWORD (see Configuration).

4. Start the development server

make dev

This runs flask run --debug --port 5000. Open http://localhost:5000 in your browser.

5. Log in as admin

Username: value of ADMIN_USERNAME (default: admin) Password: value of ADMIN_PASSWORD (default: GrizzAdmin!2025)

Default admin password

The ADMIN_PASSWORD default is GrizzAdmin!2025 (in ctfapp/config.py). The example .env in ctfapp/.env shows GrizzAdmin (no suffix). Your effective password is whichever value is in your .env file. Change it before any public deployment.

1. Create a .env file at the repo root

cp ctfapp/.env .env

Change the three secrets in .env:

SECRET_KEY=<random-32+-char-string>
ADMIN_KEY=<random-32+-char-string>
ENCRYPTION_KEY=<random-32-char-string>

2. Start the full stack

make docker-up

This brings up three containers via docker-compose.yml:

Container Image Purpose
grizzhacks-ctf built from Dockerfile Gunicorn Flask app on port 5000 (localhost-only)
grizzhacks-redis redis:7-alpine Cache, rate limiting
grizzhacks-nginx nginx:1.27-alpine Reverse proxy on ports 80 / 443

The app is accessible at http://localhost (nginx proxies to the Flask container).

TLS in Docker Compose

The compose file includes nginx with Let's Encrypt volume mounts (certbot/conf, certbot/www). For local use, HTTP on port 80 is sufficient. HTTPS configuration is covered in Deploy with Docker Compose.

3. Wait for healthy containers

docker compose ps

The ctf-app container has a health check (curl -f http://localhost:5000/). The nginx container starts only after ctf-app is healthy.

4. Log in

Open http://localhost and sign in with the admin credentials from your .env.


Import an example challenge

The repo ships example challenges under challenges/. Import one with the CLI:

uv run python cli.py challenge import challenges/circular/circular.yml

The importer reads the YAML manifest, creates the challenge in the database, and copies associated files. For a full description of the YAML schema see Challenge format.

Validate before importing

uv run python cli.py challenge validate challenges/circular/circular.yml

This parses and validates the manifest without touching the database.


View the challenge and submit a flag

  1. Navigate to http://localhost:5000/challenges (or http://localhost for Docker).
  2. Click Circular — the challenge you just imported.
  3. The challenge is marked is_dynamic: false, so there is no container to spawn; download the provided files and solve the puzzle.

What does a derived flag look like?

For static challenges the flag is derived at import time. The format is:

<FLAG_PREFIX>{<first 32 hex chars of HMAC-SHA3-256>}

For example, with DEFAULT_FLAG_PREFIX=GRIZZ:

GRIZZ{a3f1...}

The derivation uses ADMIN_KEY as the HMAC key and the team's secret plus the challenge ID as the message. Changing ADMIN_KEY invalidates all derived flags — see Configuration for details.


What's next?