Skip to main content

Federated GraphQL Schema Testing in CI/CD with WunderGraph and Sandboxes

Testing a change to one subgraph of a federated GraphQL API is hard when the router is shared: publish the new schema and every client sees it. With WunderGraph Cosmo, you can register a pull request's subgraph schema as a feature flag named after a Signadot sandbox's routing key, and run the changed subgraph in that sandbox. A small Envoy proxy in the router pod copies the routing key into Cosmo's X-Feature-Flag header, so one shared router serves the new schema only to requests that carry the key, and those requests reach the sandboxed subgraph.

If you use Apollo GraphOS instead, the Apollo GraphQL tutorial does the same with a graph variant and a forked Apollo Router for each pull request.

How it works​

Three pieces line up behind one routing key: a Cosmo feature flag that changes the schema, a sandbox that runs the new code, and a proxy that tells the router which flag a request wants.

Cosmo feature flags. In WunderGraph Cosmo, a feature flag is a named, toggleable set of feature subgraphs. Each feature subgraph replaces one base subgraph and has its own schema and routing URL. Cosmo composes a graph with the replacements swapped in, and the router serves that graph to any request that names the flag in the X-Feature-Flag header (or a feature_flag cookie). An unknown flag name falls back to the default graph. Because the router chooses per request, one router serves the baseline and every pull request.

A sandbox for the changed subgraph. The sandbox forks the products subgraph with the image built from the pull request. The feature subgraph's routing URL is the baseline products Service, not a sandbox address: the DevMesh sidecar in the products pod sees the routing key on the router's request and hands it to the fork.

A proxy that turns the routing key into a flag. The Signadot browser extension and OpenTelemetry carry the routing key in the W3C baggage header as sd-routing-key, not in X-Feature-Flag. The feature flag is named after the routing key, so all the proxy has to do is copy the value from one header to the other.

This diagram shows the path of a request that carries the routing key. Envoy, on port 3002, adds X-Feature-Flag and forwards to the router on port 13002. The router plans the query against the flag's schema and calls products at its usual Service, forwarding baggage, and the DevMesh sidecar there hands the call to the fork (the dashed box).

A request without a routing key gets no X-Feature-Flag header, so it sees the baseline schema and the baseline products.

The Envoy proxy​

The proxy runs as a second container in the router Deployment. Envoy listens on port 3002, the port the router Service exposes, and the Cosmo router listens only on 127.0.0.1:13002 inside the pod, so every request passes through Envoy first:

k8s/router.yaml
containers:
- name: router
image: ghcr.io/wundergraph/cosmo/router:latest
env:
- name: LISTEN_ADDR
value: 127.0.0.1:13002
# ...
- name: envoy
image: envoyproxy/envoy:v1.31.0
args: ["-c", "/etc/envoy/envoy.yaml", "--disable-hot-restart"]
ports:
- containerPort: 3002
name: graphql

A Lua filter in k8s/envoy-config.yaml does the translation. Its extract_rk helper splits the baggage header on commas, finds the sd-routing-key member, strips optional quotes and URL-decodes the value. The request hook then sets the header:

k8s/envoy-config.yaml
function envoy_on_request(handle)
local baggage = handle:headers():get("baggage")
if baggage then
local rk = extract_rk(baggage)
if rk and rk ~= "" then
-- replace to avoid duplicates
handle:headers():replace("X-Feature-Flag", rk)
end
end
end

So a request that arrives with this header:

Baggage: aaa=bbb, sd-routing-key=<routing-key>, xxx=yyy

reaches the router with both:

Baggage: aaa=bbb, sd-routing-key=<routing-key>, xxx=yyy
X-Feature-Flag: <routing-key>

The original baggage header stays intact because the router must pass it on to subgraphs. The router config, k8s/router-config.yaml, propagates it on every subgraph request:

k8s/router-config.yaml
headers:
all:
request:
- op: "propagate"
named: "Baggage"

The router Deployment has no Signadot sidecar annotation because it is never forked. Every subgraph Deployment has sidecar.signadot.com/inject: "true", which adds the DevMesh sidecar that hands matching requests to forks.

Before you begin​

The example is in wundergraph-tutorial in the Signadot examples repo. It adapts the Cosmo Cloud onboarding demo to Kubernetes. You need:

  • A Kubernetes cluster with the Signadot Operator installed. The manifests use imagePullPolicy: Never with images you build locally, so the steps below use minikube.
  • The Signadot CLI, logged in.
  • kubectl, minikube, docker, git and jq.
  • A WunderGraph Cosmo account and the wgc CLI, authenticated.
  • The Signadot Chrome extension, to route browser requests to a sandbox.

Step 1: Build the images​

