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.

Three defaults worth changing The readiness probe must wait for the supergraph, the termination grace period must exceed connection lifetime, and a config checksum annotation must force a rollout when the ConfigMap changes. Each default produces the same symptom: errors during a deploy readiness reports healthy before the supergraph arrives Traffic reaches a pod with no schema. Fix: probe the health endpoint the router only answers once it has loaded a supergraph, and allow a generous failureThreshold. grace period shorter than a connection's life In-flight operations and subscriptions are cut. Fix: raise terminationGracePeriodSeconds above your longest expected connection, and add a preStop sleep to drain. config changes do not restart pods Fix: annotate the pod template with a checksum of the ConfigMap contents.

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.

The overlap that makes a rollout invisible The new pod starts, fetches the supergraph, and only then reports ready. The old pod is removed from the Service, sleeps through the preStop delay, and drains its connections before exiting. A rollout with no client-visible errors new pod start fetch supergraph ready — receiving traffic old pod serving preStop sleep draining, then exit the overlap: new pod ready before old one stops Remove either the readiness gate or the preStop sleep and the overlap disappears, producing a small error spike on every deploy that is easy to blame on something else. Test it under load, because at zero traffic every configuration looks correct.

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.

Four values, four intermittent bugs Readiness, grace period, CPU limit and config checksum each produce a deploy-time symptom when left at the default. Pre-flight for a router deployment value set to symptom if wrong readiness probe the health endpoint traffic before a schema exists terminationGracePeriodSeconds above connection lifetime cut connections on rollout CPU limit unset throttling on every spike config checksum annotation present config changes appear at random Each symptom is intermittent and load-dependent, which is why they survive staging and appear in production. Test all four with a rolling restart under steady load — at zero traffic every setting looks correct.

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.