An Ingress is a Kubernetes object that describes how external HTTP and HTTPS traffic reaches Services inside the cluster: which hostname and path go to which Service, and which TLS certificate to use. The Ingress itself does nothing until an Ingress controller reads it and configures a reverse proxy. In this tutorial you will install Traefik as the Ingress controller, route two applications by path and by hostname, and secure them with automatic Let's Encrypt certificates from cert-manager.

Prerequisites

To follow this tutorial you need:

  • A Kubernetes cluster (1.30 or later) with at least one worker node, for example one built on CubePath VPS instances with kubeadm on Ubuntu 24.04.
  • kubectl configured with cluster admin access.
  • A domain name with DNS A records pointing to a node that will receive traffic. This guide uses app.your_domain and api.your_domain.
  • Ports 80 and 443 open on that node. With UFW: sudo ufw allow 80,443/tcp.

Step 1 - Installing Helm

Helm is the package manager for Kubernetes, and it is the supported way to install both Traefik and cert-manager. On Ubuntu 24.04, install it from the Snap Store on the machine where you run kubectl:

sudo snap install helm --classic
helm version

Step 2 - Installing the Traefik Ingress controller

Add the official Traefik chart repository:

helm repo add traefik https://traefik.github.io/charts
helm repo update

How Traefik receives traffic depends on your cluster:

  • The cluster supports LoadBalancer Services (a managed Kubernetes service, or MetalLB on bare metal): the chart defaults are enough, and the Service gets an external IP.
  • A self-built kubeadm cluster without a load balancer: run Traefik as a DaemonSet that binds ports 80 and 443 directly on each node through hostPort. This is the setup used below.

Create a values file:

nano traefik-values.yaml
deployment:
  kind: DaemonSet

service:
  type: ClusterIP

ports:
  web:
    hostPort: 80
  websecure:
    hostPort: 443

Install the chart into its own namespace:

helm install traefik traefik/traefik \
  --namespace traefik --create-namespace \
  -f traefik-values.yaml

If your cluster does support LoadBalancer Services, run the same command without -f traefik-values.yaml.

Check that one Traefik Pod runs on each worker node:

kubectl get pods -n traefik -o wide
NAME            READY   STATUS    RESTARTS   AGE   IP           NODE
traefik-4k8zq   1/1     Running   0          40s   10.244.1.9   worker-1
traefik-x7m2d   1/1     Running   0          40s   10.244.2.6   worker-2

The chart also creates an IngressClass named traefik and marks it as the default. Ingress objects refer to it with ingressClassName:

kubectl get ingressclass
NAME      CONTROLLER                      PARAMETERS   AGE
traefik   traefik.io/ingress-controller   <none>       1m

A request to a node now reaches Traefik, which answers 404 because no routes exist yet:

curl -I http://your_node_ip
HTTP/1.1 404 Not Found

Step 3 - Deploying two sample applications

You need something to route to. Deploy two small echo servers, each with its own ClusterIP Service:

nano apps.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-a
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app-a
  template:
    metadata:
      labels:
        app: app-a
    spec:
      containers:
        - name: echo
          image: hashicorp/http-echo:1.0
          args: ["-text=Hello from app A"]
          ports:
            - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: app-a
spec:
  selector:
    app: app-a
  ports:
    - port: 80
      targetPort: 5678
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-b
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app-b
  template:
    metadata:
      labels:
        app: app-b
    spec:
      containers:
        - name: echo
          image: hashicorp/http-echo:1.0
          args: ["-text=Hello from app B"]
          ports:
            - containerPort: 5678
---
apiVersion: v1
kind: Service
metadata:
  name: app-b
spec:
  selector:
    app: app-b
  ports:
    - port: 80
      targetPort: 5678

Apply it and wait for both Deployments:

kubectl apply -f apps.yaml
kubectl rollout status deployment/app-a
kubectl rollout status deployment/app-b

Step 4 - Routing by path

A path-based Ingress sends different URL prefixes on the same hostname to different Services. Create it:

nano ingress-paths.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-paths
spec:
  ingressClassName: traefik
  rules:
    - host: app.your_domain
      http:
        paths:
          - path: /a
            pathType: Prefix
            backend:
              service:
                name: app-a
                port:
                  number: 80
          - path: /b
            pathType: Prefix
            backend:
              service:
                name: app-b
                port:
                  number: 80

pathType controls how the path matches:

pathTypeMatches /aMatches /a/xMatches /abc
ExactYesNoNo
PrefixYesYesNo

Prefix matches whole path segments, so /a does not match /abc.

Apply the Ingress and check that it was accepted:

kubectl apply -f ingress-paths.yaml
kubectl get ingress demo-paths

Test both paths. The Host header lets you test before DNS has propagated:

