Publishing a Public API Variant with Apollo Contracts

Turning an internal supergraph into a public API is mostly a release-engineering problem, not a schema problem. This page walks the full sequence — creating the contract, standing up a router for it, wiring checks so the public surface cannot regress, and rolling the whole thing back when it goes wrong. It is the operational companion to Contracts and Schema Variants for API Consumers.

When to use this pattern

  • Use it when external consumers need a stable, smaller API derived from a graph that is already serving internal clients, and you do not want a second codebase to maintain.
  • Use it when the public surface must be provably smaller — a filtered variant guarantees absence in a way documentation and code review cannot.
  • Skip it while the public surface is still one or two fields. Until the API has enough shape to be worth versioning, an authorization rule on a single subgraph is less machinery for the same result.

Prerequisites

What “publishing a variant” actually creates

A contract publish creates three things at once, and knowing which is which makes every later operation obvious. It creates a variant — a named slot in the registry with its own schema history. It creates a filter configuration attached to that variant, recording the include and exclude tag lists. And it triggers a launch, which composes the source schema, applies the filter, validates the result, and writes a supergraph to that variant’s uplink.

Only the third of those runs again on every source change. The variant and its filter are durable configuration; the launch is the recurring event. That is why a change to any subgraph in the source variant automatically produces a new public supergraph without anyone touching the contract, and equally why a broken tag in an unrelated subgraph can change your public API without a single line of the public API’s own configuration changing.

One publish, two launches A subgraph publish triggers composition of the source variant. On success the registry applies the contract filter and triggers a second launch that publishes a filtered supergraph to the public variant's uplink. A change you did not make can move your public API subgraph publish any team, any repo source launch compose + check contract launch filter + compose again uplink public durable configuration lives here — it does not re-run this is what runs on every source change This is why a contract check belongs in every subgraph's pipeline, not only in the API team's.

Implementation walkthrough

The whole setup is four commands and one deployment. Start by confirming the source variant is healthy, because a contract can only ever be as valid as what it filters:

# 1. Source must compose before anything downstream can.
rover supergraph fetch my-graph@current > /dev/null && echo "source ok"

# 2. Create the public contract. Include only what the public audience needs;
#    exclude experimental work so an include-list mistake cannot leak it.
rover contract publish my-graph@public \
  --source-variant my-graph@current \
  --include-tag public \
  --exclude-tag experimental \
  --exclude-tag internal

# 3. Read back exactly what the public audience will see. This file, not the
#    tag lists, is the artifact worth reviewing.
rover graph fetch my-graph@public > public-api.graphql

# 4. Commit it. A diff of this file in CI is your public-API changelog.
git add public-api.graphql && git commit -m "public API surface"

Then run a second router against the new variant. The only difference from the internal deployment is the graph ref and the key — the image, the config file, and everything else stay identical:

# public-router.yaml — the router config itself is unchanged except for limits
supergraph:
  listen: 0.0.0.0:4000
limits:
  max_depth: 12
  max_aliases: 30
  parser_max_tokens: 15000
traffic_shaping:
  router:
    global_rate_limit:
      capacity: 200
      interval: 1s
headers:
  all:
    request:
      - propagate: { named: "authorization" }
APOLLO_GRAPH_REF=my-graph@public \
APOLLO_KEY="$PUBLIC_UPLINK_KEY" \
  ./router --config public-router.yaml

The limits block deserves attention because it is the one place the public deployment genuinely should differ. An internal router serves clients you can call when they write a pathological query; a public one does not. Depth and alias caps, a global rate limit, and a token cap on the parser are cheap, and they are the difference between a public endpoint and an open one. Complexity control is covered in more depth under gateway routing strategies for federated APIs.

Wiring the check so the surface cannot regress

The failure mode that matters here is silent, and it does not originate in the API team’s repository. Someone in an unrelated subgraph removes a tag, contract composition still succeeds, and the public API quietly loses a field. Nothing is red anywhere.

Two checks prevent it. rover contract check verifies the contract still composes and reports client-visible breaking changes for the public variant’s traffic, which is a different traffic set from the source variant’s. And a committed copy of the public API schema, diffed in CI, turns any surface change into a reviewable line in a pull request.

# .github/workflows/schema.yml — runs in every subgraph repository
- name: Check the subgraph against the source variant
  run: rover subgraph check my-graph@current --name "$SUBGRAPH" --schema ./schema.graphql

- name: Check the public contract still composes and does not break clients
  run: rover contract check my-graph@public

- name: Fail if the public surface changed without review
  run: |
    rover graph fetch my-graph@public > /tmp/public-now.graphql
    diff -u public-api.graphql /tmp/public-now.graphql

