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
cli/
- dist/ rctf CLI
packages/
db/
- migrations/ Drizzle SQL migrations
- 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/
- nginx.conf Main config
http.d/
- default.conf Static + /api proxy
tmp/
nginx/
- nginx.pid
- security-headers.conf CSP, generated at startup
- avatar-body-limit.conf Avatar upload size limit, generated at startup
- client_body/ Buffered request bodies
- proxy/ Proxy temp directory
supervisor/
- supervisor.sock Control socket
- supervisord.pid
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/.
The container’s ephemeral runtime state lives under /tmp/. Local uploads are written to the /app/uploads/ bind mount instead of the root filesystem. This allows compose.yml to set read_only: true while mounting /tmp/ as a tmpfs.
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; creates the unprivileged rctf user and upload directory; 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-brotli \ && addgroup -g 1001 rctf \ && adduser -S -D -H -G rctf -u 1001 -s /sbin/nologin rctf \ && install -d -m 0755 -o rctf -g rctf /app/uploads
COPY deploy/rctf/supervisord.conf /etc/supervisord.confCOPY deploy/rctf/nginx-main.conf /etc/nginx/nginx.confCOPY deploy/rctf/nginx.conf /etc/nginx/http.d/default.confCOPY deploy/rctf/docker-entrypoint.sh /usr/local/bin/docker-entrypointRUN chmod +x /usr/local/bin/docker-entrypoint
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=.jsENTRYPOINT ["/usr/local/bin/docker-entrypoint"]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, running as the unprivileged rctf user. Spawns the leaderboard worker as a Bun Worker when instanceType is all or leaderboard. |
nginx |
nginx -g 'daemon off;' -e stderr |
Serves /app/static/ and reverse-proxies /api and /uploads to 127.0.0.1:3000. Workers run as the nginx user. |
[program:api]command=/usr/local/bin/bun run /app/apps/api/dist/index.jsuser=rctfautostart=trueautorestart=true# ...
[program:nginx]command=/usr/sbin/nginx -g 'daemon off;' -e stderrautostart=trueautorestart=trueIf either process exits, 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. Before supervisor starts, the container entrypoint generates the security-header configuration and the avatar upload body limit (derived from the maxAvatarSize config option) from rctf.d/, writes them to /tmp/nginx/, and runs nginx -t -e stderr
server { listen 80 default_server; root /app/static; gzip_static on; brotli_static on; proxy_max_temp_file_size 0; client_max_body_size 1m; include /tmp/nginx/security-headers.conf; # ...
location ~ ^/api/v[12]/admin/upload$ { proxy_pass http://rctf_api; client_max_body_size 0; proxy_request_buffering off; }
location = /api/v2/users/me/avatar { proxy_pass http://rctf_api; include /tmp/nginx/avatar-body-limit.conf; }
location ~ ^/api/v[12]/admin/ { proxy_pass http://rctf_api; client_max_body_size 8m; proxy_request_buffering off; }
location ~ ^/(api|uploads) { proxy_pass http://rctf_api; }
location /_app/immutable/ { add_header Cache-Control "public, max-age=86400, immutable"; include /tmp/nginx/security-headers.conf; try_files $uri $uri/ /index.html; }
location / { try_files $uri $uri/ /index.html; }}Content Security Policy
CSP is generated in two places. SvelteKit writes a <meta http-equiv="Content-Security-Policy"> tag into each page, including hashes for the generated scripts. At container startup, rctf deployment generate-cspindex.html, merges and deduplicates them with the configured upload, captcha, and analytics provider rules, and emits a nginx Content-Security-Policy response header. Startup fails if the built policy cannot be read.
csp: dev ? undefined : { directives: { 'script-src': [ 'self', // captcha script origins // ... ], }, },Together, the build-time and runtime policies contain the following sources. The generated nginx policy starts with the base sources below and adds only those required by the active providers:
| Directive | Sources |
|---|---|
default-src |
'none' |
script-src |
'self', SvelteKit’s generated hashes, https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/, https://hcaptcha.com, https://*.hcaptcha.com, and https://challenges.cloudflare.com |
style-src |
'self', 'unsafe-inline', plus the active captcha provider |
connect-src |
'self', data:, blob:, plus the active upload, captcha, and analytics providers |
font-src |
'self' |
img-src |
http:, https:, blob:, data: |
frame-src |
https://www.youtube.com, https://youtube.com, https://www.youtube-nocookie.com, plus the active captcha provider |
base-uri |
'self' |
form-action |
'self' |
object-src |
'none' |
media-src |
'none' |
manifest-src |
'none' |
Provider-specific style-src, connect-src, and frame-src sources are selected at runtime, so disabled providers are excluded from the nginx policy. For R2, connect-src uses the configured publicBaseUrl. Captcha script origins remain build-time entries because SvelteKit owns the generated script hashes.
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, R2, or GCS for non-local upload provider. 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.