rCTF Docs
Overview

Production 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 expects to sit behind a reverse proxy, with PostgreSQL and Redis running as external services. The bundled compose.yml wires those dependencies together for a single-node deployment.

For deployment steps, see Installation and the VPS setup walkthrough. This page is a reference for the inner architecture.

Container layout#

Paths inside the running container after the production stage finishes assembly:

  • 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/ because the provider resolves uploadDirectory from process.cwd(), which is /app/.

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:

StageBaseRole
build-baseoven/bun:1.3.14-alpine (BUILDPLATFORM)Build-platform scratch for cross-compile-friendly steps.
runtime-baseoven/bun:1.3.14-alpine (target platform)Target-platform base reused by prod-deps and production.
package-configsbuild-baseCopies every workspace package.json plus bun.lock so dependency layers cache independently of source.
depsbuild-baseFull install (including devDependencies) for the API and web workspaces. Filters out @rctf/admin-bot, @rctf/docs, @rctf/export, @rctf/seed, and the test workspaces.
prod-depsruntime-baseProduction-only install (--production) on the same filter set, used as node_modules/ in the final image.
buildbuild-baseRuns bun run --filter '@rctf/api' build then bun run --filter '@rctf/web' build against the full sources.
productionruntime-baseInstalls supervisor, nginx, and nginx-mod-http-brotli, then copies the API dist, prod node_modules, package sources, migrations, email templates, and the SvelteKit static output into /app/.
deploy/rctf/Dockerfile
FROM oven/bun:1.3.14-alpine AS runtime-base
WORKDIR /app
# ...
FROM runtime-base AS production
RUN apk add --no-cache supervisor nginx nginx-mod-http-brotli
COPY deploy/rctf/supervisord.conf /etc/supervisord.conf
COPY deploy/rctf/nginx.conf /etc/nginx/http.d/default.conf
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=prod-deps /app/node_modules ./node_modules
# ...
COPY --from=build /app/apps/web/build ./static
ENV NODE_ENV=production
ENV WORKER_EXTENSION=.js
CMD ["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 inherit the container’s PID 1 (supervisord), have autorestart=true, and stream stdout/stderr to the container’s stdout/stderr without rotation.

ProgramCommandRole
apibun run /app/apps/api/dist/index.jsHono server on :3000. Spawns the leaderboard worker as a Bun Worker when instanceType is all or leaderboard.
nginxnginx -g 'daemon off;'Serves /app/static/ and reverse-proxies /api and /uploads to 127.0.0.1:3000.
deploy/rctf/supervisord.conf
[program:api]
command=/usr/local/bin/bun run /app/apps/api/dist/index.js
autostart=true
autorestart=true
# ...
[program:nginx]
command=/usr/sbin/nginx -g 'daemon off;'
autostart=true
autorestart=true

If 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.

deploy/rctf/nginx.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;
}
}

Notable behavior:

  • brotli_static and gzip_static serve the .br and .gz files the SvelteKit adapter-static precompresses (precompress: true in apps/web/svelte.config.ts). No on-the-fly compression is configured.
  • /_app/immutable/ is the SvelteKit hashed-asset directory and gets a one-day immutable cache.
  • The admin upload route has request buffering disabled and client_max_body_size=0 so large uploads stream straight to the API.
  • /api and /uploads proxy to the API on 127.0.0.1:3000. When the local upload provider is in use, the API serves files from /app/uploads/ through its own static handler.
  • nginx adds Referrer-Policy: no-referrer, X-Frame-Options: DENY, and X-Content-Type-Options: nosniff on every response. CSP is not set here.

Content Security Policy#

CSP is defined in apps/web/svelte.config.ts and applied by SvelteKit at build time. Because apps/web/ is built with adapter-static, SvelteKit injects the policy as a <meta http-equiv="Content-Security-Policy"> tag in each rendered page, along with the auto-generated script hashes. nginx does not add a Content-Security-Policy header on its own.

apps/web/svelte.config.ts
csp: dev
? undefined
: {
directives: {
'default-src': ['none'],
// ...
},
},

The directives:

DirectiveSources
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-srchttp:, https:, blob:, data:
frame-srchttps://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 list above is the entire allow-set the bundled build ships with. There is no provider-aware logic that widens it at runtime, so captcha sources, analytics endpoints, and cloud storage origins are always present, regardless of whether the matching provider is enabled. Self-hosted analytics endpoints, custom S3-compatible object stores, or any other external origin that the frontend needs to reach require editing apps/web/svelte.config.ts and rebuilding.

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.

Horizontal scaling with instanceType#

The instanceType config option (environment variable RCTF_INSTANCE_TYPE) selects what an API process runs. The available modes are all, frontend, and leaderboard. The dispatch happens at startup in apps/api/src/index.ts:

apps/api/src/index.ts
if (config.instanceType === 'leaderboard' || config.instanceType === 'all') {
startLeaderboardWorker(logger)
}
if (config.instanceType === 'frontend' || config.instanceType === 'all') {
const port = Number(process.env.PORT ?? 3000)
Bun.serve({ port, fetch: app.fetch })
}

See Scaling for the full mode table, the one-leaderboard-replica constraint, the forced-update limitation in split mode, and observed resource usage on real events.

External dependencies#

The container only contains the rCTF application, nginx, and supervisor. Everything else is external.

ServiceVersion pinned in compose.ymlRequired by
PostgreSQLpostgres: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.
Redisredis: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/s3 or uploads/gcs upload 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-controller 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.

Esc

Start typing to search the docs.