Skip to content

Configuration

CTFHive is configured entirely through environment variables. The app reads them from a .env file in the working directory (via python-dotenv) or from the process environment. Source of truth: ctfapp/config.py.

The config class hierarchy is:

Config (base)
├── DevelopmentConfig   APP_ENV=development  (default)
├── ProductionConfig    APP_ENV=production
└── TestingConfig       APP_ENV=testing

DevelopmentConfig generates random values for SECRET_KEY, ADMIN_KEY, and ENCRYPTION_KEY at startup if they are not set in the environment — so you can run the dev server without a .env file, but you will get a new secret every restart (sessions will be invalidated). Always set these explicitly even in development for persistent behavior.


Danger zone — the three secrets

Change all three before any public deployment

These three variables are the cryptographic foundation of the platform. The defaults will cause a RuntimeError if APP_ENV=production and BOOTSTRAP_ADMIN_IN_PRODUCTION is not set, because validate_production_runtime explicitly rejects them.

Variable Default Consequence of not changing
SECRET_KEY change-me-in-production Flask sessions are forgeable; any attacker can sign arbitrary session cookies
ADMIN_KEY change-me-admin-key Changing this invalidates every derived flag in the database and breaks the HMAC audit chain. Set once, never rotate mid-event.
ENCRYPTION_KEY change-me-encryption-key Encrypted DB columns (team secrets) become decryptable by anyone who knows the default

Generate secure values:

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

Run that three times — once per secret.

ADMIN_KEY is not rotatable mid-event

ADMIN_KEY is used as the HMAC key for flag derivation (HMAC-SHA3-256(ADMIN_KEY, team_secret || challenge_id)) and for the audit chain (sig_n = HMAC(ADMIN_KEY, sig_{n-1} || event || ts || payload)). Changing it after challenges are imported or after any flags have been submitted will invalidate all pre-computed flags and make the existing audit chain unverifiable. Set it once at provisioning time and keep it constant for the lifetime of the event. See Flag derivation and Audit chain.


Core / identity

Variable Default Purpose
APP_ENV development Selects config class: development, production, or testing
SECRET_KEY change-me-in-production Flask session signing key
ADMIN_KEY change-me-admin-key HMAC key for flag derivation and audit chain
ENCRYPTION_KEY change-me-encryption-key Key for encrypted database columns (team secrets)
CTF_NAME GIDK CTF Display name shown in the UI and page titles
CTF_DESCRIPTION Capture the Flag competition hosted by GrizzHacks8 Short description shown on the landing page
CTF_LOGO img/logo.png Path to the logo image (relative to static directory)
CTF_FAVICON favicon.svg Path to the favicon
DEFAULT_FLAG_PREFIX GRIZZ Prefix used in flag strings, e.g. GRIZZ{abc123...}
DEFAULT_THEME minimal Active theme name seeded to the DB on first run
DEFAULT_BG_ANIMATION matrix Background animation seeded to the DB on first run
PREFERRED_URL_SCHEME http http or https; set to https in production
SITE_URL http://localhost:5000 Canonical URL used in emails and absolute links
BASE_DOMAIN `` (empty) Apex domain for multi-tenant subdomain routing; set by CTFHive provisioner

Admin bootstrap

Variable Default Purpose
ADMIN_USERNAME admin Username for the auto-bootstrapped admin account
ADMIN_PASSWORD GrizzAdmin!2025 Password for the auto-bootstrapped admin account
ADMIN_EMAIL admin@grizzhacks8ctf.local Email for the auto-bootstrapped admin account
BOOTSTRAP_ADMIN_IN_PRODUCTION false Set to true to allow auto-bootstrap in production (not recommended; use cli.py admin bootstrap instead)

Auto-bootstrap behavior

On startup, create_app() checks whether any admin user exists. If none does and APP_ENV != production (or BOOTSTRAP_ADMIN_IN_PRODUCTION=true), it creates one using the values above. In production, skip auto-bootstrap and use the CLI instead:

