Traefik v3 + Gateway API on K3s with cert-manager and Let's Encrypt
A practical guide to setting up Traefik v3 as a Gateway API ingress controller on K3s, with cert-manager automating Let's Encrypt TLS certificates — including the pitfalls around CRD ordering, internal port conflicts, and Gateway configuration.
Kubernetes Ingress is dead. Long live Gateway API.
If you have been running Kubernetes for a while, you know the Ingress resource. It works, but it has always felt a bit bolted on: limited by design, patched with vendor-specific annotations, and never quite expressive enough for anything beyond basic HTTP routing.
The Gateway API is its structured, extensible successor. It has been stable since Kubernetes 1.24, is now the recommended approach, and is supported by all major ingress controllers, including Traefik v3.
This post walks through setting up Traefik as an ingress controller on K3s using the Gateway API, with cert-manager handling Let’s Encrypt certificates automatically.
The stack
- K3s as the Kubernetes distribution
- Traefik v3 as the ingress controller
- Gateway API for routing
- cert-manager for TLS certificate management
- Let’s Encrypt as the certificate authority
Step 1: Install Gateway API CRDs
The Gateway API CRDs are not included in K3s by default. Install them before anything else. This ordering matters: if you install Traefik first, its Helm chart will try to install its own bundled version of the CRDs, which may conflict.
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yaml
Verify the CRDs are present:
kubectl get crd | grep gateway
You should see resources including gateways.gateway.networking.k8s.io and httproutes.gateway.networking.k8s.io.
Step 2: Install Traefik CRDs
Traefik has its own set of CRDs for IngressRoute, Middleware, and other Traefik-specific resources. Install these separately before installing Traefik itself.
The traefik/traefik-crds Helm chart exists for this purpose, but helm install fails with a “Secret too long” error due to the size of the CRD bundle. The workaround is to use helm template piped directly to kubectl apply, which bypasses Helm’s release state management:
helm repo add traefik https://helm.traefik.io/traefik
helm repo update
helm template traefik-crds traefik/traefik-crds | kubectl apply -f -
Verify both Gateway API and Traefik CRDs are present:
kubectl get crd | grep -E "gateway|traefik"
Step 3: Install Traefik
With all CRDs in place, install Traefik using --skip-crds to prevent any conflict with what we just installed.
There are two important configuration choices here.
Disable the built-in Gateway resource. The Traefik Helm chart can auto-generate a Gateway resource, but its TLS configuration options are limited. Setting gateway.enabled: false lets us manage the Gateway ourselves, which is necessary when using cert-manager for certificate provisioning.
Watch out for internal port conflicts. Traefik defaults to internal ports 8080 and 8443. On K3s, the built-in ServiceLB may already be binding port 8080 on the host. Moving Traefik’s internal ports to 9080 and 9443 avoids this, while still exposing ports 80 and 443 externally via exposedPort.
Create traefik-values.yaml:
providers:
kubernetesGateway:
enabled: true
kubernetesCRD:
enabled: true
kubernetesIngress:
enabled: true
gateway:
enabled: false
ports:
web:
port: 9080
exposedPort: 80
websecure:
port: 9443
exposedPort: 443
service:
type: LoadBalancer
logs:
access:
enabled: true
Install Traefik:
helm install traefik traefik/traefik \
--namespace traefik \
--create-namespace \
-f traefik-values.yaml \
--skip-crds
Verify:
kubectl get pods -n traefik
kubectl get svc -n traefik
kubectl get gatewayclass
The GatewayClass traefik should show Accepted: True.
Step 4: Install cert-manager
cert-manager automates certificate provisioning from Let’s Encrypt. The critical detail here is enabling Gateway API support explicitly. Without it, cert-manager’s HTTP-01 solver falls back to creating Ingress resources instead of HTTPRoutes, which will fail when there is no Ingress controller present.
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true \
--set config.apiVersion="controller.config.cert-manager.io/v1alpha1" \
--set config.kind="ControllerConfiguration" \
--set config.enableGatewayAPI=true
Verify all three pods are running:
kubectl get pods -n cert-manager
Step 5: Create the Gateway
The Gateway resource defines the entry points for incoming traffic. For HTTPS with multiple domains, use a separate listener per hostname rather than a single listener with multiple certificate refs. This ensures correct certificate selection per domain via SNI.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: traefik
namespace: traefik
spec:
gatewayClassName: traefik
listeners:
- name: http
protocol: HTTP
port: 9080
allowedRoutes:
namespaces:
from: All
kinds:
- group: gateway.networking.k8s.io
kind: HTTPRoute
- name: https-example
protocol: HTTPS
port: 9443
hostname: example.com
allowedRoutes:
namespaces:
from: All
kinds:
- group: gateway.networking.k8s.io
kind: HTTPRoute
tls:
mode: Terminate
certificateRefs:
- name: example-tls
namespace: traefik
Add a new listener block for each additional domain you want to serve.
Note that the allowedRoutes.kinds block is required. Without explicitly specifying HTTPRoute, the Gateway API will reject routes from other namespaces with a NotAllowedByListeners error.
Step 6: ClusterIssuer and Certificates
Create a ClusterIssuer to tell cert-manager how to obtain certificates from Let’s Encrypt:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: your@email.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
gatewayHTTPRoute:
parentRefs:
- name: traefik
namespace: traefik
kind: Gateway
Then create a Certificate resource per domain. Store the resulting secret in the traefik namespace so the Gateway can reference it:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: example-com
namespace: traefik
spec:
secretName: example-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- example.com
cert-manager will automatically handle the HTTP-01 challenge by creating a temporary HTTPRoute, obtain the certificate from Let’s Encrypt, and store it as a Kubernetes secret.
Monitor progress:
kubectl get certificate -n traefik
kubectl get challenges -n traefik
Step 7: Route traffic with HTTPRoute
With the Gateway and certificates in place, expose a service using an HTTPRoute. Reference the correct listener via sectionName:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: my-app
spec:
parentRefs:
- name: traefik
namespace: traefik
sectionName: https-example
hostnames:
- example.com
rules:
- backendRefs:
- name: my-app
port: 80
Lessons learned
Install Gateway API CRDs first. The Traefik Helm chart bundles its own CRD versions. Installing them separately and using --skip-crds during Helm install avoids version conflicts and the ValidatingAdmissionPolicy errors that come with them.
Enable Gateway API support in cert-manager explicitly. The enableGatewayAPI=true flag is not the default. Without it, the HTTP-01 challenge solver will not create HTTPRoutes and certificate issuance will fail.
Use per-hostname listeners for HTTPS. A single HTTPS listener with multiple certificate refs can work, but per-hostname listeners are more explicit and avoid certificate selection ambiguity.
Watch for internal port conflicts on K3s. K3s ships with ServiceLB which binds ports on the host. Traefik’s default internal ports (8080, 8443) may conflict. Using 9080/9443 internally while keeping 80/443 exposed resolves this without any impact on external traffic.
gateway.enabled: false is the right call with cert-manager. Traefik’s auto-generated Gateway does not give enough control over TLS configuration. Managing the Gateway resource manually is cleaner and more predictable.