Security.md
$ cat security.md

Threat model

The cluster runs multiple workloads — this site, an MCP server, and other experiments. The Kubernetes API is the most sensitive surface: anyone reaching it with valid credentials gets full control of every workload. Everything below is about narrowing who can reach that API and what they can do once there.

Control Plane ACL

LKE supports an IP allowlist on the API server (the “Control Plane ACL”). It’s enabled and populated with the IP ranges that need long-lived access (admin, internal services).

CI deploys are the awkward case — GitHub-hosted runners egress from Azure IP ranges that change every run. There’s no static IP to whitelist permanently.

The workflow handles this by:

  1. Reading the current ACL via the Linode API.
  2. Saving it verbatim.
  3. Writing a new ACL that adds 0.0.0.0/0 and ::/0 for the duration of the deploy.
  4. Running the deploy.
  5. Restoring the original ACL — always, even on failure (if: always()).

The open window is roughly 1–3 minutes per deploy. During that window, the cluster API is reachable from anywhere — but reaching it still requires the kubeconfig credentials, which live only in GitHub’s encrypted secret store.

Alternatives considered:

  • Whitelist only the runner’s egress IP. Tried it, didn’t work reliably — ACL propagation across control plane replicas lags long enough that kubectl often EOFs the first connections. Eventually consistent but not workable for short CI jobs.
  • In-cluster self-hosted runner. Most secure option: the runner lives in the cluster and uses a ServiceAccount, ACL stays totally closed. More moving parts to maintain — on the roadmap.
  • Tailscale/WireGuard. Workable middle ground. Adds a dependency.

Supply chain

A few specific decisions to reduce supply-chain risk on the CI runner, which holds Linode and kubeconfig credentials:

  • The Hugo theme is vendored into this repo. It used to be pulled as a Go module from a third-party GitHub account on every build. If that account got compromised, malicious theme code would execute on the runner with full credential access. Now the theme code is checked in directly, at a specific known state.
  • No remote module fetches at build time. go.mod was removed.

The actions are now forced onto the Node 24 runtime ahead of the Node 20 cutoff. Pinning them to commit SHAs and swapping peaceiris/actions-hugo@v2 for a direct binary install would tighten this further. On the list.

Traefik

The ingress controller runs with the dashboard and API disabled. Earlier versions of this manifest passed --api.insecure=true, which exposed Traefik’s dashboard on :8080 of every Traefik pod with no auth. Not externally exposed (the NodeBalancer only forwards 80/443), but it gave any pod in the cluster full read access to routing and cert metadata. Removed.

If the dashboard is ever needed again, the right way is an authenticated IngressRoute with a BasicAuth or ForwardAuth middleware — not the insecure flag.

What’s not protected by any of this

  • Anything baked into the public image — it’s a static site, so no code or secrets ship inside the container.
  • DNS — woodard.tech resolves to the NodeBalancer IP, which is intentionally public.

Index