The subgraphs, including the changed products_fg version of products, come from the demo directory of WunderGraph's Cosmo repo. The scripts expect both repos at fixed paths under ~/git:

mkdir -p ~/git/signadot ~/git/third-party/wundergraph
git clone https://github.com/signadot/examples.git ~/git/signadot/examples
git clone https://github.com/wundergraph/cosmo.git ~/git/third-party/wundergraph/cosmo

cd ~/git/signadot/examples/wundergraph-tutorial
eval $(minikube docker-env)
./build.sh

build.sh builds images for the products, employees, mood and availability subgraphs, plus signadot/wundergraph-demo-products_fg:latest, a version of products that adds a productCount: Int! field to the Employee type. That field is the schema change you will test.

Step 2: Create the federated graph in Cosmo​

The router pulls its configuration from Cosmo, so the graph and its subgraphs must exist there first. setup-wg.sh creates a federated graph named demo in the development namespace, then creates and publishes the four subgraphs with routing URLs that point at their in-cluster Services, such as http://products.wundergraph-demo.svc:4004/graphql:

./setup-wg.sh

Create a token the router uses to fetch the graph's configuration:

wgc router token create myName \
--graph-name demo \
--namespace development

Step 3: Deploy the baseline​

Store the router token in a Secret and deploy the subgraphs and the router. Replace <router-token> with the token from the previous step:

kubectl create ns wundergraph-demo
kubectl -n wundergraph-demo create secret generic router-token \
--from-literal=token=<router-token>

kubectl apply -n wundergraph-demo -f ./k8s

Check the baseline. The Cosmo console should show the demo graph with its four subgraphs and a healthy router. Then connect your machine to the cluster so in-cluster DNS names resolve:

signadot local connect

Open the router's GraphQL playground at http://router.wundergraph-demo.svc:3002/ and run:

query myEmployees {
employees {
id
products
}
}

It returns each employee's ID and the products they work on.

Step 4: Fork the products subgraph in a sandbox​

The sandbox runs the pull request's version of products. Set CLUSTER to your cluster's name in Signadot, then apply the spec:

export CLUSTER=<cluster-name>

signadot sandbox apply -f - <<EOF
name: wundergraph-products
spec:
cluster: ${CLUSTER}
forks:
- forkOf:
kind: Deployment
namespace: wundergraph-demo
name: products
customizations:
images:
- image: signadot/wundergraph-demo-products_fg:latest
env:
- name: PORT
value: "4004"
EOF

The PORT variable makes the fork listen on 4004, the same port as the baseline products Service, so requests handed over from that Service land on it. Save the sandbox's routing key for the next step:

export ROUTING_KEY=$(signadot sandbox get wundergraph-products -o json | jq -r .routingKey)

Step 5: Register the feature flag​

Now tell Cosmo about the new schema, using the routing key as the flag name so the Envoy proxy's header selects it. Run these from the Cosmo demo directory, where the products_fg schema lives:

cd ~/git/third-party/wundergraph/cosmo/demo

wgc feature-subgraph create products-${ROUTING_KEY} \
--namespace development \
--routing-url http://products.wundergraph-demo.svc:4004/graphql \
--subgraph products

wgc subgraph publish products-${ROUTING_KEY} \
--namespace development \
--schema ./pkg/subgraphs/products_fg/subgraph/schema.graphqls

wgc feature-flag create ${ROUTING_KEY} \
--namespace development \
--feature-subgraphs products-${ROUTING_KEY} \
--enabled

The three commands:

  • feature-subgraph create registers products-<routing-key> as a replacement for products, with the baseline products routing URL.
  • subgraph publish uploads the products_fg schema for that feature subgraph.
  • feature-flag create creates an enabled flag, named after the routing key, that swaps in the feature subgraph.

Cosmo composes the flag's graph and the running router picks up the new configuration; no restart or router fork is needed.

Step 6: Query the change through the sandbox​

In the Signadot Chrome extension, select the wundergraph-products sandbox. The extension adds the routing key to every browser request in a baggage header (see developer access for the headers and other ways to set them). Add productCount to the query and run it:

query myEmployees {
employees {
id
products
productCount
}
}

The router serves the feature flag's schema and resolves productCount from the forked products:

WunderGraph router playground response with the Signadot extension routing to the wundergraph-products sandbox, showing a productCount value for each employee

Turn the extension off and run the same query against the baseline. The field does not exist there, so the router returns an error, with HTTP status 200:

{
"errors": [
{
"message": "Cannot query field \"productCount\" on type \"Employee\".",
"path": ["query", "employees"]
}
]
}

