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.
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.
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.
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.
Related
- Contracts and Schema Variants for API Consumers — parent guide
- Using @tag and @inaccessible to Build Contract Variants — the directive mechanics
- Hiding Internal Fields from External Consumers — choosing the mechanism per field
- Publishing Subgraph Schemas with Rover CLI — the publish that triggers all of this
- Apollo Router Configuration and Deployment — running the second router