Static export
Generate a read-only snapshot of a finished rCTF instance for Cloudflare Pages or GitHub Pages.
The rctf export
CLI
The exporter reads from a live rCTF API and copies a built SvelteKit frontend. Build the frontend first, then run the export command:
bun run --filter '@rctf/web' buildbun rctf export \ --api-url https://ctf.example.com \ --backend cloudflare-pages \ --output ./export-output| Flag | Default | Description |
|---|---|---|
--api-url |
required | Base URL of the live rCTF instance to crawl. |
--backend |
required | Target host. One of cloudflare-pages or github-pages. |
--web-build |
apps/web/build/ |
Pre-built SvelteKit frontend that is copied into the output. |
--output |
./export-output/ |
Destination directory. Wiped and recreated on every run. |
--concurrency |
5 |
Concurrent in-flight API requests during the discovery phase. |
--skip-uploads |
off | Skips downloading challenge files and avatars. URL rewriting is skipped with it. |
The output directory is deleted before each run. Point --output somewhere that does not contain other files.
What the exporter collects
The discovery phase fetches:
/api/v2/integrations/client/configwithisArchivedpatched totrue./api/v2/challsand/api/v2/leaderboard/challs.- The full leaderboard and graph, paginated and merged into a single
api-data/v2/leaderboard/dump.json. - One
/api/v2/users/:idresponse per unique user seen on the leaderboard. - Every challenge’s paginated solves, merged into one
api-data/v2/challs/:id/solves.jsonper challenge. - A static
401 badTokenstub for/api/v2/users/meso the frontend stays in a logged-out state.
Unless --skip-uploads is set, the exporter downloads challenge files, avatars, sponsor icons, the favicon, logos, and the OG image. Files already under /uploads/ keep that path. Files from S3, Discord, and other external hosts are stored under uploads/external/<hash>/<filename>. The saved JSON responses are updated to use the local copies.
Output layout
The resulting directory is ready to upload to a static host:
export-output/
- index.html SvelteKit shell with the fetch interceptor injected
- 404.html Copy of index.html for SPA fallback
- index.html.gz
- index.html.br
- _app/ SvelteKit build assets
api-data/ Static API responses
v2/
- challs.json
leaderboard/
- dump.json Full leaderboard + graph for client-side filtering
users/
- [id].json
challs/
[id]/
- solves.json
- uploads/ Mirrored /uploads/ and uploads/external/[hash]/
- _redirects cloudflare-pages only
- _headers cloudflare-pages only
- wrangler.toml cloudflare-pages only
- .nojekyll github-pages only
The exporter removes the Content-Security-Policy meta tag from index.html because the static archive injects an inline request interceptor.
Deployment
The exporter prints the matching deploy command on success.
The cloudflare-pages backend writes:
_redirectswith/* /index.html 200so SvelteKit’s client-side routes resolve._headerssettingCache-Control: public, max-age=31536000, immutableon/api-data/*,/uploads/*, and/_app/*, plusAccess-Control-Allow-Origin: *on/api-data/*.wrangler.tomlwith a placeholdername = "rctf-archive"andpages_build_output_dir = ".". Edit the name before deploying.
Deploy from the output directory:
npx wrangler pages deploy ./export-outputThe github-pages backend writes an empty .nojekyll so files under _app/ are served instead of being filtered by Jekyll.
Publish with gh-pages--dotfiles flag is required so .nojekyll is uploaded:
npx gh-pages -d ./export-output --dotfilesPages served from a project subpath (username.github.io/repo) need a custom domain or root deployment, since the injected interceptor and SvelteKit build both assume the site is served from /.
Runtime behavior
The injected script wraps window.fetch and routes /api/* calls through static files:
- Any non-
GETrequest returns405 Method Not Allowedwith"kind": "badEndpoint"and the message"This is a read-only archive.". Login, registration, flag submission, profile edits, and admin actions all fail with this response. /api/v2/leaderboard/with-graph,/api/v2/leaderboard/now, and/api/v2/leaderboard/graphare answered fromdump.jsonwith in-memorydivisionfiltering,searchsubstring matching, andoffset/limitslicing. The scoreboard stays searchable without a backend./api/v2/challs/:id/solvesis sliced from each challenge’ssolves.json./api/v2/integrations/analytics/scriptreturns an empty404so the archived bundle does not ping a third-party analytics provider.- Every other
/api/*GETis rewritten to/api-data/<path>.jsonand served from the static bundle. Missing files return a404 badEndpoint.
When isArchived is set, the frontend hides flag submission, profile editing, account creation, and other interactive controls.
Limitations
Warning (Read-only by design)
The archive contains every JSON response the exporter saw at crawl time. Anything that requires authentication, server state, or live computation is absent.
- No login, registration, password recovery, or token issuance.
/api/v2/users/mealways returns401 badToken. - No admin pages. Admin endpoints require an authenticated session that the archive cannot produce.
- No live updates. The leaderboard reflects the moment the exporter ran. Re-run the export to refresh.
- No instancer, admin bot, or CTFtime export. Those depend on the live API and external services.
- Captcha, analytics, and any other integration that calls back to the API or a third party is disabled.
To refresh the snapshot, run the exporter again while the live instance is still available. Each run rebuilds the output directory from scratch.