sudo docker network create -d bridge traefik-network
sudo docker run -d -p 80:80 -p 8080:8080 -v $PWD/traefik.yaml:/etc/traefik/traefik.yml -v /var/run/docker.sock:/var/run/docker.sock --name traefik --network traefik-network traefik:v3.6Each challenge instance creates multiple Docker networks, and Docker's default /16 allocation quickly runs out of address space. Configure the Docker daemon to use smaller subnets by adding the following to /etc/docker/daemon.json (create the file if it doesn't exist):
{
"default-address-pools": [
{ "base": "10.0.0.0/8", "size": 24 }
]
}Then restart the Docker daemon:
sudo systemctl restart dockerThis carves 10.0.0.0/8 into /24 subnets (65,536 available), supporting ~32,000+ simultaneous team instances.
Create firewall rules to allow external traffic to the VM:
- Port 80 (and 443 if using HTTPS): Traefik — routes traffic to challenge containers
- Port 5000: Instancer web app — login, deploy UI, websockets
gcloud compute firewall-rules create allow-traefik \
--allow tcp:80,tcp:443 \
--target-tags=instancer \
--description="Allow HTTP/HTTPS traffic to Traefik"
gcloud compute firewall-rules create allow-instancer \
--allow tcp:5000 \
--target-tags=instancer \
--description="Allow traffic to instancer web app"Make sure your VM has the instancer network tag applied.
Create two DNS A records pointing to the VM's static external IP:
<BASE_CHALLENGE_DOMAIN>(e.g.bonk4cash.test.umasscybersec.org)*.<BASE_CHALLENGE_DOMAIN>(e.g.*.bonk4cash.test.umasscybersec.org)
The wildcard record is required so that each team's instance subdomain (e.g. <team_uuid>.bonk4cash.test.umasscybersec.org) resolves to the VM where Traefik routes it to the correct container.
cd UMassCTF26-Instancertouch .envSpecify environment variables in .env:
# EXAMPLE
SECRET_KEY=your_secret_key_here
CTFD_BASE_URL=http://34.28.81.490
CTFD_BASE_URL_2=http://34.28.81.491
TRAEFIK_CONTAINER_NAME=traefik
CHALLENGE_NAME=bonk4cash
BASE_CHALLENGE_DOMAIN=bonk4cash.test.umasscybersec.org
CHALLENGE_TEMPLATE_DIR=/home/umasscybersec_gmail_com/UMass26-Instancer/tests/umassctf25-test/bonk4cash
LOG_LEVEL=INFO # optional, default: INFO
COLOR_THEME=orange # optional, default: orange (options: orange, blue, green, purple)
INSTANCE_DURATION=30 # optional, default: 30 (minutes)
INSTANCER_BACKEND=traefik # optional, default: test (options: test, traefik)
INSTANCES_DIR=~/instances # optional, default: ~/instances
INSTANCE_URL_SCHEME=https # optional, default: http (set to https if Traefik terminates TLS)
SIMULATE_COMMAND_DELAY_FOR_TESTING=0 # optional, default: 0 (seconds, for testing only)Generate a secret key:
python3 -c "import secrets; print(secrets.token_hex(32))"The instancer validates the compose file on every deploy. Your challenge's docker-compose.yml must satisfy all of the following:
-
No external port mappings. Services must not have
portswith apublishedvalue. Traefik handles all external routing. -
Traefik router label. At least one service must have a label matching:
traefik.http.routers.${TEAM_UUID}.rule=Host(`${TEAM_UUID}.${BASE_CHALLENGE_DOMAIN}`) -
Traefik port label. The same service must also have:
traefik.http.services.${TEAM_UUID}.loadbalancer.server.port=<your_app_port> -
Router and port labels must appear together. A service cannot have one without the other.
-
Multi-network services need
traefik.docker.network. If a service with Traefik labels is on multiple networks, it must specify which network Traefik should use via thetraefik.docker.networklabel. -
Bind mounts must be inside the team instance directory. Any
bindtype volume must have a source path within the team's instance directory. Named volumes are fine (they are scoped to the compose project).
TEAM_UUID is a unique identifier generated per deploy. Use the ${TEAM_UUID} environment variable in your compose file — the instancer passes it automatically.
Important: Labels must use list format, not map format. Docker compose only substitutes variables in YAML values, not in map keys. Use list format so ${TEAM_UUID} in label keys gets resolved:
# WRONG — map format, ${TEAM_UUID} in keys won't be substituted
labels:
traefik.http.routers.${TEAM_UUID}.rule: "Host(...)"
# CORRECT — list format, entire string is a value, variables are substituted everywhere
labels:
- "traefik.http.routers.${TEAM_UUID}.rule=Host(...)"Example docker-compose.yml:
services:
web:
image: my-challenge:latest
networks:
- public
- internal
labels:
traefik.enable: "true"
traefik.http.routers.${TEAM_UUID}.rule: "Host(`${TEAM_UUID}.${BASE_CHALLENGE_DOMAIN}`)"
traefik.http.services.${TEAM_UUID}.loadbalancer.server.port: "8080"
traefik.docker.network: public # required when service is on multiple networks
# no 'ports:' — traefik handles external routing
db:
image: postgres:16
networks:
- internal
volumes:
- db-data:/var/lib/postgresql/data # named volume (ok)
networks:
public:
internal:
internal: true # no external access, only inter-service communication
volumes:
db-data:The instancer does not build images at deploy time. Pre-build them:
cd /path/to/challenge-template
sudo docker compose up --no-startStart the instancer inside a tmux session so you can detach, reattach to view live logs at any time, and the process keeps running even if you disconnect from SSH:
tmux new -s instancer
./run.sh 2>&1 | tee instancer.log./run.sh 2>&1— runs the instancer, merging stderr into stdout| tee instancer.log— prints output to the terminal AND saves it toinstancer.logCtrl+B, D— detach from the tmux session (process keeps running)tmux attach -t instancer— reattach to view live outputinstancer.log— always available for reviewing past logs, even outside tmux
Verify that Traefik is not making unintended modifications to the request.
Traefik routes requests like this:
client request -> EntryPoint -> Router -> Middleware -> Service -> backend server
Traefik components:
- Entrypoint: accepts incoming traffic on specific ports (e.g.
:80) - Router: inspects incoming requests and decides which service to send them to using rules to match patterns (host, path, headers, etc.)
- Middleware: transforms or filters requests/responses before they reach the app (e.g. authentication, rate limiting, redirects, header modification)
- Service: final destination (tells Traefik where to forward the request), also handles load balancing
EntryPoint request modifications (based on Traefik docs):
Path Modifications:
sanitizePath— normalizes path traversal sequences (/./,/../,//) into clean paths. On by default.encodeQuerySemicolons— encodes semicolons in query strings so they aren't treated as parameter separators- Encoded characters — optionally blocks or allows encoded characters like
%2f,%00,%5c, etc. in the path
Header Modifications:
- Connection header cleanup — per RFC 7230, strips any headers listed in the
Connection:header, and removes theConnectionheader itself when empty. This happens before middleware sees the request. forwardedHeaders— either trusts, rewrites, or stripsX-Forwarded-*headers depending on whether the source IP is intrustedIPs
Protocol Upgrades / Rewrites:
- TLS termination — if
http.tlsis configured, decrypts the incoming TLS connection - HTTP/3 upgrade — handles the TCP to UDP upgrade for HTTP/3 connections
http.redirections— issues an HTTP redirect (e.g. HTTP to HTTPS) before the request reaches a router
Connection-level:
- PROXY protocol — if enabled, reads the real client IP from the PROXY protocol header and replaces the connection's remote address
- Timeout enforcement — applies
readTimeout,writeTimeout,idleTimeoutat the connection level - Max header size — enforces
maxHeaderBytes, rejecting requests with oversized headers
Middleware injection:
http.middlewares— prepends a list of middlewares to every router on this EntryPoint, running globally before any router-specific middleware
Modify traefik.yaml as necessary so that these modifications do not interfere with the challenge.
- The
validate_compose_filefunction checks that any bind mount volumes are inside the team's instance directory; however, named volumes are not restricted (they are managed by Docker and scoped to the compose project). Check this manually per challenge.
If you stop the instancer during the middle of the competition:
TraefikInstancer.__init__automatically wipesINSTANCES_DIRon startup.
sudo docker compose ls --filter "name=team" -q | xargs -I{} sudo docker compose -p {} down -v
docker network ls --filter "name=team" -q | xargs -I{} sh -c 'docker network inspect {} -f "{{range .Containers}}{{.Name}} {{end}}" | xargs -n1 docker network disconnect {} 2>/dev/null; docker network rm {}'
for net in $(sudo docker network ls --filter name=-external -q); do
for c in $(sudo docker network inspect -f '{{range .Containers}}{{.Name}} {{end}}' "$net"); do
sudo docker network disconnect -f "$net" "$c"
done
sudo docker network rm "$net"
doneIt will be recreated on startup:
rm instance/instancer.sqlite