Moving from Docker Compose to k3s: Flannel, Traefik and the Gateway API in simple terms
Hi Guys, I moved the Kithly backend from a Docker Compose setup on a home server to a single-node k3s cluster. This post explains what Flannel, Traefik and the Gateway API actually do, in plain words, and the one networking surprise that cost me eight minutes of downtime…
Hi Guys, the Kithly backend (a Go API, a few queue workers and a video transcoder) has been running on a home server with Docker Compose for a while. Last week I moved it to k3s, a small Kubernetes. In this post I’ll show where we started, why I picked k3s, and what the three pieces everyone mentions really do: Flannel, Traefik and the Gateway API. I’ll keep it simple, use a few analogies, and finish with how the cutover went, including the part where I broke production for eight minutes.
All the code is open source at deBilla/kithly-api. The bits this post covers are in deploy/k3s (bootstrap and ServiceLB scripts), deploy/helm (the chart) and docs/K8S_DEPLOYMENT.md.
Where we started: one Compose file
The whole stack lived in one docker-compose.yml. Here’s a trimmed version so you can see the shape of it:
services:
api:
build: { context: ., args: { BINARY: api } }
ports:
- "80:3001"
env_file: [.env]
environment:
- REDIS_HOST=redis
worker:
build: { context: ., args: { BINARY: worker } }
env_file: [.env]
transcoder-worker:
build: { context: ., args: { BINARY: transcoder } }
deploy:
resources:
limits: { cpus: "2", memory: 4G }
bull-board-dashboard:
build: { context: ., args: { BINARY: dashboard } }
ports:
- "3002:3002"
redis:
image: redis:7-alpine
Public traffic came in through a Cloudflare Tunnel. The cloudflared daemon on the server sent api.example.com to localhost:80 and the dashboard to localhost:3002. TLS ended at Cloudflare, so the server only ever spoke plain HTTP on localhost.
Honestly, this worked. Compose is great. But a few things kept bothering me:
- Images were built on the server. Every deploy was SSH,
git pull,docker compose up --build. The server is also where the transcoder burns CPU, so builds and real work fought each other. - No real rollback. If a bad build went out, “rollback” meant checking out an older commit and building again.
- No health-gated rollout. Compose stops the old container and starts the new one. If the new one crashes on boot, you’re down until you notice.
- Redis wasn’t persisted. A restart lost whatever jobs were queued.
- One worker. Scaling meant copy-pasting service blocks.
None of these are Compose’s fault. They’re just the things Kubernetes was built for.
Why k3s and not “real” Kubernetes
Full Kubernetes is a box of parts. You choose a container runtime, a network plugin, a DNS server, an ingress controller, a load balancer and a storage driver, and then you wire them together.
k3s is Kubernetes that ships as one binary with all of those already chosen for you:
- containerd to run containers
- Flannel for pod networking
- CoreDNS for in-cluster names
- Traefik as the reverse proxy at the edge
- ServiceLB to expose services on the host’s ports
- local-path storage, so a volume is just a folder on the disk
For a single home server that’s exactly what I want. It’s still certified Kubernetes, so everything I learn here works on EKS or GKE later. I’d pick k3s over Docker Swarm every time now, because the skills carry over and Swarm’s don’t.
Kubernetes in four words
Before we get to the networking, here’s how the Compose ideas map across:
| Compose | Kubernetes | What it means |
|---|---|---|
a service block |
Deployment | “Keep N copies of this container running” |
| a running container | Pod | One running copy (one or more containers) |
| the service name as a hostname | Service | A stable name and IP in front of the pods |
ports: "80:3001" |
Gateway / LoadBalancer | How traffic gets in from outside |
The important one is the Service. Pods come and go, and every new pod gets a new IP. A Service is a fixed address that always points at whichever pods are currently healthy. When the API wants Redis, it asks for kithly-redis and doesn’t care which pod answers.
Flannel: giving every pod an address
Here’s the problem Flannel solves. In Kubernetes every pod gets its own IP address, and every pod must be able to reach every other pod directly, even if they sit on different machines. Nobody sets that up for you. A plugin called a CNI (Container Network Interface) does it, and in k3s that plugin is Flannel.
Think of the cluster as an apartment complex:
- Each node (machine) is a building.
- Each pod is an apartment with its own number.
- Flannel is the postal system. It hands every building a block of apartment numbers and makes sure a letter addressed to apartment 10.42.1.7 reaches the right building, whichever building it was posted from.
In k3s the defaults look like this:
- Pods get addresses from
10.42.0.0/16. Each node takes a/24slice of that. - Services get virtual addresses from
10.43.0.0/16. These aren’t real interfaces. kube-proxy writes iptables rules that catch traffic to a Service IP and forward it to one of its pods.
Across machines, Flannel’s default backend is VXLAN: it wraps a pod-to-pod packet inside a normal UDP packet, sends it to the other node, and unwraps it there. On my single node there’s no other building, so most traffic just crosses a local bridge called cni0. The addresses still matter, though, and that’s where I hit my first problem.
The firewall gotcha
The server runs ufw. Out of the box, ufw saw traffic from 10.42.x.x (a pod) going to CoreDNS and dropped it, because it wasn’t on the allow list. The symptom wasn’t “firewall blocked”. The symptom was DNS failing silently inside every pod, so the API couldn’t find kithly-redis.
The fix is to allow both ranges. This lives in the bootstrap script:
# Without these, ufw drops pod -> CoreDNS / ClusterIP traffic and DNS fails silently.
sudo ufw allow from 10.42.0.0/16 to any comment 'k3s pods'
sudo ufw allow from 10.43.0.0/16 to any comment 'k3s services'
There was a second DNS trap. The host’s /etc/resolv.conf listed IPv6 link-local servers like fe80::…%2, and CoreDNS can’t use zone-scoped addresses like that. So k3s gets its own clean file with a single upstream, like your router or 1.1.1.1:
printf 'nameserver %s\n' "$UPSTREAM_DNS" | sudo tee /etc/rancher/k3s/resolv.conf
and resolv-conf: /etc/rancher/k3s/resolv.conf goes in the k3s config. If your pods can’t resolve anything, check these two first.
Traefik: the receptionist at the front door
Flannel moves packets between pods. But something has to stand at the edge, take an HTTP request for api.example.com, and decide which Service gets it. That’s a reverse proxy, and in Kubernetes the one doing that job is often called an ingress controller.
Traefik is the one k3s bundles. Think of it as the receptionist in the lobby. Every visitor comes through the same door, says who they’re here to see (the Host header and the path), and the receptionist walks them to the right office.
In Compose, the API published 80:3001 directly, so there was no receptionist at all. Port 80 was the API. That’s fine for one service and gets messy the moment you want a second hostname on the same port.
Traefik needs a list of rules to work from. There are two ways to write that list in Kubernetes: the old Ingress resource, and the newer Gateway API. I went with the Gateway API.
The Gateway API: three objects, three jobs
Ingress was Kubernetes’ first attempt at HTTP routing. It’s one object that holds everything: the hostname, the path, the backend, and the TLS settings. Anything beyond the basics (timeouts, header rewrites, traffic splitting) ended up in controller-specific annotations, so an Ingress written for nginx didn’t work on Traefik.
The Gateway API is its replacement. It splits that one object into three, and each one has a clear owner:
- GatewayClass: which kind of receptionist. “We use Traefik.” Usually set up once by whoever runs the cluster.
- Gateway: the front door. Which port, which protocol, and who is allowed to attach routes to it.
- HTTPRoute: the directory on the wall. “Visitors for
api.example.comgo to thekithly-apiService on port 3001.” Owned by the app team.
The split matters even on a one-person project. My routes live in the app’s Helm chart and ship through CI with the code. The door and the receptionist are cluster plumbing that I set up once.
Turning it on in k3s
k3s’s Traefik ships with the Gateway API provider turned off. You switch it on by dropping a HelmChartConfig into the k3s manifests folder, and k3s picks it up automatically:
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
providers:
kubernetesGateway:
enabled: true
gateway:
enabled: false
gatewayClass:
enabled: true
That gives me a GatewayClass called traefik. I turned off Traefik’s default Gateway because I want the app’s chart to own its own front door, next to its routes. One more thing: k3s already installs the Gateway API CRDs through its own traefik-crd chart. Don’t install a second copy, or the two will fight over who owns them.
The Gateway and the route
This is what the Helm chart renders. First the front door:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: kithly
spec:
gatewayClassName: traefik
listeners:
# Plain HTTP: TLS is terminated at Cloudflare and the tunnel connects over localhost.
- name: http
protocol: HTTP
port: 8000
allowedRoutes:
namespaces:
from: Same
Then the directory entry that hangs on it:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kithly-api
spec:
parentRefs:
- name: kithly
sectionName: http
hostnames:
- api.example.com
- api.example.org
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: kithly-api
port: 3001
Notice the listener port is 8000, not 80. That’s because the port on a Traefik Gateway listener has to match a Traefik entrypoint, and Traefik’s web entrypoint listens on 8000 inside its container. Host port 80 gets mapped to it further out. If you write port: 80 here, the Gateway never becomes ready and nothing tells you why very loudly.
In the chart, adding a new hostname is now a two-line change in values.yaml:
gateway:
routes:
api:
service: api
hostnames:
- api.example.com
- api.example.org
ServiceLB, and how I broke production
So Traefik is listening on 8000 inside a pod. How does traffic arriving at the server’s port 80 actually get there? In the cloud, a LoadBalancer Service would get a real cloud load balancer. On bare metal, k3s uses ServiceLB.
ServiceLB is simple. For every LoadBalancer Service, it runs a tiny pod on the node that claims the same port on the host using a hostPort. Traefik’s Service asks for 80 and 443, so ServiceLB maps host :80 to Traefik. The dashboard Service asks for 3002, so host :3002 goes to the dashboard. That meant the Cloudflare tunnel config never had to change. It still sends traffic to localhost:80 and localhost:3002, and whoever owns those ports gets it.
Here’s the surprise. I assumed that while Compose still had port 80, ServiceLB would fail to bind it and wait. It doesn’t bind anything. A hostPort is an iptables DNAT rule, not a socket. It rewrites packets before they ever reach Docker’s listener. So the moment k3s started for the first time, with ServiceLB on by default, it quietly stole port 80 from the running Compose API and sent it to a Traefik with no routes yet. That was about eight minutes of API outage on the first bootstrap.
The fix was to keep ServiceLB off from the very first boot and turn it on only at cutover. k3s lets you do that with a node label, and the label has to be set at registration time through a config drop-in, because k3s re-applies node labels on every start:
# /etc/rancher/k3s/config.yaml.d/50-servicelb.yaml
node-label:
- svccontroller.k3s.cattle.io/enablelb=false
A small servicelb.sh on|off script flips both the live label and that file, so a reboot keeps whatever state I chose. It also refuses to turn on while Docker still listens on :80, so I can’t repeat the mistake.
To check that the localhost-to-hostPort path works without touching port 80, the bootstrap starts a throwaway traefik/whoami pod with hostPort: 18080, curls localhost:18080, and deletes it. That’s the exact path cloudflared will use, tested on a port nobody cares about.
The full path of one request
Here’s everything together, from a phone to a Go handler:
phone
-> Cloudflare (TLS ends here)
-> Cloudflare Tunnel -> cloudflared on the server
-> http://localhost:80
-> ServiceLB hostPort (iptables DNAT)
-> Traefik pod, "web" entrypoint :8000
-> Gateway "kithly", listener "http"
-> HTTPRoute matches Host: api.example.com
-> Service kithly-api (ClusterIP, 10.43.x.x)
-> kube-proxy picks a ready pod
-> API pod (10.42.x.x) on :3001, over Flannel's cni0 bridge
Every arrow is one of the things we’ve talked about. If a request goes missing, you walk this list from the top.
Deploying it: CI builds, Helm ships
The other half of the move was getting builds off the server. GitHub Actions now does this on every push to main:
go vetandgo test -race ./...helm lintandhelm template | kubeconform -strictto catch broken YAML before it reaches the cluster- Build one image per binary and push it to GHCR, tagged with the commit SHA
- Over SSH, run
helm upgrade --install --rollback-on-failure
That last flag is the one I like most. If the new pods don’t become ready within ten minutes, Helm rolls the release back on its own. Together with a rolling update that brings the new pod up before taking the old one down, a bad build no longer means downtime:
strategy:
type: RollingUpdate
rollingUpdate:
# Bring the new pod up before taking the old one down.
maxSurge: 1
maxUnavailable: 0
Two smaller tricks worth stealing:
- A checksum of the secrets as a pod annotation. Kubernetes doesn’t restart pods when a Secret changes. Putting a hash of the secret contents on the pod template means a rotated secret changes the template, so the next deploy rolls every pod.
- Keep the last 20 images in GHCR. Then
helm rollbackcan go back up to 19 deploys and the image is still there to pull.
Workers went from one to four with replicas: 4. Redis now uses AOF on a local-path volume, so queued jobs survive a restart.
The cutover: about 12 seconds
Because the k3s stack had its own Redis, it could run next to Compose without the two sharing queues. The workers have no scheduled jobs, so nothing ran twice. The cutover was:
- Check the k8s API through Traefik’s NodePort with a
Host: api.example.comheader, before it has any public traffic. - Delete the old Compose deploy workflow, so a push can’t bring Compose back and fight for the ports.
- Let the Compose queue drain on the dashboard, then
docker compose down. servicelb.sh on, thencurl https://api.example.com/health.
The gap between Compose letting go of the ports and ServiceLB taking them was about 12 seconds. The rollback plan was just as short: servicelb.sh off, then docker start the old containers, which I’d stopped rather than removed.
Was it worth it?
For a single server, Kubernetes is more moving parts than Compose, and I won’t pretend otherwise. But k3s makes most of those parts somebody else’s problem, and what I got back is worth it: builds off the server, automatic rollback, zero-downtime deploys, persisted queues, and routing I can change with a pull request. If you want to copy the setup, start from deploy/k3s/bootstrap.sh in the repo.
If you’re doing the same move, my advice comes down to three things:
- Allow
10.42.0.0/16and10.43.0.0/16in your firewall before you debug anything else. - Turn ServiceLB off from first boot if anything else on the host already owns 80 or 443.
- Use the Gateway API, not Ingress, for new setups. The three-object split is easier to reason about, and it’s where Kubernetes is going.
Next up I’ll write about moving the database off Supabase into the same cluster, with nightly backups and a row-count-verified import.
Happy Coding ;)
Ask the archive
Got a question about this or anything else here? Ask, and get the passage that answers it — from all 97 posts, in your browser.