A/B testing using the Traefik Kubernetes ingress controller
Split live traffic between app versions without a service mesh. This tutorial shows you how to A/B test on Civo Kubernetes using Traefik’s built-in weighted routing.
Written by
Technical Writer at Civo
Written by
Technical Writer at Civo
A/B testing, sometimes called split testing, is the practice of routing a percentage of your users to a newer version of an application so you can watch how it behaves in production before rolling it out to everyone. Instead of flipping every user over to a new release at once, you send a slice of live traffic to it, measure, and decide.
You can reach for a full service mesh like Istio or Linkerd to do this, but that is a lot of moving parts to introduce for traffic splitting alone. There is a good chance you already run an ingress controller in your cluster, so extending that to shift traffic between two versions is a much simpler approach.
This tutorial uses Traefik, a fast and lightweight ingress controller you can run on Civo Kubernetes. Traefik can split traffic across two backends using weighted round-robin, and it can route based on a request header, both without any extra components.
Why use Traefik for A/B testing?
- No service mesh required. Weighted traffic splitting is built into Traefik through its
TraefikServiceresource, so there is nothing extra to install on top of the ingress controller. - Two ways to split. You can split by percentage weight for a classic canary, or by request header when you want to target a specific set of users or testers.
- Native Kubernetes objects. The split is expressed as ordinary Custom Resources you can keep in Git and roll forward or back with
kubectl. - A gradual dial, not a switch. You start a new version at a small weight, watch it, and turn the dial up as your confidence grows.
- One lightweight deployment. Traefik runs as a single ingress controller pod and asks for a Civo Load Balancer, so there is very little to operate.
Prerequisites
- A Civo account
- The Civo CLI installed and authenticated (
civo apikey save <name> <key>) - kubectl installed and on your PATH
- Familiarity with Kubernetes Deployments, Services, and ingress
Launching a cluster using the Civo CLI
Create a small two-node cluster to work in:
civo k3s create --create-firewall --nodes 2 --size g4s.kube.medium \--save --switch --wait ab-demo
The --save --switch flags merge the new cluster into your kubeconfig and set it as the current context, so kubectl points at it as soon as the command returns. Confirm the nodes are ready:
kubectl get nodesNAME STATUS ROLES AGE VERSIONk3s-traefiklab-2a58-53387a-node-pool-9c18-rqgrh Ready <none> 37m v1.35.0+k3s1k3s-traefiklab-2a58-53387a-node-pool-9c18-yyv1p Ready <none> 37m v1.35.0+k3s1
Installing Traefik
Install Traefik with Helm and ask for a Civo Load Balancer so it gets a public IP:
helm repo add traefik https://traefik.github.io/chartshelm repo updatehelm install traefik traefik/traefik \--namespace traefik --create-namespace \--set service.type=LoadBalancer \--wait
Civo provisions a Load Balancer and attaches a public IP to the Traefik Service. Wait for that IP to appear:
kubectl -n traefik get svc traefikNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEtraefik LoadBalancer 10.43.14.41 212.2.240.191 80:30853/TCP,443:30211/TCP 2m
Traefik has a public IP from a Civo Load Balancer; this is the address your split test will answer on.
Deploying the application
We need two versions of an application to split traffic between. To keep the focus on the traffic split rather than on building an app, we'll use hashicorp/http-echo, a tiny web server that returns whatever text you give it. Version 1 returns one string and version 2 returns another, so you can tell at a glance which one served your request.
Deploy version 1:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: webapp-v1labels:app: webappversion: v1spec:replicas: 2selector:matchLabels:app: webappversion: v1template:metadata:labels:app: webappversion: v1spec:containers:- name: webappimage: hashicorp/http-echo:1.0.0args:- "-text=Hello from version 1"- "-listen=:5678"ports:- containerPort: 5678---apiVersion: v1kind: Servicemetadata:name: webapp-v1spec:selector:app: webappversion: v1ports:- port: 80targetPort: 5678EOF
Now deploy version 2. It is identical except for the labels and the text it returns:
kubectl apply -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata:name: webapp-v2labels:app: webappversion: v2spec:replicas: 2selector:matchLabels:app: webappversion: v2template:metadata:labels:app: webappversion: v2spec:containers:- name: webappimage: hashicorp/http-echo:1.0.0args:- "-text=Hello from version 2"- "-listen=:5678"ports:- containerPort: 5678---apiVersion: v1kind: Servicemetadata:name: webapp-v2spec:selector:app: webappversion: v2ports:- port: 80targetPort: 5678EOF
Check that both versions are running:
kubectl get pods -l app=webapp
Two replicas each of version 1 and version 2, ready to receive traffic.
Splitting traffic with a weighted service
Traefik expresses a weighted split through a TraefikService, a Custom Resource that sits in front of two or more ordinary Services and hands each request to one of them according to the weights you set.
We'll start by sending 60% of traffic to version 2 and keep 40% on version 1. Because Civo assigns your ingress a public IP through a Civo Load Balancer, we'll use a nip.io hostname built from that IP so you don't need to own a domain. First grab the IP that Traefik is serving on:
LB_IP=$(kubectl -n traefik get svc traefik \-o jsonpath='{.status.loadBalancer.ingress[0].ip}')HOST=ab.${LB_IP}.nip.ioecho "Using HOST=$HOST"
Now create the weighted TraefikService and an IngressRoute that points your hostname at it:
kubectl apply -f - <<EOFapiVersion: traefik.io/v1alpha1kind: TraefikServicemetadata:name: webapp-canaryspec:weighted:services:- name: webapp-v1port: 80weight: 40- name: webapp-v2port: 80weight: 60---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata:name: webappspec:entryPoints:- webroutes:- match: Host(\`${HOST}\`)kind: Ruleservices:- name: webapp-canarykind: TraefikServiceEOF
Note the kind: TraefikService on the route's service reference. That is what tells Traefik to route through the weighted split rather than straight to a plain Kubernetes Service.
Performing the split test
Send a handful of requests and watch the responses alternate between the two versions:
for i in $(seq 1 10); do curl -s http://${HOST}/; doneHello from version 1Hello from version 2Hello from version 1Hello from version 2Hello from version 2Hello from version 1Hello from version 2Hello from version 2Hello from version 1Hello from version 2
Roughly six out of every ten requests land on version 2 and the rest on version 1, which matches the 60/40 weight we set. Run the loop a few more times and the ratio holds. To count it directly:
for i in $(seq 1 100); do curl -s http://${HOST}/; done | sort | uniq -c42 Hello from version 158 Hello from version 2
Out of 100 requests, close to 60 are served by version 2 and 40 by version 1.
Routing by header instead of by weight
Percentage weights are the right tool when you want a random slice of all users on the new version. Sometimes you instead want a specific group, your QA team or a set of beta users, to always get version 2 while everyone else stays on version 1. Traefik can match on a request header for that.
Replace the IngressRoute with one that has two routes: a specific one that sends any request carrying X-Canary: always straight to version 2, and the original catch-all that keeps everyone else on the weighted split:
kubectl apply -f - <<EOFapiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata:name: webappspec:entryPoints:- webroutes:- match: Host(\`${HOST}\`) && Header(\`X-Canary\`, \`always\`)kind: Ruleservices:- name: webapp-v2port: 80- match: Host(\`${HOST}\`)kind: Ruleservices:- name: webapp-canarykind: TraefikServiceEOF
When two routes could both match a request, Traefik picks the one with the higher priority, and unless you set a priority yourself it derives one from the length of the match rule. The header route's rule is longer than the plain host rule, so it automatically wins for requests that carry the header. Resist the urge to pin an explicit low priority on the header route: a small number like priority: 10 is very likely lower than the length-derived priority of the plain host rule, and your header requests would end up on the weighted split instead.
A request with the header always gets version 2:
curl -s -H "X-Canary: always" http://${HOST}/Hello from version 2
While a request without it still follows the weighted split:
curl -s http://${HOST}/Hello from version 1
The X-Canary: always header pins a request to version 2; without it, the same host follows the 40/60 split.
Rolling out
Once version 2 has proven itself, you finish the rollout by dialing its weight up. Edit the TraefikService to send all traffic to version 2:
kubectl patch traefikservice webapp-canary --type=merge -p 'spec:weighted:services:- name: webapp-v1port: 80weight: 0- name: webapp-v2port: 80weight: 100'
Every request now returns version 2:
for i in $(seq 1 10); do curl -s http://${HOST}/; done | sort | uniq -c10 Hello from version 2
At this point version 2 is your production release. You can point the IngressRoute directly at the webapp-v2 Service and delete the canary resources, then retire the version 1 Deployment when you are ready.
Cleaning up
Delete the whole cluster when you are done so it stops billing:
civo k3s remove ab-demo
Conclusion
You have split live traffic between two versions of an application using nothing but Traefik, first by percentage weight for a classic canary and then by request header to target a specific audience. Because the split is expressed as ordinary Kubernetes resources, you can version it in Git and turn the dial up or down with a single kubectl command.
From here, you might look at exposing a gRPC service through Traefik, or at wiring metrics into the rollout so the decision to promote a version is driven by data rather than by eye.

Technical Writer at Civo
Jubril Oyetunji is a DevOps engineer and technical writer with a strong focus on cloud-native technologies and open-source tools. His work centers on creating practical tutorials that help developers better understand platforms such as Kubernetes, NGINX, Rust, and Go.
As a contract technical writer, Jubril authored an extensive library of technical guides covering cloud-native infrastructure and modern development workflows. Many of his tutorials achieved strong search rankings, helping developers around the world learn and adopt emerging technologies.
Share this article
Further Reading
22 August 2026
Rate-limiting applications with Traefik on Civo
19 October 2026