This guide explains how to create a Zuplo policy that routes employee requests
to canary or staging backends for testing and dogfooding purposes.
Overview
When releasing new API versions, it's common to route internal employees or beta
testers to a canary or staging environment before rolling out to all users. This
allows teams to test new features and catch issues early without affecting
production traffic.
How It Works
The policy checks for staging environment indicators in this order:
Query parameter: ?stage=canary (defaults to release)
Request header: x-stage
User identity: Employee email/ID in allow list
If any condition is met, the request routes to canary backends.
Creating a Canary Routing Policy
The policy doesn't set the backend URL directly. Instead, it stores the chosen
backend on context.custom, and the route's handler reads that value to forward
the request (see "Adding the Policy to Routes" below).
Create a new policy file in your project:
Code
// policies/canary-routing.tsimport { InboundPolicyHandler, ZuploRequest, environment,} from "@zuplo/runtime";export const canaryRoutingPolicy: InboundPolicyHandler = async ( request, context,) => { // Get canary users from environment variable const CANARY_USERS = environment.CANARY_USERS ? environment.CANARY_USERS.split(",").map((user) => user.trim()) : []; // Check for staging indicators const url = new URL(request.url); const stageParam = url.searchParams.get("stage"); const stageHeader = request.headers.get("x-stage"); const canaryUser = request.user?.sub && CANARY_USERS.includes(request.user.sub); // Determine if we should route to canary const isCanary = stageParam === "canary" || stageHeader === "canary" || canaryUser; // Store the backend URL for the route's handler to use if (isCanary) { context.custom.backendUrl = environment.API_URL_CANARY; // Log canary routing for debugging context.log.info("Routing to canary backend", { reason: stageHeader ? "header" : canaryUser ? "user" : "query", user: request.user?.sub, stage: stageParam || stageHeader || "canary", }); } else { context.custom.backendUrl = environment.API_URL_PRODUCTION; } // Remove stage query parameter to avoid passing it to backend if (stageParam) { url.searchParams.delete("stage"); return new ZuploRequest(url.toString(), { method: request.method, headers: request.headers, body: request.body, user: request.user, params: request.params, }); } return request;};
Advanced: Multiple Backend Support
For applications with multiple backend services, extend the policy to route each
service appropriately:
Code
// policies/multi-service-canary-routing.tsimport { InboundPolicyHandler, ZuploRequest, environment,} from "@zuplo/runtime";export const multiServiceCanaryRoutingPolicy: InboundPolicyHandler = async ( request, context,) => { const CANARY_USERS = environment.CANARY_USERS ? environment.CANARY_USERS.split(",").map((user) => user.trim()) : []; // Check staging conditions const url = new URL(request.url); const stageParam = url.searchParams.get("stage"); const stageHeader = request.headers.get("x-stage"); const canaryUser = request.user?.sub && CANARY_USERS.includes(request.user.sub); // Determine if we should route to canary const isCanary = stageParam === "canary" || stageHeader === "canary" || canaryUser; // Route multiple services based on stage if (isCanary) { // Store multiple canary URLs in context for different services context.custom.userApiUrl = environment.USER_API_CANARY; context.custom.orderApiUrl = environment.ORDER_API_CANARY; context.custom.inventoryApiUrl = environment.INVENTORY_API_CANARY; // Add stage indicator header for downstream services request.headers.set("X-Stage", "canary"); } else { // Production URLs (release stage) context.custom.userApiUrl = environment.USER_API_PRODUCTION; context.custom.orderApiUrl = environment.ORDER_API_PRODUCTION; context.custom.inventoryApiUrl = environment.INVENTORY_API_PRODUCTION; request.headers.set("X-Stage", "release"); } // Clean up query parameter if (stageParam) { url.searchParams.delete("stage"); return new ZuploRequest(url.toString(), { method: request.method, headers: request.headers, body: request.body, user: request.user, params: request.params, }); } return request;};
Each service's route then reads the matching value in its handler options. For
example, the user API route would use a URL Forward handler with
"baseUrl": "${context.custom.userApiUrl}".
Percentage-Based Canary Routing
Roll out canary deployments gradually with percentage-based routing:
Code
// policies/percentage-canary-routing.tsimport { InboundPolicyHandler, environment } from "@zuplo/runtime";export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async ( request, context,) => { // Always route configured users to canary const CANARY_USERS = environment.CANARY_USERS ? environment.CANARY_USERS.split(",").map((user) => user.trim()) : []; if (request.user?.sub && CANARY_USERS.includes(request.user.sub)) { context.custom.backendUrl = environment.API_URL_CANARY; return request; } // Route percentage of anonymous traffic to canary const CANARY_PERCENTAGE = parseInt(environment.CANARY_PERCENTAGE || "0", 10); if (CANARY_PERCENTAGE > 0) { // Use a consistent hash for sticky sessions. The client IP is // available on the true-client-ip header. const sessionId = request.headers.get("x-session-id") ?? request.headers.get("true-client-ip") ?? "unknown"; const hash = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(sessionId), ); const hashArray = Array.from(new Uint8Array(hash)); const hashValue = hashArray[0] / 255; // Value between 0 and 1 if (hashValue * 100 < CANARY_PERCENTAGE) { context.custom.backendUrl = environment.API_URL_CANARY; request.headers.set("X-Canary-Route", "percentage"); } else { context.custom.backendUrl = environment.API_URL_PRODUCTION; } } else { context.custom.backendUrl = environment.API_URL_PRODUCTION; } return request;};
Configuration
Environment Variables
Configure environment variables under the
Environments tab
of your project in the Zuplo Portal:
Code
# List of employee emails or user IDsCANARY_USERS=alice@company.com,bob@company.com,user-123# Backend URLsAPI_URL_PRODUCTION=https://api.company.comAPI_URL_CANARY=https://api-canary.company.com# For percentage-based routingCANARY_PERCENTAGE=10 # Route 10% of traffic to canary
Adding the Policy to Routes
Add the policy to your route configuration. Use the built-in
URL Forward handler and reference the value the
policy stored on context.custom in the baseUrl option:
The authentication policy runs first so that request.user is populated when
the canary routing policy checks the allow list.
Testing Your Policy
1. Using Query Parameters
Test canary routing with a query parameter:
Code
# Route to canary backendcurl https://your-api.zuplo.app/api/v1/users?stage=canary# Route to release backend (default)curl https://your-api.zuplo.app/api/v1/users?stage=release# Or omit the parameter entirely for releasecurl https://your-api.zuplo.app/api/v1/users
2. Using Headers
Test with a custom header:
Code
# Route to canary backendcurl https://your-api.zuplo.app/api/v1/users \ -H "x-stage: canary"# Route to release backendcurl https://your-api.zuplo.app/api/v1/users \ -H "x-stage: release"
Add comprehensive logging to track canary routing. Inbound policies run before a
response exists, so set debug response headers from a
response sending hook:
To fall back to production when the canary backend fails, replace the URL
Forward handler with a custom handler that
retries against production on server errors:
Code
// modules/canary-fallback-handler.tsimport { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime";export default async function handler( request: ZuploRequest, context: ZuploContext,) { const url = new URL(request.url); const backendUrl = context.custom.backendUrl; const target = `${backendUrl}${url.pathname}${url.search}`; // Buffer the body so the request can be retried const body = request.body ? await request.arrayBuffer() : undefined; const response = await fetch(target, { method: request.method, headers: request.headers, body, }); // Retry against production if the canary backend returns a server error if (backendUrl === environment.API_URL_CANARY && response.status >= 500) { context.log.warn("Canary backend failed, falling back to production", { status: response.status, }); const fallbackBase = environment.API_URL_PRODUCTION; return fetch(`${fallbackBase}${url.pathname}${url.search}`, { method: request.method, headers: request.headers, body, }); } return response;}
Security Considerations
Authentication: Always verify user identity before canary routing
Query Parameter: Consider restricting stage parameter to authenticated
users only
Access Control: Limit canary access to verified employees only
Audit Logging: Log all staging decisions for security audits
Stage Validation: Only accept valid stage values ("release" or "canary")