Because the status is 200, an automated test should assert on the errors array, not the status code. From a terminal or a test, with signadot local connect running, send the routing key in the baggage header and the router's /graphql endpoint does the rest:

curl -s http://router.wundergraph-demo.svc:3002/graphql \
-H "content-type: application/json" \
-H "baggage: sd-routing-key=${ROUTING_KEY}" \
-d '{"query":"{ employees { id productCount } }"}'

Clean up​

Deleting a feature flag does not delete its feature subgraphs, so remove both, then the sandbox and the demo namespace:

wgc feature-flag delete ${ROUTING_KEY} --namespace development
wgc subgraph delete products-${ROUTING_KEY} --namespace development
signadot sandbox delete wundergraph-products
kubectl delete ns wundergraph-demo

Run it in CI​

In a pipeline, the sandbox and the feature flag are created together for each pull request: detect the subgraphs the pull request changes, create a sandbox that forks them, wait for it to be ready, then register one feature subgraph per changed service and a single feature flag named after the sandbox's routing key:

for service in $CHANGED_SERVICES; do
# Create a feature subgraph for each updated service
wgc feature-subgraph create ${service}-${ROUTING_KEY} \
--namespace development \
--routing-url <original-service-url> \
--subgraph ${service}

# Publish the updated schema for the service
wgc subgraph publish ${service}-${ROUTING_KEY} \
--namespace development \
--schema <updated-service-schema>
done

# Finally, create a single feature flag referencing all feature subgraphs
wgc feature-flag create ${ROUTING_KEY} \
--namespace development \
--feature-subgraphs <service1>-${ROUTING_KEY} ... <serviceN>-${ROUTING_KEY} \
--enabled

Then run your tests with the routing key in the baggage header, as the Playwright tutorial does for end-to-end tests, and delete the flag, the feature subgraphs and the sandbox when the pull request closes. The pull request sandboxes guide covers the sandbox side of the pipeline.

Alternatives to the proxy​

The Envoy proxy exists only because the routing key and the feature flag travel in different headers. Two changes would remove it:

  • Route on X-Feature-Flag directly. Signadot can route on custom headers, set with the Operator's routing.customHeaders Helm value. If your services propagated X-Feature-Flag, you could make it a routing header, and one header would drive both the router and the sandbox routing.
  • Extend the router. The Cosmo router is open source, so you can extend or fork it to read the feature flag from the baggage header and skip the translation step.

Frequently asked questions​

How do feature flags work in GraphQL federation?​

In WunderGraph Cosmo, a feature flag is a named, toggleable set of feature subgraphs, each of which replaces one base subgraph. Cosmo composes a graph with those replacements swapped in, and the router serves it to any request that names the flag in the X-Feature-Flag header or the feature_flag cookie. Requests without a valid flag name get the default federated graph.

How do you test federated GraphQL schema changes in CI/CD?​

For each pull request, deploy the changed subgraphs in a Signadot sandbox, then register their new schemas in Cosmo as feature subgraphs under a feature flag named after the sandbox's routing key. Tests that send the routing key in the baggage header get the new schema from the shared router and reach the sandboxed subgraphs, while every other request keeps the baseline graph. When the pull request closes, delete the sandbox, the feature flag and the feature subgraphs.

Why does the Cosmo router need an Envoy proxy in this setup?​

Signadot carries the routing key in the W3C baggage header as sd-routing-key, while the Cosmo router picks a feature flag from the X-Feature-Flag header. An Envoy proxy in the router pod copies the routing key into X-Feature-Flag and leaves baggage in place, so one key selects both the schema in the router and the sandboxed subgraph behind it. Propagating X-Feature-Flag through your services as a custom routing header, or extending the open-source router, would remove the proxy.

Do I need a separate WunderGraph router for each pull request?​

No. One Cosmo router serves the baseline graph and every feature flag graph, choosing per request from the X-Feature-Flag header, so the sandbox forks only the changed subgraph. This is the main difference from Apollo GraphOS, where each Apollo Router instance loads a single graph variant and a pull request's schema needs its own router fork.

What happens to a request whose routing key has no feature flag?​

Cosmo falls back to the default federated graph when the X-Feature-Flag header names a flag that does not exist. A sandbox for a service with no schema change, or one whose flag is not registered yet, therefore still gets a working router with the baseline schema, and its forked services still receive its requests as usual.

Does this approach work with WunderGraph alternatives such as Apollo GraphOS?​

Yes. The pattern is the same for any federated GraphQL router: run the changed subgraph in a sandbox, make the router serve the pull request's schema, and let one routing key select both. With Apollo GraphOS you publish the schema to a graph variant per pull request and fork the Apollo Router to point at it, as the Signadot Apollo GraphQL tutorial shows.

See also​