uv run python cli.py admin bootstrap \
  --username yourname \
  --email you@example.com \
  --password "$(python -c 'import secrets; print(secrets.token_urlsafe(20))')"

Database and cache

Variable Default Purpose Production note
DATABASE_URL sqlite:///ctfapp.db SQLAlchemy database URI Use postgresql://user:pass@host/db
REDIS_URL redis://localhost:6379/0 Redis URI for cache and flag storage Required for rate limiting to work correctly across workers
RATELIMIT_STORAGE_URI memory:// Backend for Flask-Limiter counters Must be redis://... in multi-worker production

In-memory rate limiting is per-worker

RATELIMIT_STORAGE_URI=memory:// (the default) stores counters inside each gunicorn worker process. With -w 4 workers, clients can send 4× the configured limit before being blocked. Set RATELIMIT_STORAGE_URI=redis://localhost:6379/0 whenever you run more than one worker.


Session and security headers

Variable Default Purpose
SESSION_COOKIE_SECURE false Set to true in production (requires HTTPS)
SESSION_COOKIE_SAMESITE Lax SameSite cookie policy
TRUST_PROXY false Apply ProxyFix middleware; set true when behind nginx/Caddy
PROXY_FIX_X_FOR 1 Number of trusted X-Forwarded-For headers
PROXY_FIX_X_PROTO 1 Number of trusted X-Forwarded-Proto headers
PROXY_FIX_X_HOST 1 Number of trusted X-Forwarded-Host headers
PROXY_FIX_X_PREFIX 1 Number of trusted X-Forwarded-Prefix headers
SECURE_HEADERS_ENABLED true Emit security headers (CSP, X-Frame-Options, etc.)
ENABLE_HSTS false Include Strict-Transport-Security header
HSTS_SECONDS 31536000 max-age for HSTS (1 year)
CONTENT_SECURITY_POLICY (strict default — see config.py) Override the full CSP header string
MAX_CONTENT_LENGTH 33554432 (32 MB) Maximum request body size in bytes

Email

Variable Default Purpose
EMAIL_VERIFICATION_ENABLED false Require email verification on registration
MAILTRAP_API_KEY `` (empty) Mailtrap API key for sending email; required if email verification is enabled
MAIL_SENDER no-reply@grizzhacks8ctf.us From address for outbound email

Email verification

Setting EMAIL_VERIFICATION_ENABLED=true without a valid MAILTRAP_API_KEY will leave new registrations stuck in an unverified state. Ensure email delivery works before enabling in production.


Rate limiting

Variable Default Purpose
RATELIMIT_STORAGE_URI memory:// See Database and cache above
LOGIN_RATE_LIMIT 10 per minute Max login attempts per IP per minute
REGISTER_RATE_LIMIT 5 per minute Max registration attempts per IP per minute
PASSWORD_RESET_RATE_LIMIT 5 per hour Max password reset requests per IP per hour
TEAM_JOIN_RATE_LIMIT 10 per minute Max team join attempts per IP per minute
ADMIN_SENSITIVE_RATE_LIMIT 30 per minute Rate limit for sensitive admin actions
FLAG_WRONG_LIMIT 3 Wrong flag submissions before lockout
FLAG_LOCKOUT_SECONDS 30 Lockout duration after too many wrong submissions

Rate limit strings use Flask-Limiter syntax: "N per period" where period is second, minute, hour, or day. You can also write "N/minute".


Event timing

Variable Default Purpose
EVENT_STARTS_AT 2025-03-28T12:00:00+00:00 ISO 8601 datetime when the event opens
EVENT_ENDS_AT 2025-03-29T12:00:00+00:00 ISO 8601 datetime when the event closes

The app uses these to control challenge visibility and flag submission windows. Set them in UTC ISO 8601 format:

EVENT_STARTS_AT=2026-09-01T18:00:00+00:00
EVENT_ENDS_AT=2026-09-02T18:00:00+00:00

Labs and VPN (dynamic challenges)

Lab features are optional

