Self-hosting the pwn.college dojo

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

0. What you are deploying (and two corrections to common lore)

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

1. Quick start (verified path)

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.

or dojo flask).

data/docker; use a named volume -v "dojo-data-docker:/data/docker".

Environment (defaults live in dojo-init; set via -e or $DATA_PATH/config.env)

VariableMeaning
DOJO_ENV=productionproduction settings (disables the flask debugger)
DOJO_HOST=dojo.example.comyour domain, pointed at the host via DNS
WORKSPACE_HOSTworkspace subdomain, defaults to workspace.$DOJO_HOST; must be a separate IP/domain (NGINX routing)
WORKSPACE_KEY / WORKSPACE_SECRET / WORKSPACE_NODEmulti-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.

2. Compose scaffold (host-level wrapper)

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.

3. The flag / instance mechanism (as verified from the repo)

This is the part your randomized training loop depends on, so here is exactly how upstream works:

  1. A challenge is a Docker image, nothing more

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

  1. Flags are platform-managed and per-user. The dojo creates /flag

inside 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).

  1. **Submissions are checked against per-user signed flags.**

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.

  1. Instances are per-user containers created by the platform, labeled

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.

4. Custom private dojo with randomized per-instance content

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:

image: cyber-factory/dojo-challenges, built from the repo Dockerfile.

setuid-root helper (unlock <answer> / crackme <key> / direct exploitation) β€” matching the verified mechanism above;

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.

Registering the private dojo

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").

5. Wiring in the runner

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

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

the same transport and compares β€” legitimate because we own the deployment; cross-user flag theft would still fail upstream's signed-flag check.

(--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>/.

6. Operational notes

Ubuntu-based with gcc/python3. Budget ~1–2 GB RAM per concurrent training agent plus the inner stack (Postgres/Redis/nginx/Prometheus).

(profile main).

S3 DB backups when configured. Trajectories are the real training asset β€” they are written by the runner to the factory repo, not the dojo host.

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