Deploying Apollo Router on Kubernetes with Helm
The router is an unusually easy service to deploy and an unusually easy one to deploy badly, because three of its requirements — a startup dependency on the uplink, long-lived connections, and a config file that must roll with the pods — do not match the defaults of a typical web service. This page covers the values that matter, why each one is not a default, and how to verify the deployment survives a rollout. It sits under Apollo Router Configuration and Deployment.
When to use this pattern
- Use it for any router deployment on Kubernetes, which is most of them, and especially where several router pools serve different variants.
- Use it when moving from a single container to a scaled deployment, because that is when probes, drain behaviour and config rollout stop being cosmetic.
- Skip the Helm chart only if you already template your manifests, in which case the same values still apply — the chart is a packaging choice, not a requirement.
Prerequisites
Three requirements the defaults do not meet
The router needs a schema before it can serve. On startup it fetches the supergraph from the uplink; until that succeeds it has no schema and cannot answer anything. A readiness probe that reports healthy too early sends traffic to a pod with nothing to route, and the symptom is errors during every deploy.
Connections outlive requests. Subscriptions hold a connection for a session and ordinary keep-alives persist between requests. A default thirty-second grace period cuts them mid-flight, so every rollout produces a burst of client errors that looks like an application fault.
Configuration is not a deploy artifact by default. A router reads router.yaml at startup. Mounting it from a ConfigMap without a checksum annotation means editing the ConfigMap changes nothing until something else happens to restart the pods — and then the change appears at a moment nobody connects to the edit.
Implementation walkthrough
The values file below is complete for a production deployment. Every non-default is annotated with the failure it prevents.
# values.yaml
router:
configuration:
supergraph:
listen: 0.0.0.0:4000
health_check:
listen: 0.0.0.0:8088
enabled: true
path: /health
traffic_shaping:
all:
timeout: 5s
telemetry:
exporters:
metrics:
prometheus: { enabled: true, listen: 0.0.0.0:9090, path: /metrics }
# The uplink key comes from a secret, never from values — values end up in git.
managedFederation:
existingSecret: apollo-uplink
graphRef: my-graph@current
replicaCount: 3
# The router only answers /health once it has loaded a supergraph, which is
# exactly the semantics a readiness probe wants. A generous failureThreshold
# covers a slow uplink fetch on a cold start without failing the rollout.
probes:
readiness:
httpGet: { path: /health, port: 8088 }
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
liveness:
httpGet: { path: /health, port: 8088 }
initialDelaySeconds: 30
periodSeconds: 15
# Long-lived connections need time to drain. The preStop sleep lets the
# endpoint be removed from the Service before the process starts shutting down.
terminationGracePeriodSeconds: 120
lifecycle:
preStop:
exec: { command: ["/bin/sh", "-c", "sleep 15"] }
resources:
requests: { cpu: "1", memory: "512Mi" }
limits: { memory: "1Gi" } # CPU limit omitted deliberately: throttling
# a latency-sensitive proxy is worse than
# letting it burst.
podDisruptionBudget:
minAvailable: 2 # a node drain must not take the pool below this
# Force a rollout when the configuration changes, rather than whenever a pod
# happens to restart next.
podAnnotations:
checksum/config: "{{ include (print $.Template.BasePath \"/configmap.yaml\") . | sha256sum }}"
Two values deserve their comments read twice. Omitting the CPU limit is deliberate — throttling a proxy that sits in front of every request converts a brief load spike into elevated latency for everything, and a request-only reservation gives you scheduling guarantees without the cliff. And the checksum annotation is what makes configuration a deploy artifact rather than a surprise waiting for the next restart.
Verification steps
Verify the three behaviours the defaults get wrong, by causing them.
# 1. A pod must not report ready before it has a schema.
kubectl run probe --rm -it --image=curlimages/curl -- \
curl -s -o /dev/null -w '%{http_code}\n' http://router:8088/health
# During a cold start this must be non-200 until the supergraph is loaded.
# 2. A rollout must not drop in-flight work.
kubectl rollout restart deploy/router
# Run a steady load throughout and assert zero client-visible errors.
# 3. A config change must roll the pods.
kubectl edit configmap router-config # change a timeout
kubectl rollout status deploy/router # expect a new rollout, not "unchanged"
The second test is the one worth automating, because it is the failure users notice and the only one that appears exclusively under load. A rollout with a steady request stream and no errors is a strong signal that probes and drain timing are right; anything else means one of them is wrong and the deploy will keep producing a small error spike nobody quite attributes.
Finally, confirm the pod disruption budget actually holds by draining a node during that same load test. A budget set to a value the replica count cannot satisfy blocks the drain instead of protecting the service, which is a different and equally annoying failure.
A short pre-flight list catches the differences between a router deployment that survives rollouts and one that produces a small error spike every time. All four are single values.
Common mistakes & gotchas
Putting the uplink key in values.yaml. Values files end up in version control and in CI logs. Reference an existing secret instead.
Setting a CPU limit on the router. Throttling a component on the path of every request turns a spike into graph-wide latency. Request CPU, do not limit it.
Running several variants from one deployment. A router process loads one supergraph. A second variant is a second release of the same chart with a different graph ref, which also gives it its own scaling and limits.
Frequently Asked Questions
Should the supergraph come from the uplink or from a file?
The uplink for almost everything, because it decouples schema delivery from deployment entirely — a schema change reaches routers in seconds without a rollout. A file baked into the image is worth considering only where the cold-start dependency on an external service is unacceptable, and even then the usual arrangement is a file as a fallback with the uplink as the normal path.
How many replicas?
At least three, so a disruption budget of two can hold during a node drain, and beyond that sized from peak concurrent requests plus headroom. The router is CPU-bound on planning and largely memory-flat, so scaling is usually about absorbing bursts rather than about capacity in the steady state.
Does the router need a sidecar or service mesh?
Not for federation. A mesh is useful if you already run one — mTLS to subgraphs, uniform retries — and it adds a hop and a set of timeouts that must agree with the router’s own. If you do run one, make sure its idle timeouts exceed anything the router expects to hold open, or subscriptions will die for reasons that appear nowhere in the router’s logs.
How should several environments be handled?
One release per environment with its own graph ref, and ideally its own namespace. The chart values differ in exactly two places — the graph ref and the secret — which makes environment drift easy to spot in a diff and hard to introduce by accident.
How should the router’s own configuration be reviewed?
As code, in the same repository as the values file, with a diff on every release. Router configuration carries security decisions — limits, header propagation, error redaction — that drift upward under delivery pressure and are invisible in any dashboard. A diff makes “someone raised the depth limit to unblock a launch” a line in a pull request rather than an archaeology exercise six months later.
Does a router pool need its own namespace?
Not strictly, and it usually helps. A namespace per variant gives you independent network policy, independent resource quotas, and a clean blast radius for a bad rollout — all of which matter more for a component that sits in front of every request than for an ordinary service. It also makes the “which router is this?” question answerable from a pod name alone, which is worth something at three in the morning.
Related
- Apollo Router Configuration and Deployment — parent guide
- Configuring Query Plan Caching in Apollo Router — the shared tier a scaled deployment wants
- Scaling Federated Subscriptions Across Router Instances — what changes with more replicas
- Securing the Federated Gateway — the configuration this deployment carries
- Schema Registry and Managed Federation — where the uplink schema comes from