Running without Docker
Docker Compose is the supported happy path, but nothing in Flagpost requires it. You need: Python 3.12+ and Node 20+ (the release images run Python 3.14 and Node 26, which is what CI tests), plus reachable PostgreSQL, Redis, and an S3-compatible object store (MinIO works well).
Backend
Section titled “Backend”cd backendpython3 -m venv .venv.venv/bin/pip install -r requirements.txt
# Environment (see the configuration reference):export DATABASE_URL="postgresql+asyncpg://user:pass@dbhost:5432/flagpost"export REDIS_URL="redis://redishost:6379/0"export MINIO_ENDPOINT="miniohost:9000"export MINIO_PUBLIC_ENDPOINT="files.example.com:9000"export MINIO_ACCESS_KEY="…" MINIO_SECRET_KEY="…"export CORS_ORIGINS="https://ctf.example.com"export PUBLIC_BASE_URL="https://ctf.example.com" # OIDC redirect + SAML ACS URLs build from thisexport JWT_SECRET="a-long-random-value"# Encrypts stored SSO/SMTP secrets (ADR-0020) — must be a Fernet key, not an arbitrary string:export SECRET_ENCRYPTION_KEY="$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
.venv/bin/alembic upgrade head # migrate + seed built-in roles.venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000A single process (the default) needs nothing extra — just no --reload.
Since v1.4.0 multi-worker uvicorn on one host is
supported, with three things mandatory:
export WEB_CONCURRENCY=Nand pass the same N touvicorn --workers N— the env var, not the flag, is what the app reads. If they disagree (say--workers 4withWEB_CONCURRENCYunset), no cross-worker relay attaches: broadcasts reach only each socket’s own worker, presence fragments, and the scheduler fires in every worker.REDIS_URLmust be set — the app refuses to boot multi-worker without it.- Run the singleton scheduler as its own process with the same
environment:
.venv/bin/python -m scheduler(frombackend/) — under multi-worker the web workers deliberately skip the in-process scheduler, so without the sidecar timed automations, the daily update check, and retention never fire.
Size Postgres to match: each worker’s pool is
DB_CONNECTION_BUDGET // N plus half that as overflow (budget default
100) — the Docker stack raises Postgres to max_connections=200 for the
same reason. Multi-machine replicas remain unsupported either way.
Frontend
Section titled “Frontend”cd frontendnpm installNEXT_PUBLIC_API_URL="https://ctf.example.com" npm run buildnpm run start # serves on :3000NEXT_PUBLIC_API_URL is compiled into the client bundle — rebuild to change
it. It should be your public origin, not the backend’s internal address.
The reverse proxy contract
Section titled “The reverse proxy contract”Put both services behind one TLS-terminating proxy on a single origin, mirroring the stock Caddyfile:
/api/*→ backend (:8000)/ws/*→ backend, with WebSocket upgrade support- everything else → frontend (
:3000)
Same-origin is what keeps auth cookies and WebSocket calls simple — don’t
split the API onto its own subdomain. Bring over the security headers from
the repo’s Caddyfile
(HSTS, nosniff, frame options, CSP) if your proxy doesn’t add its own.
Health
Section titled “Health”GET /api/health returns 200 once migrations have applied and the app is
serving — gate your process manager or load balancer on it.