Architecture

Reference for the bundled rCTF container, its build pipeline, in-container processes, and horizontal scaling model.

Edit this page View Markdown

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. Independent stages, so BuildKit builds all three in parallel.
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/.
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-api /app/apps/api/dist ./apps/api/dist
COPY --from=prod-deps /app/node_modules ./node_modules
# ...
COPY --from=build-web /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 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.
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;
}
}

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.

apps/web/svelte.config.ts
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/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-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.

Type to search.