The third step is the one that makes the other two trustworthy. Composition checks tell you the contract is valid; the diff tells you it is what you agreed. Those are different questions, and only the second one catches a field silently disappearing.

The regression that no composition check reports A tag removed in the shipping subgraph leaves both the source and contract compositions valid, so every check is green, while the public API silently loses a field. Only a diff of the fetched public schema catches it. Green pipeline, smaller public API signal what it reports catches a removed tag? rover subgraph check source composes, clients ok no — the tag is metadata rover contract check filtered graph composes only if a client used the field diff of the fetched API schema every surface change, always yes — unconditionally A field nobody has queried yet is invisible to usage-based checks and perfectly visible in a diff. For a brand-new public API that is most of the surface, which is when the diff matters most.

Verification steps

Confirm the contract variant serves what you expect, and — more importantly — refuses what it should:

# The public router should not know internal fields exist at all.
curl -s https://api.example.com/graphql \
  -H 'content-type: application/json' \
  -d '{"query":"{ product(id:\"P-1\"){ wholesaleCost { amount } } }"}'
# expected: "Cannot query field \"wholesaleCost\" on type \"Product\""

A validation error rather than a permission error is the proof that filtering, not guarding, is doing the work. Then check the two routers disagree in the right direction:

rover graph introspect https://internal-router.example.com | wc -l   # larger
rover graph introspect https://api.example.com          | wc -l      # smaller

Finally, confirm the launch history for the public variant records the launch you expect. A contract that has not launched since your filter change is still serving the previous surface, and the symptom — an unchanged API after a config change — is easy to misread as a caching problem.

Rolling back

Rollback for a contract has two flavours and they are not interchangeable. If the filter is wrong, republish the contract with the previous tag lists; the next launch produces the previous surface. If the source schema is wrong, roll back the offending subgraph publish, which fixes both variants at once. Attempting the first when the problem is the second produces a public API that disagrees with the internal one in ways nobody intended.

Deleting the contract variant is never the rollback. It removes the uplink your public routers are polling, which turns a wrong schema into no schema — a much larger incident than the one you started with.

Because two independent things can be wrong — the filter, or the schema it filters — the first minute of an incident is spent deciding which. The table below is worth keeping somewhere visible, since the wrong first move in each row makes the situation harder to reason about rather than merely failing to help.

Deciding what to fix first A wrong filter, a wrong source schema, a failed contract launch, and routers serving an older schema each have a correct first move and a tempting wrong one. What to do when the public API breaks situation correct first move the tempting wrong move the filter was wrong republish the contract delete the variant the source schema was wrong roll back the subgraph edit the filter contract launch failed read the launch error restart the routers routers serve an old schema check launch history clear a cache Deleting a contract variant removes the uplink its routers poll, turning a wrong schema into no schema. Every correct move here is reversible; several of the wrong ones are not.

Common mistakes & gotchas

Publishing the contract before the tags exist. The launch succeeds and produces an almost-empty schema, because an include-list filter keeps only what is tagged. Tag first, verify the fetched schema, then point routers at it.

Sharing an API key between variants. A key scoped to the source variant can publish to it, which means a compromised public-router key becomes an internal-graph write. Use a read-only uplink key per variant.

Treating the public router as a copy of the internal one. It needs stricter limits, its own rate limiting, and its own alerting. Reusing the internal configuration wholesale is how a public endpoint inherits an internal trust model.

Frequently Asked Questions

How long does a contract launch take to reach routers?

Composition and filtering typically complete in seconds, and routers poll the uplink continuously, so the practical answer is under a minute for the whole path. If a change has not landed after several minutes, look at the launch history first — a failed contract composition leaves routers correctly serving the previous supergraph, and that is the expected behaviour rather than a delivery problem.

Can the public variant run a different router version?

Yes, and it often should lag the internal one deliberately. The supergraph format is stable across router versions, so pinning the public deployment a release behind gives you a soak period on internal traffic before external consumers meet a new runtime.

Should the public API have its own deprecation policy?

It needs a stricter one. Internally you can deprecate and remove within a sprint because you can see and redeploy every client. Externally you cannot, so the policy has to be time-based — a published minimum window between deprecation and removal — and enforced by watching usage on the public variant specifically, since usage on the source variant tells you nothing about external consumers.

What happens if the source variant fails to compose?

Nothing downstream moves. The contract launch never runs, the public uplink keeps its last good supergraph, and both routers keep serving. A broken source composition is an inability to ship, not an outage, and that property is worth preserving by never bypassing composition to publish a supergraph by hand.