A modern, open-source platform for running Capture the Flag competitions —
self-hosted, real-time, and batteries-included.
Highlights · Features · Quick start · Deploy · Docs
Flagpost is a complete competition platform for CTF organisers: publish challenges, score solves the moment they land, support competitors, automate the whole event, and — when it wraps — hand out certificates and generate a branded post-event report. It's multi-tenant from the ground up (run many competitions from a single install), real-time throughout (WebSockets, not polling), translatable (ships in English, French, Spanish & Polish), and deploys as a one-command production stack.
Sign-in is local (username + optional email) or an external identity provider — OIDC & OAuth 2.0 (one-click Google, Microsoft, GitHub & Discord presets, or Okta, Keycloak, Entra, or anything with a discovery document), SAML 2.0, or LDAP / Active Directory. Optional AI assistants (administrator + competitor) plug into an OpenAI-compatible provider you configure — off by default until you enable them.
The things that set Flagpost apart — every one of them built and working today:
- ⚡ Real-time everything. The scoreboard, "who's viewing this challenge" presence, notifications, and support-ticket threads all update live over WebSockets. No refreshing, no polling.
- 🤖 A visual automation engine. A no-code When → If → Then rule builder: on any event, run actions — notify, call a (SSRF-hardened) webhook, send email, release a hint, unlock a bonus challenge, open a survey, adjust scores, grant an award, freeze the board, or post an announcement. Includes time-based triggers like "an hour before the end, open the feedback survey."
- 📝 Live collaborative notes. True CRDT (Y.js) co-editing: a shared scratchpad per team on each challenge, and private staff notes on each ticket — everyone types at once, conflict-free.
- 🤖 Optional AI assistants. An administrator assistant (query stats, triage tickets, summarise feedback) and a competitor assistant, wired to an OpenAI-compatible provider you configure. Ships inert — nothing calls out until an admin turns it on.
- 🛡️ Permissions as data. RBAC that isn't hard-coded: a visual role editor lets you clone the built-ins and craft custom roles with granular, per-competition or site-wide scope.
- 🧩 A genuinely deep challenge model. Static, regex, and multiple-choice flags; dynamic (decay) scoring; prerequisite unlock chains; scheduled/waved release; tags & difficulty; connection info for live services; and per-competition guess caps.
- 🏆 A scoreboard done right. Live standings with first-blood, parallel brackets/divisions, a freeze for the final stretch, a public spectator board, and a CTFtime feed so rated events just work.
- 🎓 Run the event end to end. When the dust settles, hand competitors shareable participation & placement certificates from a template you design, and turn the finished competition into a branded post-event report (PDF or HTML) — executive summary, participation, results, per-challenge analysis, and support load — in a click.
- 🔁 CTFd-compatible & fully portable. Bulk challenge import/export in the ctfcli YAML format, plus a one-click, full-fidelity platform backup (export/import any section of your install).
- 🔐 Bring your own identity provider. OIDC & OAuth 2.0 (PKCE, sub-first account linking, just-in-time provisioning, with one-click presets for Google, Microsoft — single- and multi-tenant — GitHub & Discord), SAML 2.0 (signature-before-trust, SP-metadata endpoint), and LDAP / Active Directory (a directory bind behind the ordinary login form) — alongside local accounts, so an existing Google/Okta/Keycloak/Entra/Shibboleth or on-prem directory just works, while local login stays break-glass.
- 🌍 Speaks your competitors' language. The interface is internationalised and ships in English, French, Spanish & Polish, translated by the community through Crowdin — each person picks their language, and anything not yet translated falls back to English rather than breaking.
- 🔒 Secure by default. argon2 hashing, a per-install auto-derived JWT secret (no shipped credentials — a first-run setup wizard creates your owner account), SSRF-hardened webhooks, ReDoS-contained regex flags, and timing-safe auth.
- 🚀 One command to production.
docker compose upbrings up the whole stack behind a Caddy reverse proxy on a single origin — with automatic HTTPS when you point it at a domain.
|
Competitions
Challenges
Scoring & scoreboard
Certificates & wrap-up
|
Teams & participants
Communicate & collaborate
Automation, feedback & insight
Administration
|
Requires Docker with Compose. The default stack is production: built images behind a Caddy reverse proxy, with Postgres, Redis, and MinIO.
git clone https://github.com/tbcsec/flagpost.git
cd flagpost
docker compose up --buildOpen http://localhost:8080 and complete the one-time setup wizard to create your owner account. That's it — the app, its API, and its live WebSocket updates are all served same-origin through Caddy, so there's nothing else to configure for a local run.
A fresh install ships with no administrator and no default password. It's unconfigured until you complete the setup wizard, which creates the owner account and initial branding.
The default compose is the production stack, so going live is mostly
configuration in .env (copy .env.example). (Want a demo instance that
resets to your own configured baseline on a schedule? See
docs/INTERNAL_DEMO.md.)
| Variable | What it does |
|---|---|
SITE_ADDRESS |
Your domain, e.g. ctf.example.com. Caddy obtains & renews TLS automatically. Map ports 80 + 443. |
PUBLIC_ORIGIN |
The browser-facing origin, e.g. https://ctf.example.com. Baked into the frontend at build time — set it before docker compose build. Also what OIDC redirect URIs are built from, so it must be exact if you configure SSO. |
JWT_SECRET |
A long random value (required for multi-host; otherwise the app derives and persists one). |
POSTGRES_PASSWORD, MINIO_ROOT_USER/PASSWORD |
Real credentials — generate with openssl rand -hex 24. Compose falls back to well-known development values so a local run needs no config, but those are published defaults, not secrets. |
MINIO_PUBLIC_ENDPOINT |
A browser-reachable MinIO host for signed attachment downloads. |
The backend refuses to start if it finds MinIO's default credentials on a deployment that looks reachable —
PUBLIC_ORIGINnaming a non-local host, orMINIO_PUBLIC_ENDPOINTbeing set. Browsers fetch attachments straight from the S3 API, so it has to be reachable, and default credentials there mean world read/write on every challenge attachment — including unreleased ones — outside RBAC entirely. Note thatMINIO_ROOT_USER/MINIO_ROOT_PASSWORDinitialise the MinIO server rather than reconfigure it: changing them after first boot needsdocker compose up -d --force-recreate minio, and a rotation on a stack that already holds data has to be done inside MinIO too.
The backend runs as a single process by default (in-process WebSocket
broadcast, no Redis required). For larger events it scales out to multiple
workers — set WEB_CONCURRENCY>1 and the real-time layer switches to a
Redis-backed cross-worker relay (a startup guard refuses to boot multi-worker
without Redis) — and beyond one box to multiple instances behind a load
balancer (the AWS ECS/Fargate + ALB + S3 + RDS + ElastiCache topology is a
supported target). To run without Docker: build & serve the frontend with
npm run build && npm run start, and run the backend with alembic upgrade head
then uvicorn main:app (no --reload) behind your own TLS-terminating proxy.
Every release tag publishes pinned, reproducible images to GHCR — pick a tag from Releases:
ghcr.io/tbcsec/flagpost-backend:vX.Y.Z (also :latest)
ghcr.io/tbcsec/flagpost-frontend:vX.Y.Z (also :latest)
The release frontend is built in same-origin mode — API calls and
WebSockets resolve against whatever origin serves the page — so one image works
behind any single-origin proxy with no baked-in domain and no PUBLIC_ORIGIN
rebuild. Point the compose frontend/backend services at these images (a
two-line override) to upgrade by tag instead of rebuilding from source.
A release image reports its exact tag as the running version; a build from
source reports the release it's based on with an -src suffix (e.g.
1.4.0-src), since main starts accumulating the next version the moment a tag
is cut.
The dev stack mounts source and runs hot-reloading dev servers:
docker compose -f docker-compose.dev.yml up --build
# frontend → http://localhost:3000 · backend → http://localhost:8000/docsOr run each side directly (backend needs Python 3.12+ and a venv; frontend needs Node 20+ — the shipped images run 3.14 and 26, which is what CI tests):
# Backend
cd backend && python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/alembic upgrade head # against a reachable Postgres
.venv/bin/uvicorn main:app --reload
# Frontend
cd frontend && npm install && npm run devRun the checks CI runs before opening a PR:
cd backend && .venv/bin/pytest # SQLite-backed, no infra needed
cd frontend && npx tsc --noEmit && npx eslint .
cd frontend && npm run test # vitest
cd frontend && npm run build # catches prerender-only failuresCI also runs alembic upgrade head against a real PostgreSQL, because the test
suite builds its schema from the models rather than by running migrations
(ADR-0006) — so a broken migration only shows up there. Run the production stack
once before shipping one.
Backend — Python · FastAPI · SQLAlchemy 2 (async) · Alembic · PostgreSQL · Redis · MinIO/S3 · JWT + argon2 + OIDC/OAuth2/SAML/LDAP · WeasyPrint + Pillow (reports & certificates) · a first-class async event bus. Frontend — TypeScript · Next.js 16 (App Router) · React 19 · TanStack Query · Zustand · Tailwind v4 · TipTap + Y.js (CRDT) · next-intl (i18n). Realtime — WebSockets throughout. Deploy — Docker Compose + Caddy, or multi-instance behind a load balancer (Fargate/ALB).
| Doc | What's in it |
|---|---|
docs/VISION.md |
What Flagpost is and why it exists |
docs/ARCHITECTURE.md |
The binding technical design |
docs/ROADMAP.md |
Build order and what's next |
docs/adr/ |
Architecture Decision Records — why things are the way they are |
Contributions are welcome! Start with CONTRIBUTING.md for
setup, conventions, and the PR flow, and please follow the
Code of Conduct.
Found a security issue? Don't open a public issue — see
SECURITY.md for private disclosure.
Your competitions, users and submissions never leave your infrastructure. The only thing Flagpost sends out is a once-daily update check that carries your version number and nothing else — no identifier, no hostname, no user data. The count of those requests is how the project gauges how many deployments are live.
Turn it off in Admin → Site settings, or set UPDATE_CHECK_URL="" to make sure
the call is never attempted at all. Full detail in PRIVACY.md.
Copyright © 2026 Tom Collier.
Flagpost is licensed under the Apache License 2.0 (from v1.5.1 — earlier releases were published under AGPL-3.0 and remain available on those terms). You're free to use, modify, self-host, and build on it, commercially or otherwise, with the license's explicit patent grant. The name "Flagpost" and the project marks are not part of the grant (License §6). The built-in "Powered by Flagpost" footer links every page back to this repository.



