Traefik as ingress on k3s: from local routing to public access without cert-manager
When I started setting up my homelab on a Raspberry Pi 4B, one of the first questions was: how do I make multiple services running on the cluster each respond on their own domain, without opening a different port for each one?
I already knew the answer — I remembered working with EKS on AWS in the past, where ingress was a separate component that needed to be installed and configured. On k3s, it comes included, name and all: Traefik.
What k3s ships with by default
k3s is not “vanilla” Kubernetes. It comes with some choices already made:
- Traefik as the ingress controller
- CoreDNS for name resolution
- Flannel as the CNI
- local-path-provisioner for volumes
- SQLite instead of etcd — much lighter
These design decisions make k3s a natural fit for ARM hardware with limited resources, like the Raspberry Pi. And if you come from the world of nginx-ingress-controller or expected to configure your own ingress, it’s a pleasant surprise to find that everything is already there — no setup needed before you start using it.
The Traefik that ships with k3s exposes two entrypoints by default:
web→ port 80 (HTTP)websecure→ port 443 (HTTPS)
And this is where the important difference between using Traefik “the standard way” and using it the way it was designed to be used begins.
IngressRoute vs Ingress: what’s the difference?
Kubernetes has a native resource called Ingress that any ingress controller can implement. It’s the standard API, works with anything, and you’ll probably find examples of it in every documentation.
Traefik supports standard Ingress. But it also exposes its own CRDs (Custom Resource Definitions) — and the main one is IngressRoute.
Why use IngressRoute instead of Ingress?
Because standard Ingress was designed to be generic. It uses annotations that each controller interprets differently, creating a layer of indirection. With IngressRoute, you write the intent directly in YAML — no magic annotations, no memorizing what each controller accepts.
In practice, the difference looks like this:
Standard Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
rules:
- host: gitea.homelab.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 3000Traefik IngressRoute:
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: gitea
namespace: apps
spec:
entryPoints:
- web
routes:
- match: Host(`gitea.homelab.local`)
kind: Rule
services:
- name: gitea
port: 3000I prefer the second. It’s more explicit — you see exactly which entrypoint, which match rule, and which destination service. No implicit layers.
The homelab setup: hostname-based routing
In my cluster I have services spread across different namespaces, each responding on its own hostname:
| Host | Service | Namespace |
|---|---|---|
gitea.homelab.local | Gitea (port 3000) | apps |
docs.homelab.local | Docusaurus (port 80) | docs |
internal.homelab.local | Internal docs (port 80) | docs |
Each has its own IngressRoute in the corresponding namespace. Traefik discovers all of them automatically because it watches CRDs across all cluster namespaces.
DNS for *.homelab.local I resolve locally — via /etc/hosts on the machines I access from, or via Pi-hole when I want the whole network to see it. Not elegant, but sufficient for personal use.
TLS: why I don’t have cert-manager
The direct answer: resources. The Raspberry Pi 4B has 4 GB of RAM, and cert-manager brings three permanent pods (controller, webhook, cainjector) that consume between 100–200 MB at idle. In my memory budget, that’s significant.
But the bigger issue is conceptual: why have local TLS if external access is already HTTPS through another path?
My solution was Cloudflare Tunnel. Instead of exposing ports on the router and managing Let’s Encrypt certificates, I run a cloudflared pod in the cluster that opens an outbound connection to the Cloudflare network. Public traffic enters through Cloudflare (HTTPS with their certificate), crosses the tunnel, and arrives at the internal services as plain HTTP.
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: infra
spec:
replicas: 1
template:
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- run
- --token
- $(TUNNEL_TOKEN)
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-secret
key: tokenThe token comes from a Secret created manually once in the cluster. The mapping from public domain to internal service lives in the Cloudflare Zero Trust dashboard — no YAML, no redeploying anything.
Result: TLS at the public edge (Cloudflare handles it), plain HTTP internally (Traefik routes it), no cert-manager, no certificate renewal to manage.
What this solves and what it doesn’t
This setup works well for a homelab because:
- No open ports on the router
- No public IP exposure
- Automatic TLS via Cloudflare, no Let’s Encrypt
- Minimal resources on the Pi
- Adding new services is trivial: one
IngressRouteand an entry in the Cloudflare dashboard
What it doesn’t solve: if you want internal TLS — requests inside the cluster over HTTPS — you’ll still need cert-manager or an internal CA. In my case, internal traffic is HTTP and I accept that tradeoff.
Adding a new service
The practical flow when I bring something new up on the cluster:
- Create the
Deployment,Service(ClusterIP, internal port), andIngressRoutein the project’s repository - The Gitea runner detects the push and applies the manifests to the cluster via
kubectl - Add an entry to the local
/etc/hosts(or wait for Pi-hole to pick it up) - If I want external access: add the public hostname in Cloudflare Zero Trust pointing to
http://service.namespace.svc.cluster.local:port
Today each project has its own Gitea pipeline that handles deployment — no manual apply.
There’s no “reload ingress configuration” step. Traefik watches CRDs in real time and starts routing immediately.
It’s this combination — k3s with Traefik built in, declarative IngressRoute, and Cloudflare Tunnel for public exposure — that made the networking side of the homelab simple enough for me to focus on what actually matters: continuous improvement of the environment and its services.