services: caddy: image: caddy:2-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b restart: unless-stopped ports: ["80:80", "443:443"] volumes: # Poll is the score transport (ADR 56, issue #377): one Caddyfile, no # SCORE_INGEST knob to drift from a second copy of the same answer. - ./caddy/Caddyfile.poll:/etc/caddy/Caddyfile:ro - caddy-data:/data environment: EVENT_HOST: ${EVENT_URL:-http://localhost} networks: [frontend] app: profiles: ["app"] build: context: . dockerfile: apps/web/Dockerfile args: # Build stamp for GET /health. Both optional: unset reports # "unknown"/null rather than failing, so a plain `docker compose build` # keeps working. `dev-stack` and deploy/fly/deploy.sh fill them in. APP_BUILD_REV: ${APP_BUILD_REV:-} APP_BUILT_AT: ${APP_BUILT_AT:-} restart: unless-stopped environment: BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} BETTER_AUTH_URL: ${EVENT_URL:-http://localhost} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} LEADERBOARD_SOURCE: lambda # plain-HTTP adapter; reads the local scorer LEADERBOARD_API_URL: http://scorer:4000 UPSTASH_REDIS_REST_URL: http://srh:80 UPSTASH_REDIS_REST_TOKEN: ${SRH_TOKEN} TEAM_WRITES_ENABLED: "true" # The GitHub org contestants fork the target repos under (config v2, # issue #386): a runtime read, not a build arg, so it must be passed # through here or setting it in .env does nothing. GITHUB_ORG: ${GITHUB_ORG:-} # The /admin allowlist, comma-separated logins (config v2, issue #386): # a runtime read too. Empty or unset refuses every login at /admin — # see apps/web/src/lib/bootstrap-env.ts. ADMIN_LOGINS: ${ADMIN_LOGINS:-} # Runtime flag, not a build arg: non-empty means this deployment runs # Secure Development (the scorer and sync containers exist), and that is # the ONLY module enabled before an organizer switches others on in # /admin (issue #386). Empty means no scorer image — the SD toggle is # refused and nothing is enabled by default. SCORE_IMAGE: ${SCORE_IMAGE:-} # "=1" downgrades the http:// refusal on a non-loopback EVENT_URL to a # startup warning — for deliberately TLS-less closed networks only # (docs/hosting.md). Read at runtime by the app, so it must be passed # through here or setting it in .env silently does nothing. ALLOW_INSECURE_EVENT_URL: ${ALLOW_INSECURE_EVENT_URL:-} # NOT depends_on scorer: the app reads the scorer over HTTP lazily, per # request, and depends_on does not wait for readiness anyway — so this # only ever expressed a start ORDER. Keeping it would have forced the # (profiled) scorer into every `up` that starts the app, including a # quiz-only event that has no scorer image to pull. See scorer's profiles # below. depends_on: [srh] networks: [frontend] scorer: # The scorer belongs to the secure-development module, exactly as `sync` # does — it exists to score PRs against forked targets. A quiz-only event # has nothing for it to serve, so it is PROFILED rather than left in the # default set: carrying no profile at all, it was pulled by every # documented `up`, and a quiz-only organizer (who has no SCORE_IMAGE and # no access to the private upstream fallback below) could not bring the # documented stack up at all. # # `secdev` is the module's own profile (config v2, issue #386): whoever # brings the stack up adds it iff SCORE_IMAGE is non-empty, so "does this # event run Secure Development" is answered once, from one key, and never # from a second knob — see scripts/dev-stack and # deploy/fly/render-compose.sh. Quiz-only events pass neither. # docs/hosting.md's "Which profiles do I need?" table is the # organizer-facing version of this comment. profiles: ["secdev"] # Set SCORE_IMAGE in .env to your own build from scorer/ (docs/scorer.md). # The fallback is the upstream image, which is private — it only pulls if # the OWASP-CTF maintainers granted you access. Kept as a fallback (not # required) so parse-time interpolation doesn't break the smoke override. image: ${SCORE_IMAGE:-ghcr.io/owasp-ctf/score:latest} command: ["score", "serve"] restart: unless-stopped environment: UPSTASH_REDIS_REST_URL: http://srh:80 UPSTASH_REDIS_REST_TOKEN: ${SRH_TOKEN} CTF_SCORE_BEARER_TOKEN: ${SCORER_TOKEN} # requires upstream scorer bearer-auth mode depends_on: [srh] networks: [frontend] srh: # Pinned by digest: this third-party image sits in the score read/write path, # so a mutable :latest tag must not silently swap it out on a repull. image: hiett/serverless-redis-http:latest@sha256:5b0bb9239fce53abf87b2018a7a0deb9ec7bd900c5360738fe5fbeeb426f9150 restart: unless-stopped environment: SRH_MODE: env SRH_TOKEN: ${SRH_TOKEN} # Empty username, password only — the RFC form for a Redis instance # using `requirepass` without ACL users. SRH_CONNECTION_STRING: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD in .env (setup/ctf-setup.sh secrets generates one; see docs/hosting.md)}@redis:6379 depends_on: [redis] # The ONLY service on both networks. That is the isolation: `app`, # `scorer` and `sync` can reach srh (and must, for the Upstash-REST # contract) but have no route to redis:6379 at all, so the bearer token # in front of it cannot be side-stepped from a compromised app container. networks: [frontend, backend] redis: image: redis:8-alpine@sha256:3811787313eba226a2ef38658c6ccb91cd5e110edc89c37767de373120a0e5a0 restart: unless-stopped # THE PASSWORD IS READ FROM THE ENVIRONMENT, NOT INTERPOLATED INTO ARGV. # # `--requirepass ${REDIS_PASSWORD}` put the live credential in three places # it did not belong: the container's command line (so any `ps` inside the # stack, and `docker inspect`, showed it), the output of `docker compose # config` (which organizers are told to run, and which deploy/fly renders # to a file), and every machine-config dump on a hosted deployment. # # `$$` is escaped for COMPOSE, not for the shell: compose turns `$$` into a # literal `$`, so redis-server's own `sh` is what expands $REDIS_PASSWORD, # at start-up, inside the container. `exec` keeps redis as PID 1 so it # still receives signals directly rather than through a shell that ignores # them. Without `sh -c` there is no expansion at all and redis would take # the literal string "$REDIS_PASSWORD" as its password — an error that # starts cleanly and reports healthy (this exact bug shipped once on Fly, # where `[processes]` also runs no shell; see ADR 42). # `mkdir -p` because `--dir` must already exist — redis-server does not # create it and exits with "Can't chdir to ...". It only ever matters when # REDIS_DIR is a SUBDIRECTORY of the mount, which is the Fly case below. # # `chown redis` because the mkdir runs as root and the entrypoint will NOT # fix that for us. The image's permission pass only chowns the data dir # when every entry in it is one redis recognises; anything else — and a # fresh Fly volume ships a root-owned `lost+found`, while REDIS_DIR itself # is a subdirectory it does not know — makes it print "Unknown file ... # Permissions will not be modified" and leave ownership alone. Redis then # starts as uid 999 inside a root-owned directory and dies with "Can't # open or create append-only dir appendonlydir: Permission denied". That # is what the first deploy of a renamed Fly app did; the old app only # worked because an earlier image had chowned its volume back when the # pass was unconditional. Reproduced on a docker named volume seeded with # a root-owned lost+found: without the chown, exit 1; with it, ready. # # `exec docker-entrypoint.sh redis-server ...`, NOT `exec redis-server ...`. # # The image's entrypoint does two things that only happen when its first # argument is literally `redis-server`: # # 1. drops privileges — `setpriv --reuid redis --regid redis # --clear-groups` # 2. sets `umask 0077`, so the AOF and RDB are not world-readable # # Running `sh -c 'exec redis-server ...'` skips BOTH: the entrypoint sees # `sh`, falls through to `exec "$@"`, and redis ends up running as ROOT # with a 0022 umask. Nothing about that is visible in `docker compose ps` # or in the logs — it was found by diffing `ps -o user` against the # previous command. # # So the shell is used only to expand the two variables, and then hands # back to the entrypoint, which sees `redis-server` and behaves exactly as # it did before this file used a shell at all. command: - sh - -c - mkdir -p "$$REDIS_DIR" && chown redis "$$REDIS_DIR" && exec docker-entrypoint.sh redis-server --appendonly yes --dir "$$REDIS_DIR" --requirepass "$$REDIS_PASSWORD" environment: # Where the append-only file lives, INSIDE the mounted volume. # # Defaults to the volume root, which is what compose has always done and # keeps every local stack byte-identical. It is a knob because a Fly # machine permits exactly ONE volume ("invalid config.mounts, only 1 # volume supported"), so redis and sync have to share it under separate # directories — .env.fly sets this to /data/redis. See deploy/fly/fly.toml. REDIS_DIR: ${REDIS_DIR:-/data} # `:?` on purpose: an unset REDIS_PASSWORD fails the bring-up with this # message rather than starting an unauthenticated Redis. A security # control that silently degrades to "off" when a variable is missing is # the failure mode this whole change exists to remove — and the empty # string would be exactly that, since redis-server treats # `--requirepass ""` as no password at all. `:?` rejects both unset AND # empty, which is why it stays `:?` rather than `:-`. REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD in .env (setup/ctf-setup.sh secrets generates one; see docs/hosting.md)} # redis-cli reads REDISCLI_AUTH, so `docker compose exec redis redis-cli # ...` keeps working unchanged — for organizers debugging by hand, and # for smoke.sh/dev-stack, which drive Redis exactly that way. Passing # `-a` at every call site instead would put the password in each # command line (visible in `ps`) and print a warning on every # invocation. REDISCLI_AUTH: ${REDIS_PASSWORD} volumes: [redis-data:/data] networks: [backend] sync: # Same derivation as the scorer above — the profile is added iff # SCORE_IMAGE is non-empty. profiles: ["secdev"] build: ./sync # Config v2 (#386) removed the "nothing to poll" exit 0: whether sync runs # at all is this profile's call, so if the container is up it polls. The # only clean-exit case left is a refusal — a missing GITHUB_ORG exits 1 # naming the key (sync/src/index.js's main()) — and on-failure restarting # that is what an organizer wants: the log line repeats until the key is # set, instead of one message scrolling away in a container nothing brings # back. unless-stopped would behave the same here; on-failure is kept as # the narrower policy. restart: on-failure environment: # The org the poller reads score comments from. sync refuses to start # when it is empty, logging `ctf-sync: GITHUB_ORG is not set` — so it # must be passed through here or setting it in .env does nothing. GITHUB_ORG: ${GITHUB_ORG:-} GITHUB_APP_ID: ${GITHUB_APP_ID:-} GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-} GITHUB_APP_INSTALLATION_ID: ${GITHUB_APP_INSTALLATION_ID:-} SCORER_TOKEN: ${SCORER_TOKEN} SCORER_URL: http://scorer:4000 # Same srh proxy as app/scorer above — lets sync read the organizer's # pause flag (ctf:admin:settings) and publish its heartbeat # (ctf:sync:status). Without this, sync's redis client stays null and # the admin-panel freeze never reaches the poller. UPSTASH_REDIS_REST_URL: http://srh:80 UPSTASH_REDIS_REST_TOKEN: ${SRH_TOKEN} # The poller's LEGACY state file. The cursor itself lives in Redis # (ctf:sync:state, through srh above); this file is read once, to carry # an older box's cursor into Redis, then renamed state.json.migrated. # It is the store only when sync has no Redis client. The volume stays # so that migration has something to read. Same reasoning as redis's # REDIS_DIR above: the default is exactly what compose has always used, # and .env.fly moves it # to /data/sync/state.json so both services fit the single volume a Fly # machine allows. sync creates the parent directory itself. STATE_PATH: ${STATE_PATH:-/state/state.json} volumes: - sync-state:/state depends_on: [scorer, srh] networks: [frontend] # Two networks, and the split is the point: only `srh` sits on both, so the # internet-facing `app` has no route to redis:6379 and cannot bypass srh's # bearer token even if it were compromised (an SSRF or RCE in the app was the # concrete worry — see ADR 41). `backend` is `internal`, so nothing on it can # reach the outside world either. networks: frontend: backend: internal: true volumes: caddy-data: redis-data: sync-state: