How trivago tests hundreds of PRs at once on Kubernetes, and why each preview has two URLs

trivago has more than 200 engineers working on 100+ microservices on Google Kubernetes Engine, with over 100 open pull requests on its website repository…

Loading
trivago··PT1M46.733S

video loads only when you press play

trivago has more than 200 engineers working on 100+ microservices on Google Kubernetes Engine, with over 100 open pull requests on its website repository…

trivago has more than 200 engineers working on 100+ microservices on Google Kubernetes Engine, with over 100 open pull requests on its website repository alone. A shared staging environment can't take that: changes queue up, and a failing test could be anyone's change. So every PR gets a preview environment, a short-lived deployment of only the service it changed, with every dependency pointing at staging.

  • Every pull request at trivago gets a preview environment that deploys only the service it changed, with every dependency pointing at staging, so 600+ previews stay small and a failure belongs to the change inside.
  • Always on, those previews would be about 1,200 pods and, by trivago's estimate, cost more than production. They scale to zero instead: the KEDA HTTP add-on's interceptor holds the first request while a pod starts.
  • A rolling update kept an old pod serving while the new commit crashed on boot, so tests passed and the crash shipped. Each preview now has a PR URL for people and a commit URL that only reaches the latest commit's pods, where tests run.

What a preview environment is, and the scale trivago runs them at

A preview environment is a short-lived deployment of the code in one pull request, reachable at its own URL. It appears when the PR opens and is deleted when the PR merges or closes. The point is that the author, a reviewer, a QA engineer or a product manager can try the change before it merges, without waiting for a shared environment.

Preview environments are not new, and trivago does not claim they are. What is worth studying is how they hold up at trivago's size. trivago says more than 200 engineers work on over 100 microservices running on Google Kubernetes Engine (GKE) in three regions. At any moment its website repository alone has more than 100 open pull requests, with a similar number across the backend services. Today it runs more than 600 preview environments on Kubernetes, and at most times most of them cost nothing because they are scaled to zero.

Why one shared staging environment was not enough

Before previews reached the backends, backend teams shared a staging environment, and trivago describes the usual problems. Changes queued behind each other. A failing test could come from your change or from something another team deployed an hour earlier. Finding out whose change broke staging often took longer than fixing it.

A preview per PR removes both problems, because each change gets an environment of its own. It also creates a new one: hundreds of environments that have to be created, routed, paid for and deleted without anyone doing it by hand.

Where trivago started: two platforms

The video skipped this part. trivago's frontend already had preview environments on Google Cloud Run, Google's serverless container platform. Cloud Run charges per request and scales to zero by itself, so 600+ frontend previews cost less than 100 euros a month. The backends ran on GKE with staging and production and no previews at all.

trivago chose not to move the backends to Cloud Run. Pay-per-request pricing that makes idle previews cheap gets expensive for production services that are never idle, and running two platforms meant two copies of monitoring, logging and deployment tooling. The goal became one platform, Kubernetes, and the way in was to solve backend previews there first. If previews worked well on Kubernetes, the frontend could move without losing anything. Both now run there, and the Cloud Run setup is retired.

Deploy only the changed service

The decision that shapes everything else: a preview contains only the service the PR changed. A PR against the booking service gets a preview with the booking service built from that branch, and every service it calls resolves to its staging instance. With 100+ microservices, copying the whole platform per PR would multiply both the cost and the number of things that can flake. One service against stable staging dependencies keeps each environment small, and keeps any failure attributable to the change under test. trivago puts it as a lesson: cost grows with the number of open PRs either way, and one service per preview keeps that growth linear.

From pull request to running environment

The pipeline is GitOps end to end, which means the desired state of every environment lives in Git and a controller makes the cluster match it.

1. Every PR triggers a GitHub Actions workflow that builds a Docker image and pushes it to Google Artifact Registry. 2. The workflow renders the Kubernetes manifests with Helm and Skaffold, injecting the PR number and the short commit SHA so names, namespaces and URLs are unique per PR and per commit. 3. The rendered manifests are committed to a GitOps branch, one directory per PR, such as preview/1247/. 4. One Argo CD ApplicationSet per service uses the pull request generator to watch GitHub, and creates one Argo CD Application per open PR, pointed at that PR's directory and deployed into its own namespace, preview-booking-1247. 5. When the PR merges or closes, the generator removes the Application, Argo CD prunes its resources, and the workflow deletes the directory.

trivago's simplified ApplicationSet polls GitHub every 90 seconds (requeueAfterSeconds: 90) and only picks up PRs carrying a preview label that the CI workflow adds. Nobody creates or deletes environments by hand; opening and closing pull requests is the whole interface.

Why always-on previews were too expensive

The obvious version is to render manifests per PR, apply them and delete them on merge. trivago did the arithmetic: 600+ environments at about two pods each is roughly 1,200 pods, each requesting about 1 CPU and 1 GB of memory, around the clock. That is about 28,800 pod-hours a day, and by trivago's estimate it would have cost more than its production workloads, for environments that are used two to three hours per working day.