All lab variables are ignored when LAB_ENABLED=false (the default). You do not need Docker or WireGuard for static challenges.

Variable Default Purpose
LAB_ENABLED false Enable per-team Docker lab spawning
LAB_DOCKER_HOST (host Docker socket) Docker daemon URL, e.g. unix:///var/run/docker.sock or tcp://...
LAB_WORKER_ID local-worker Identifier for this lab worker node
LAB_WORKER_MAX_CPU_UNITS 16000 CPU capacity units for scheduling (millicores × 1000)
LAB_WORKER_MAX_MEMORY_MB 32768 Memory capacity for scheduling in MB
LAB_SUBNET_POOL_CIDR 10.200.0.0/16 CIDR pool for per-team Docker bridge networks
LAB_SUBNET_PREFIX 24 Prefix length for each per-team subnet
LAB_APPLY_IPTABLES false Enforce per-team iptables FORWARD rules for network isolation
LAB_ISSUE_WG_CONFIG false Issue WireGuard client configs to players
LAB_VPN_PEER_POOL_CIDR 10.50.0.0/24 VPN IP pool for WireGuard peers
LAB_VPN_PLAYERS_PER_TEAM 4 Max WireGuard peers (VPN IPs) allocated per team
LAB_WG_INTERFACE wg0 WireGuard interface name on the server
LAB_WG_ENDPOINT vpn.example.com Public WireGuard server address given to players
LAB_WG_LISTEN_PORT 51820 WireGuard UDP listen port
LAB_WG_SERVER_PUBLIC_KEY `` (empty) Server's WireGuard public key included in player configs
LAB_REQUIRE_PINNED_IMAGES false Require image@sha256:... digest pins for all challenge images
LAB_ALLOW_BASENAME_IMAGE_FALLBACK true If false, a missing tag will not fall back to a same-named public image (closes a supply-chain hole)
INSTANCE_TTL_SECONDS 7200 Lab instance lifetime in seconds (2 hours)
INSTANCE_DNS_DOMAIN `` (empty) Wildcard DNS domain for dynamic instance URLs; empty = show localhost in UI
ADMIN_DOCKER_TIMEOUT_SECONDS 3 Timeout for Docker API calls from the admin panel

Dispatch service (lab orchestration)

Variable Default Purpose
DISPATCH_INTERNAL_URL http://localhost:5001 Internal URL of the dispatch (lab orchestration) service
DISPATCH_ADMIN_TOKEN change-me-dispatch-token Shared secret for the dispatch service API
DISPATCH_USE_REMOTE true When false, the app skips HTTP calls to dispatch and uses local Docker fallback
DISPATCH_PREFER_LOCAL true Prefer local Docker spawning over remote dispatch

Container registry

Variable Default Purpose
CTF_DEPLOY_MODE local local or remote; affects image resolution
REGISTRY_HOST `` (empty) Registry hostname for pulling challenge images (e.g. registry.example.com)
REGISTRY_USER `` (empty) Registry authentication username
REGISTRY_PASS `` (empty) Registry authentication password
CHALLENGES_ROOT <repo>/challenges Absolute path to the challenges directory; used by the importer and image builder

Minimal production .env

The smallest .env you need for a real single-server production deployment (static challenges only, no labs):

APP_ENV=production
SECRET_KEY=<generated>
ADMIN_KEY=<generated>
ENCRYPTION_KEY=<generated>

DATABASE_URL=postgresql://ctfuser:strongpassword@localhost/ctfapp
REDIS_URL=redis://localhost:6379/0
RATELIMIT_STORAGE_URI=redis://localhost:6379/0

CTF_NAME=My CTF 2026
DEFAULT_FLAG_PREFIX=FLAG

PREFERRED_URL_SCHEME=https
SITE_URL=https://ctf.example.com
TRUST_PROXY=true
SESSION_COOKIE_SECURE=true
ENABLE_HSTS=true

EVENT_STARTS_AT=2026-09-01T18:00:00+00:00
EVENT_ENDS_AT=2026-09-02T18:00:00+00:00