Deploying the Web CTF challenges to k3s (Traefik)
Five web challenges, each a single-container HTTP service on port 80, exposed on its own subdomain via a Traefik Ingress.
| Host | Challenge | Stack | Flag secret key |
|---|---|---|---|
chal1.entysec.com |
Second Track (Easy) | Flask + gunicorn | chal1-flag |
chal2.entysec.com |
Second Track Aftermath | Flask + gunicorn | chal2-flag |
chal3.entysec.com |
Second Track Reborn | Flask + gunicorn | chal3-flag |
chal4.entysec.com |
Big Software Foundation | PHP 7.2 + Apache | chal4-flag |
chal5.entysec.com |
Elf's Blog | Node + Chromium + Flask (admin bot) | chal5-flag |
Each challenge's entrypoint.sh reads the FLAG env var and writes it to the
file the app expects — so the flags live only in the ctf-flags Secret, never
baked into an image.
Prerequisites
- A running k3s cluster with Traefik installed (k3s ships it by default).
kubectlconfigured against the cluster.- Docker on the k3s node (to build images), or a container registry every node can reach (multi-node).
1. Build images and make them available to k3s
k3s uses containerd, not the Docker daemon — a docker build alone is not
visible to k3s. build-images.sh builds each image and imports it into k3s'
containerd store. Run it on the k3s node:
./build-images.sh
Multi-node cluster? Use a registry instead of per-node import:
REGISTRY=registry.entysec.com/ctf ./build-images.sh
# then prefix the image: names in the chalN-*.yaml manifests with the same registry
2. Deploy
./deploy.sh
That applies the namespace, the flags Secret, and all five challenges (each a Deployment + Service + Ingress), then waits for rollout.
Deploy a single challenge:
kubectl apply -f chal4-big-software-foundation.yaml
3. Point DNS at the cluster
Create DNS records (A/AAAA, or a *.entysec.com wildcard) for each host,
pointing at the IP where Traefik's entrypoint is exposed (the k3s node / load
balancer). Traefik routes by the Host header, so the subdomain must resolve to
the cluster.
Quick local test without DNS:
curl -H 'Host: chal1.entysec.com' http://<NODE_IP>/
4. (Optional) HTTPS
Two common options:
- cert-manager + Let's Encrypt — add a
ClusterIssuer, then atls:block referencing a cert Secret on each Ingress and switch the router entrypoint annotation towebsecure. - Traefik default TLS — set the annotation
traefik.ingress.kubernetes.io/router.tls: "true"and add atls:section.
Example TLS-enabled Ingress (with cert-manager):
metadata:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [chal1.entysec.com]
secretName: chal1-tls
Managing flags
Flags live in 01-flags-secret.yaml. After editing, re-apply and restart the
affected deployment so the new flag is written on boot:
kubectl apply -f 01-flags-secret.yaml
kubectl -n ctf rollout restart deploy/chal1-second-track
Tear down
kubectl delete namespace ctf
Notes
- Elf's Blog runs gunicorn + a Puppeteer admin bot in one container. It needs
more memory (limit 1Gi) and gets a tmpfs
/dev/shm. Chromium already launches with--no-sandbox, so no extra pod privileges are required. Keep it atreplicas: 1— it persists comments to the container filesystem. - The Flask apps read
PORT(set to 80); the PHP app listens on Apache's default 80. All Services target container port 80. - To reset a challenge's state (e.g. Elf's Blog comments):
kubectl -n ctf rollout restart deploy/chal5-elfs-blog.