trivago is careful about this number. It never ran the always-on version, so the saving is avoided spend, not a measured before and after.

The first idea most teams have is a schedule: scale previews down at night and up in the morning. trivago dropped it. Its engineers work across time zones and do not keep office hours, and a preview that is asleep exactly when a reviewer clicks the link costs trust.

Scale to zero with the KEDA HTTP add-on

What trivago needed was the behaviour Cloud Run had: go to zero pods when idle, and wake when a request arrives without losing that request. Kubernetes' Horizontal Pod Autoscaler historically cannot go below one replica. trivago notes that native scale to zero for the HPA is arriving with Kubernetes 1.36, but that still leaves the harder half: something has to catch the request that arrives while there are no pods.

KEDA (Kubernetes Event-driven Autoscaling) can take a workload to zero and back, and trivago already ran it in production to scale Kafka consumers on consumer lag. A Kafka scaler can watch from outside, because the queue holds the messages while pods start. HTTP has no queue, so whatever holds the request has to sit in the request path. That is the KEDA HTTP add-on: an interceptor, a layer 7 proxy, in front of the workload. When a request arrives for a preview at zero, the interceptor holds it, the pending request count becomes the scaling metric, KEDA scales the deployment from zero to one, and the interceptor releases the request once the pod is ready. The person clicking sees a slow first response instead of an error. When traffic stops, a cooldown passes and KEDA scales back to zero.

Each preview declares this with an HTTPScaledObject rendered by the same Helm chart:

kind: HTTPScaledObject
spec:
  # the interceptor routes by Host
  hosts:
    - pr-1247.booking.internal.trv.cloud
  # zero pods when idle
  replicas: { min: 0, max: 2 }
  scaleTargetRef:
    kind: Deployment
    name: booking
    service: booking
    port: 80
  # seconds idle before zero
  scaledownPeriod: 300

hosts is why every preview needs a unique hostname: the interceptor maps the Host header to the right deployment. scaledownPeriod is the idle window; too short and a preview flaps while someone reads a page, too long and you pay for idle pods.

Why not Knative

Knative is the standard answer for scale-to-zero HTTP services on Kubernetes, and trivago looked at it seriously. It gives three reasons for saying no. A Knative Service is its own workload model rather than a plain Deployment, and trivago wanted previews to use the same manifests as staging and production. Knative routes traffic only to healthy pods, which is right for production and wrong for a preview, as the incident below shows. And Knative supports a single ingress, while trivago runs two Istio ingress gateways, one public and one private, with previews behind the private one.

The incident behind the two URLs

trivago's first version gave each preview one URL, pointed at the Kubernetes Service, which routes only to ready pods. Integration tests ran against it, they passed, PRs merged. Then a deployment to production started crashing on startup, and its PR's pipeline had been green.

One commit in that PR made the service exit with a non-zero code on boot. The preview received the commit and Kubernetes did a rolling update: it started a new pod, the new pod crashed, so Kubernetes kept the old pod and never moved traffic. Every request, the integration tests included, was answered by the previous version. The tests validated code that was about to be replaced, and the crashing code shipped. Kubernetes was doing its job. trivago's summary is that routing which always prefers healthy pods is safety in production and blindness in testing: the setup could catch bugs in business logic, but was structurally blind to a service that failed to start.

The fix gives every preview two URLs:

  • The PR URL, pr-1247.booking.internal.trv.cloud, goes through the KEDA interceptor to whichever pods are ready. It is the one for people, and the one that wakes the preview from zero.
  • The commit URL, pr-1247-abc12ef.booking.internal.trv.cloud, carries the short SHA of the latest commit and reaches only pods running exactly that commit. If they are crash-looping, requests fail, and that failure is the signal.

Integration tests make one request to the PR URL to wake the environment, wait for availability, then run against the commit URL. Both hosts live in one Istio VirtualService, with the commit route matched first because its host prefix is more specific. The commit route skips the interceptor and goes to a subset defined by an Istio DestinationRule, which selects pods by a version label that the Helm chart sets to the short SHA. Every push renders a new commit URL, so tests always hit the code that was just pushed and never a leftover pod.

Living with a proxy on the request path

The HTTP add-on is not like other KEDA scalers. A Kafka or Prometheus scaler observes from outside, so if it fails your application keeps running at its current scale. The interceptor proxies every request, so if it fails traffic stops. trivago treats it as critical infrastructure, with the readiness gates, timeouts, resource headroom and observability of an ingress. Cold starts are user-facing too: when a preview wakes, a person is looking at a spinner, so pod startup time becomes their experience.

