Architecture
Reference for the bundled rCTF container, its build pipeline, in-container processes, and horizontal scaling model.
rCTF ships as a single container managed by supervisord. Inside the container, the Hono API server runs as a Bun process that also spawns the leaderboard worker as a Bun Worker thread. Alongside it, an nginx instance serves the SvelteKit static build and proxies /api and /uploads to the API.
The container sits behind a reverse proxy and connects to PostgreSQL and Redis. The bundled compose.yml starts all three services for a single-server deployment.
For deployment steps, see Installation and the VPS setup walkthrough. The sections below describe the production image itself.
Container layout
The production container has the following layout:
app/
apps/
api/
- dist/ Bun-built API + leaderboard worker
- templates/ Email templates
packages/
- config/ Loader sources
db/
- src/
- migrations/ Drizzle SQL migrations
- scoring/
- types/
- util/
- node_modules/ Production-only deps
- static/ SvelteKit static build, served by nginx
- rctf.d/ Mounted config directory
- uploads/ Local upload provider data (when used)
etc/
- supervisord.conf Process definitions
nginx/
http.d/
- default.conf Static + /api proxy
The rctf.d/ and uploads/ directories are mounted from the host by compose.yml. With the default local upload provider, files land under /app/uploads/.
Build stages
The Dockerfile is at deploy/rctf/Dockerfile and uses oven/bun:1.3.14-alpine as both the build and runtime base. The stages:
| Stage | Base | Role |
|---|---|---|
build-base |
oven/bun:1.3.14-alpine (BUILDPLATFORM) |
Base for build steps that run on the build platform. |
runtime-base |
oven/bun:1.3.14-alpine (target platform) |
Base for production dependencies and the final image. |
package-configs |
build-base |
Copies workspace manifests and bun.lock so source changes do not invalidate the dependency layer. |
deps |
build-base |
Full install (including devDependencies) for the API, web, and CLI workspaces. Filters out @rctf/admin-bot, @rctf/docs, and the test workspaces. |
prod-deps |
runtime-base |
Production-only install (--production) on the same filter set, used as node_modules/ in the final image. |
build-src |
build-base |
Assembles the full sources plus dev node_modules/ as the shared base for the build stages. |
build-api / build-web / build-cli |
build-src |
Each runs its workspace’s bun run build |
production |
runtime-base |
Installs supervisor, nginx, and nginx-mod-http-brotli, then copies the API dist, prod node_modules, migrations, email templates, the rctf CLI, and the SvelteKit static output into /app/. |
FROM oven/bun:1.3.14-alpine AS runtime-baseWORKDIR /app
# ...
FROM runtime-base AS production
RUN apk add --no-cache supervisor nginx nginx-mod-http-brotliCOPY deploy/rctf/supervisord.conf /etc/supervisord.confCOPY deploy/rctf/nginx.conf /etc/nginx/http.d/default.conf
COPY --from=build-api /app/apps/api/dist ./apps/api/distCOPY --from=prod-deps /app/node_modules ./node_modules# ...COPY --from=build-web /app/apps/web/build ./static
ENV NODE_ENV=productionENV WORKER_EXTENSION=.jsCMD ["supervisord", "-c", "/etc/supervisord.conf"]WORKER_EXTENSION is set to .js so the API process resolves its compiled worker entry (./leaderboard.js) rather than the .ts source used in development.
Process supervision
/etc/supervisord.conf defines two long-running programs. Both stream stdout/stderr to the container’s stdout/stderr without rotation.
| Program | Command | Role |
|---|---|---|
api |
bun run /app/apps/api/dist/index.js |
Hono server on :3000. Spawns the leaderboard worker as a Bun Worker when instanceType is all or leaderboard. |
nginx |
nginx -g 'daemon off;' |
Serves /app/static/ and reverse-proxies /api and /uploads to 127.0.0.1:3000. |
[program:api]command=/usr/local/bin/bun run /app/apps/api/dist/index.jsautostart=trueautorestart=true# ...
[program:nginx]command=/usr/sbin/nginx -g 'daemon off;'autostart=trueautorestart=trueIf either process exits non-zero, supervisord restarts it. The leaderboard worker is not a separate supervised program. It is a thread inside the API process, so it restarts together with the API.
Note (Leaderboard updates without a worker thread)
When instanceType is frontend, the API process does not start the worker. Another process running with leaderboard or all must be reachable on the same Redis instance for cached leaderboard reads to stay fresh.
Nginx
The container’s nginx is built from Alpine’s nginx package together with nginx-mod-http-brotli. The site config at deploy/rctf/nginx.conf is copied to /etc/nginx/http.d/default.conf.
server { listen 80 default_server; root /app/static; gzip_static on; brotli_static on;
# ...
location ~ ^/api/v[12]/admin/upload$ { proxy_pass http://127.0.0.1:3000; client_max_body_size 0; proxy_request_buffering off; }
location ~ ^/(api|uploads) { proxy_pass http://127.0.0.1:3000; }
location /_app/immutable/ { add_header Cache-Control "public, max-age=86400, immutable"; try_files $uri $uri/ /index.html; }
location / { try_files $uri $uri/ /index.html; }}Content Security Policy
CSP is defined in apps/web/svelte.config.ts and applied during the SvelteKit build. The static adapter writes it into each page as a <meta http-equiv="Content-Security-Policy"> tag with hashes for the generated scripts. nginx does not add a separate Content-Security-Policy header.
csp: dev ? undefined : { directives: { 'default-src': ['none'], // ... }, },The directives:
| Directive | Sources |
|---|---|
default-src |
'none' |
script-src |
'self', https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/, https://hcaptcha.com, https://*.hcaptcha.com, https://challenges.cloudflare.com |
style-src |
'self', 'unsafe-inline', https://hcaptcha.com, https://*.hcaptcha.com |
connect-src |
'self', data:, blob:, https://*.storage.googleapis.com/, https://*.amazonaws.com/, https://hcaptcha.com/, https://*.hcaptcha.com/, https://www.google.com/recaptcha/, https://www.google-analytics.com/, https://*.google-analytics.com/, https://*.analytics.google.com/, https://cloudflareinsights.com/ |
font-src |
'self' |
img-src |
http:, https:, blob:, data: |
frame-src |
https://www.youtube.com, https://youtube.com, https://www.youtube-nocookie.com, https://www.google.com/recaptcha/, https://recaptcha.google.com/recaptcha/, https://hcaptcha.com, https://*.hcaptcha.com, https://challenges.cloudflare.com |
base-uri |
'self' |
form-action |
'self' |
object-src |
'none' |
The bundled build always includes every origin listed above, even when the corresponding provider is disabled. It cannot add new origins from configuration at runtime. To use self-hosted analytics, a custom S3-compatible service, or another frontend origin, update apps/web/svelte.config.ts and rebuild rCTF.
Warning (frame-ancestors)
The CSP omits frame-ancestors because it is ignored from <meta> tags. Click-jacking protection is enforced via the nginx X-Frame-Options: DENY header instead.
External dependencies
The container only contains the rCTF application, nginx, and supervisor. Everything else is external.
| Service | Version pinned in compose.yml |
Required by |
|---|---|---|
| PostgreSQL | postgres:18.0 (any 15+) |
Core data store. The pg_trgm extension is installed by migration 0015_add_trigram_search.sql for team-name fuzzy search. |
| Redis | redis:8.2.2 (any 7+) |
Cache for leaderboard snapshots, rate limiting, and provider locks. |
Optional dependencies that the deployer must supply when the matching provider is enabled:
- S3 / GCS for the
uploads/s3oruploads/gcsupload providers. See Uploads. - SES, SMTP, or another email provider for the email integration.
- OpenAI (or another moderation provider) for avatar moderation.
- Docker instancer or a Kubernetes cluster running the rCTF k8s-operator for per-team challenge instances. See Instancer.
- Captcha provider (reCAPTCHA, hCaptcha, or Turnstile). The CSP already permits all three.
The bundled compose.yml pins one PostgreSQL and one Redis container alongside rctf. The rctf service exposes 127.0.0.1:8080 and expects a reverse proxy (typically nginx or Caddy on the host) to terminate TLS and forward to it.