2026-07-12 20:32:52 +01:00
..
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00
2026-07-12 20:32:52 +01:00

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).
  • kubectl configured 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 a tls: block referencing a cert Secret on each Ingress and switch the router entrypoint annotation to websecure.
  • Traefik default TLS — set the annotation traefik.ingress.kubernetes.io/router.tls: "true" and add a tls: 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 at replicas: 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.