curl -H "Host: app.your_domain" http://your_node_ip/a
curl -H "Host: app.your_domain" http://your_node_ip/b
Hello from app A
Hello from app B

Step 5 - Routing by hostname

Host-based routing gives each application its own hostname, which is the usual choice for separate services. Replace the previous Ingress with one rule per host:

kubectl delete -f ingress-paths.yaml
nano ingress-hosts.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo
spec:
  ingressClassName: traefik
  rules:
    - host: app.your_domain
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-a
                port:
                  number: 80
    - host: api.your_domain
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-b
                port:
                  number: 80

Apply and test it:

kubectl apply -f ingress-hosts.yaml
curl http://app.your_domain
curl http://api.your_domain
Hello from app A
Hello from app B

If these commands fail but the Host header test works, your DNS records are not pointing to the node yet. Check them with dig +short app.your_domain.

Step 6 - Adding automatic HTTPS with cert-manager

cert-manager requests certificates from Let's Encrypt, stores them as Kubernetes Secrets and renews them before they expire. Install it from its official OCI chart, including its CustomResourceDefinitions:

helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true

Wait for its three Pods (cert-manager, cert-manager-cainjector and cert-manager-webhook) to be running:

kubectl get pods -n cert-manager

Create a ClusterIssuer that uses the Let's Encrypt production API and solves the HTTP-01 challenge through Traefik. Replace your_email with an address you control:

nano cluster-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your_email
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik

Apply it and check that the ACME account was registered:

kubectl apply -f cluster-issuer.yaml
kubectl get clusterissuer letsencrypt-prod
NAME               READY   AGE
letsencrypt-prod   True    10s

Now request certificates for the Ingress. Add the cert-manager.io/cluster-issuer annotation and a tls section to ingress-hosts.yaml. The metadata and spec blocks become:

metadata:
  name: demo
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - app.your_domain
        - api.your_domain
      secretName: demo-tls
  rules:
    # keep the two rules from Step 5 unchanged

Apply it. cert-manager sees the annotation, creates a Certificate object and completes the challenge:

kubectl apply -f ingress-hosts.yaml
kubectl get certificate

Issuance usually takes less than a minute:

NAME       READY   SECRET     AGE
demo-tls   True    demo-tls   45s

Verify the certificate from outside:

curl -v https://app.your_domain 2>&1 | grep -E 'subject:|issuer:'
*  subject: CN=app.your_domain
*  issuer: C=US; O=Let's Encrypt; CN=R12

Step 7 - Redirecting HTTP to HTTPS

Traefik configures features such as redirects through Middleware objects, which you attach to an Ingress with an annotation. Create a middleware that redirects to HTTPS:

nano redirect-https.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: redirect-https
  namespace: default
spec:
  redirectScheme:
    scheme: https
    permanent: true
kubectl apply -f redirect-https.yaml

Reference it from the Ingress. The value format is <namespace>-<name>@kubernetescrd:

kubectl annotate ingress demo traefik.ingress.kubernetes.io/router.middlewares=default-redirect-https@kubernetescrd

Add the same annotation to ingress-hosts.yaml so the next kubectl apply keeps it. Then check the redirect:

curl -I http://app.your_domain
HTTP/1.1 308 Permanent Redirect
Location: https://app.your_domain/

Certificate renewals keep working, because cert-manager publishes its challenge on a separate, more specific route.

Troubleshooting

Traefik returns 404 for your hostname. Check kubectl describe ingress demo: the ingressClassName must be traefik, the host must match the request exactly, and every backend must list endpoints. kubectl logs -n traefik -l app.kubernetes.io/name=traefik shows configuration errors.

502 Bad Gateway or Service Unavailable. Traefik found the route but not a ready Pod. Check the Service selector and the Pods with kubectl get endpointslices -l kubernetes.io/service-name=app-a.

The certificate stays READY False. Follow the chain with kubectl describe certificate demo-tls, then kubectl get challenges and kubectl describe challenge <name>. The usual causes are a DNS record that does not point to the node, or port 80 blocked by the firewall, so Let's Encrypt cannot reach the challenge URL.

Connections to ports 80 or 443 time out. Confirm that a Traefik Pod runs on the node you are testing and that UFW allows 80,443/tcp on it.

Conclusion

You installed Traefik as the Ingress controller, routed traffic to two Services by path and by hostname, and secured them with Let's Encrypt certificates that cert-manager renews automatically. Every new application now needs only a ClusterIP Service and an Ingress with the cert-manager.io/cluster-issuer annotation.

As next steps, add more Traefik middlewares (basic authentication, rate limiting, security headers), look at the Gateway API, which Traefik also supports, for more advanced routing, and give your applications persistent storage if they need it.