All upstream facts below were verified against github.com/pwncollege/dojo (master) and the org's official dojo repos on 2026-11-02; file paths refer to that repository unless noted. Everything runnable lives in this directory; the custom-dojo scaffold is in ./scaffold/cyber-factory-dojo/.
per-user Docker challenge containers. License: BSD-2-Clause ("Copyright (c) 2022, Arizona Board of Regents", LICENSE). It is not MIT, contrary to common retelling.
ghcr.io/pwncollege/dojo image. A registry probe(ghcr.io/v2/pwncollege/dojo/tags/list) is denied while the same anonymous method works for known-public packages, and docs/deployment.md builds locally. So the canonical pull is a build from source (below). If a GHCR mirror appears later, swap the build step for a pull β nothing else changes.
On a Linux host with Docker:
# host networking prereq (verbatim from docs/deployment.md)
sudo modprobe br_netfilter
DOJO_PATH="$HOME/dojo"
DATA_PATH="$HOME/dojo-data"
git clone https://github.com/pwncollege/dojo "$DOJO_PATH"
docker build -t pwncollege/dojo "$DOJO_PATH"
docker run \
--name dojo \
--privileged \
-v "${DOJO_PATH}:/opt/pwn.college" \
-v "${DATA_PATH}:/data" \
-p 22:22 -p 80:80 -p 443:443 \
-d \
pwncollege/dojo
docker exec dojo dojo logs.
admin / admin β change it immediately (settings pageor dojo flask).
data/docker; use a named volume -v "dojo-data-docker:/data/docker".
dojo-init; set via -e or $DATA_PATH/config.env)| Variable | Meaning |
|---|---|
DOJO_ENV=production | production settings (disables the flask debugger) |
DOJO_HOST=dojo.example.com | your domain, pointed at the host via DNS |
WORKSPACE_HOST | workspace subdomain, defaults to workspace.$DOJO_HOST; must be a separate IP/domain (NGINX routing) |
WORKSPACE_KEY / WORKSPACE_SECRET / WORKSPACE_NODE | multi-node workspaces only (WireGuard mesh, port 51820/udp on the main node) |
For local dev you can skip DNS: localhost.pwn.college / workspace.localhost.pwn.college resolve to 127.0.0.1 by default.
The privileged container you launched is the deployment; internally it manages a full compose stack (from the repo's docker-compose.yml: site + Next.js frontend, PostgreSQL 17 + PgBouncer, Redis, two nginx gateways, SSH daemon, workspace-builder / dojofs / homefs, Prometheus/cAdvisor/ Grafana, optional Splunk and LiteLLM under splunk/llm profiles, selected per node via main / workspace / workspace_exclusive profiles). You should not need to drive that inner stack yourself, but a host-level compose wrapper keeps the outer container reproducible:
# docker-compose.host.yml β outer wrapper around the dojo container
services:
dojo:
image: pwncollege/dojo # built per Β§1 from the cloned repo
container_name: dojo
privileged: true # required: inner dind + user namespaces
restart: unless-stopped
ports:
- "22:22" # ssh gateway -> user containers
- "80:80"
- "443:443"
volumes:
- ./dojo:/opt/pwn.college # the cloned pwncollege/dojo repo
- ./dojo-data:/data # config.env, database state
# macOS hosts: replace ./dojo-data with a named volume for /data/docker
environment:
- DOJO_ENV=production
- DOJO_HOST=dojo.example.com
- WORKSPACE_HOST=workspace.example.com
# add WORKSPACE_* multi-node vars on extra nodes
docker compose -f docker-compose.host.yml up -d
docker exec dojo dojo logs
Upgrading: docker rm -f dojo β git -C "$DOJO_PATH" pull β rebuild β re-run (upstream-documented path); dojo update inside the container only refreshes site code, not challenge images.
This is the part your randomized training loop depends on, so here is exactly how upstream works:
(docs/challenge.md). The image's ENTRYPOINT is ignored; an optional CMD runs during (privileged) initialization β the modern replacement for the legacy root hook /challenge/.init. Long-running services must be forked and init must exit 0 quickly.
/flaginside each instance with student-specific secret content, readable only by uid 0. The interactive user is hacker (uid 1000); reaching root (typically via a setuid-root challenge binary) is the intended solve path. Challenge YAML has no flags key β every dojo challenge gets Flags(type="dojo") (site/dojo/utils/dojo.py).
POST /api/v1/dojos/<dojo>/<module>/<challenge_id>/solve with field submission unserializes a user-signed flag (unserialize_user_flag): a flag stolen from another student fails with "This flag is not yours!" and a flag for the wrong challenge fails with "This flag is not for this challenge!". Wrong submissions are rate limited (incorrect_submissions_per_min, default 10 β HTTP 429 ratelimited); re-solving returns already_solved.
dojo.user_id / dojo.dojo_id / dojo.module_id / dojo.challenge_id. The SSH gateway authenticates against the site DB and execs straight into the user's container; the workspace web API (GET /workspace, POST /workspace/reset_home, POST /workspace/backup_home) drives the same container, and admins can docker exec dojo dojo enter <user_id>.
Consequence for the factory: you never author flag values. Randomizing per-instance challenge content (keys, logs, tokens, webroots, binaries) is the author's job β that is what our scaffold does below β while flag randomization, signing, anti-theft and rate limiting come from the platform.
Verified layout (modern style, as used by the official welcome-dojo/injection-dojo repos): one git repository per dojo β
cyber-factory-dojo/
βββ dojo.yml # id, name, type, password, image:, award, modules [id, ...]
βββ DESCRIPTION.md # dojo description
βββ Dockerfile # builds the single dojo-wide challenge image (image: name above)
βββ init.sh # container-start randomizer entry (CMD + legacy .init copy)
βββ randomize.py # regenerates ALL challenge data + root-only .secret files
βββ common/unlock.c # shared setuid answer-checker (prints /flag on match)
βββ <module>/module.yml # name + challenges: [{id, name}] (inline, no challenge.yml)
βββ <module>/<challenge>/DESCRIPTION.md
βββ <module>/<challenge>/β¦ # sources/artifacts copied into /challenge by the Dockerfile
Our scaffold (./scaffold/cyber-factory-dojo/) implements exactly this with five modules β pwn (stack overflow, format string), web (SQLi login on :1337, path traversal on :1338), crypto (RSA small-e, classic chain), forensics (auth-log brute-force hunt), rev (crackme recompiled per start) β where:
dojo.yml sets type: hidden + password: so the dojo is private, andimage: cyber-factory/dojo-challenges, built from the repo Dockerfile.
/flag via asetuid-root helper (unlock <answer> / crackme <key> / direct exploitation) β matching the verified mechanism above;
randomize.py runs as root at every instance start (CMD / .init):fresh RSA primes, cipher chains, attack IPs/timings, admin usernames, webroot files, and a freshly compiled crackme per boot, with answers in root-mode .secret files. No two episodes present the same bytes.
Create-dojo is exposed at /dojos/create on your deployment; the API equivalent is POST /dojos/create taking repository, spec, public_key, private_key (the platform clones your dojo repo via a deploy key; rate-limited to 1 dojo creation per day). Push the scaffold to a private git repo, register it, and the platform builds the challenge image during initialization ("will take some time").
runner.py drives episodes over the jsonl-v1 protocol (see README) and targets this deployment like so:
python3 specs.py --check-scaffold --out specs.jsonl
# per episode: pick a spec, resolve the agent user's container, stream the agent
python3 runner.py \
--spec specs.jsonl --task-id dojo-crypto-rsa-smalle \
--transport docker --outer-container dojo --user-id 1001 \
--verify-mode container \
< agent_output.jsonl
--transport docker execs asdocker exec dojo docker exec <user-container> <argvβ¦> (container resolved by the verified dojo.user_id label). Interactive debugging equivalent: docker exec -it dojo dojo enter 1001.
--verify-mode container (default) reads that user's /flag as root viathe same transport and compares β legitimate because we own the deployment; cross-user flag theft would still fail upstream's signed-flag check.
--verify-mode api instead POSTs to the verified solve endpoint(--api-base https://dojo.example.com --api-cookie 'session=β¦' --dojo-ref <dojo>); note the API prefix/cookie shape depends on your fronting nginx, so confirm once against your deployment.
Trajectories land in <repo>/data/trajectories/pwn-college-dojo/<date>/.
Ubuntu-based with gcc/python3. Budget ~1β2 GB RAM per concurrent training agent plus the inner stack (Postgres/Redis/nginx/Prometheus).
(profile main).
$DATA_PATH holds DB state; upstream optionally does hourlyS3 DB backups when configured. Trajectories are the real training asset β they are written by the runner to the factory repo, not the dojo host.
--privileged by design (inner docker+ user namespaces). Run it on a dedicated VM; never expose the Docker socket beyond it. Change admin/admin and the scaffold dojo.yml password: before registering.