trivago first tried the add-on in a proof of concept in December 2023. The upstream add-on then lacked features it needed and was not stable at its scale, so it went to production on Kedify's distribution, built by KEDA maintainers. The feature trivago values most is the failure mode: if the interceptor becomes unhealthy, traffic is rewired around it and the workload falls back to a fixed replica count, and autoscaling resumes when the interceptor recovers. The worst case becomes "no scale to zero for a while", which for previews is fine.

trivago's lessons, in their order

  • Design the commit URL in from day one. They added it after an incident that reached production.
  • Deploy only the changed service.
  • Treat the interceptor as production infrastructure.
  • Skip the cron detour: wake on request is what makes previews trustworthy.

Terms

Preview environment
A temporary deployment of one pull request's code at its own URL, created when the PR opens and deleted when it closes. Also called an ephemeral environment.
Staging
A shared, production-like environment where changes are tested before release. One staging environment serves everyone, so changes queue and failures are hard to attribute.
Pod
The smallest unit Kubernetes runs: one or more containers scheduled together. Here, a running copy of a service.
Rolling update
Kubernetes' default way of replacing a Deployment's pods. New pods start beside old ones, and traffic moves only to pods that pass their readiness check, so a broken new pod is quietly kept out of rotation.
Readiness
Whether Kubernetes considers a pod able to serve traffic. A Service sends requests only to ready pods.
Scale to zero
Running no pods for a workload while it is idle, and starting one when traffic arrives.
KEDA
Kubernetes Event-driven Autoscaling, a CNCF graduated project that scales workloads, including to and from zero, from external signals such as queue length.
KEDA HTTP add-on / interceptor
A proxy that sits in front of a workload, holds HTTP requests while it scales from zero, and releases them when a pod is ready.
HTTPScaledObject
The custom resource that tells the HTTP add-on which hostnames route to which Deployment, and the replica range and idle period to use.
GitOps
Keeping the desired state of infrastructure in Git and letting a controller make the cluster match it.
Argo CD ApplicationSet
An Argo CD resource that generates Applications from a template. Its pull request generator creates one per open PR.
Istio VirtualService / DestinationRule
Istio service mesh resources. A VirtualService routes requests by host or path; a DestinationRule defines subsets of a service's pods by label.
Knative
A Kubernetes platform for serverless workloads that includes scale to zero, using its own Service resource.

Questions

What is a preview environment in Kubernetes?

A temporary deployment created for one pull request, with its own namespace and URL, deleted when the PR closes. At trivago each one runs only the changed service and calls staging for everything else.

How does trivago run 600+ preview environments without paying for them all day?

Each preview scales to zero pods when idle. The KEDA HTTP add-on's interceptor holds the first request, KEDA starts a pod, and the request is released when the pod is ready. After an idle period the preview goes back to zero.

Why did trivago's tests pass on code that crashed on startup?

The preview had one URL pointed at a Kubernetes Service, which only routes to ready pods. During the rolling update the new pod never became ready, so the tests were answered by the old pod and passed.

Why does every trivago preview have two URLs?

The PR URL goes to whichever pod is ready and wakes the preview, which is what a person wants. The commit URL reaches only pods running the latest commit, so tests fail when that commit cannot start.

Why didn't trivago use Knative for scale to zero?

It brings its own workload model instead of plain Deployments, it routes only to healthy pods, and it supports one ingress where trivago runs two Istio gateways.

What happens if the KEDA interceptor goes down?

With the upstream add-on, traffic through it stops. trivago runs Kedify's distribution, which routes around an unhealthy interceptor and falls back to a fixed replica count until it recovers.

At trivago, hundreds of unmerged changes are being tested at the same time. That's two hundred engineers and over a hundred microservices on one Kubernetes platform, with more than a hundred pull requests open on the website alone. A shared staging environment can't take that, because changes queue up behind each other, and when a test fails, it could be your change or something someone else deployed an hour ago. So every pull request gets its own preview environment, and it only deploys the one service you changed, built from your branch, while everything that service calls is still the shared staging copy. That keeps every preview small, and when one breaks, the change that broke it is the one inside. There are over 600 of them, and keeping them all running would cost more than production does, when each one is only used for two or three hours a day, so they sleep at zero pods, the running copies of a service, and when someone opens the link, a proxy holds the request until a pod starts. The first version of this still let a crash reach production. When a new commit landed, Kubernetes did a rolling update, starting the new pod beside the old one and only moving traffic once the new one was ready. One commit made the service exit on boot, so the new pod never got there, and the integration tests ran against the old one and passed. So now every preview has two URLs. The PR URL goes through that proxy to whichever pod is ready, which is what a person wants to click. The commit URL has the commit's short hash in it and only reaches the pod running that exact commit, so when that pod can't start, the tests fail, which is exactly what they're for. Routing around a broken pod is safety in production and blindness in a preview, because a test that never reaches the broken code can never fail.

This explainer is based on Spin-Up, Test, Tear-Down: How trivago Runs 600+ Preview Environments on Kubernetes by trivago ↗. The original reporting and technical work belong to its publisher.