# Routing a Test Hostname to a Separate Backend

> **Scope of this page**
>
> This page documents the Zuplo **gateway** runtime (API, AI, and MCP gateways), not the Developer Portal.
>
> It applies only to Managed Dedicated hosting. It doesn't apply to Managed Edge or Self-Hosted, so don't use it for those deployments. See [Hosting options](https://zuplo.com/docs/articles/hosting-options).

Test a new backend release against production data by sending a second public
hostname through the same production Zuplo gateway. Akamai tags each request
with a header based on the hostname, and one Zuplo policy uses that header to
pick the production or test backend. Your frontend and API clients change
nothing except the hostname they call.

## How it works

<Diagram height="h-80">
  <DiagramNode id="prod-client">Production clients</DiagramNode>
  <DiagramNode id="test-client">Test clients</DiagramNode>
  <DiagramGroup id="akamai-edge" label="Akamai property">
    <DiagramNode id="cdn" variant="blue">
      api.example.com
      <br />
      api-test.example.com
    </DiagramNode>
  </DiagramGroup>
  <DiagramNode id="gateway" variant="zuplo">
    Production Zuplo gateway
  </DiagramNode>
  <DiagramGroup id="backends" label="Backends">
    <DiagramNode id="prod-backend">Production backend</DiagramNode>
    <DiagramNode id="test-backend">Test backend</DiagramNode>
  </DiagramGroup>
  <DiagramEdge from="prod-client" to="cdn" label="api.example.com" />
  <DiagramEdge from="test-client" to="cdn" label="api-test.example.com" />
  <DiagramEdge from="cdn" to="gateway" label="x-zuplo-route header" />
  <DiagramEdge from="gateway" to="prod-backend" label="prod" />
  <DiagramEdge from="gateway" to="test-backend" label="test" />
</Diagram>

Akamai turns the hostname into a header, and the gateway turns the header into a
backend. Everything between the client and the backend is the production path
for both hostnames: the same Akamai property, the same gateway deployment, and
the same routes, policies, and rate limits.

The examples on this page use these hostnames. Replace them with your own.

| Example hostname              | Purpose                                               |
| ----------------------------- | ----------------------------------------------------- |
| `api.example.com`             | Public production hostname                            |
| `api-test.example.com`        | New public test hostname, on the same Akamai property |
| `gateway.example.com`         | Production Zuplo custom domain, the Akamai origin     |
| `origin-api.example.com`      | Production backend                                    |
| `origin-api-test.example.com` | Test backend that runs the release candidate          |

## Prerequisites

- An Akamai property that already forwards `api.example.com` to your production
  Zuplo gateway. See [Setting up Akamai CDNs](./cdn.mdx).
- A test backend that runs the release you want to validate and can reach
  production data.
- An edge certificate and DNS access for the new test hostname.

## Step 1: Set the routing header in Akamai

Both public hostnames live on the same Akamai property and use the same origin:
the production Zuplo custom domain. Akamai always sets `x-zuplo-route`, so it
overwrites any value a client sends.

In [Property Manager](https://techdocs.akamai.com/property-mgr/docs), edit the
property and make these changes:

1. **Default rule.** Add a
   [Modify Outgoing Request Header](https://techdocs.akamai.com/property-mgr/docs/modify-outgoing-req-header)
   behavior. Set the action to **Add** or **Modify**, the custom header name to
   `x-zuplo-route`, and the value to `prod`.
2. **New child rule named `Test routing`.** Add a **Hostname** match for
   `api-test.example.com`. Add a **Modify Outgoing Request Header** behavior
   with the custom header name `x-zuplo-route` and the value `test`. Because the
   child rule runs after the default rule, it wins for the test hostname.
3. **Cache key.** Make sure test and production responses can't share a cache
   entry. Set **Cache Key Hostname** to **Incoming Host Header**, or turn off
   caching in the `Test routing` rule.
4. **Hostname.** Add `api-test.example.com` to the property's hostnames with its
   edge certificate, and create its CNAME record in your DNS.

Activate the property version. Clients that test the release only change the
base URL they call. They never send the header themselves.

## Step 2: Add the backend URLs in Zuplo

In the [Zuplo Portal](https://portal.zuplo.com/+/account/project/), open your
project and select **Settings → Environment Variables**. Add these variables to
the production environment. If you already have a variable for the production
backend, keep its name and use it in place of `BASE_URL_PROD` in the following
steps.

```bash
BASE_URL_PROD=https://origin-api.example.com
BASE_URL_TEST=https://origin-api-test.example.com
```

You don't need a new Zuplo environment, branch, or custom domain. The production
deployment you run today serves both hostnames. For more on variables, see
[Environment variables](../../articles/environment-variables.mdx).

## Step 3: Add the routing policy

This policy follows the pattern in
[Canary routing for employees](../../guides/canary-routing-for-employees.mdx),
adapted to one signal: the `x-zuplo-route` header that Akamai sets. Any value
other than `test` goes to production, so a missing or unexpected value never
reaches the test backend. You add the policy once; it doesn't change between
releases.

Create `modules/test-routing.ts`:

```ts title="modules/test-routing.ts"
import {
  HttpProblems,
  ZuploContext,
  ZuploRequest,
  environment,
} from "@zuplo/runtime";

// Set by the Akamai property on every request. Never set by clients.
const ROUTE_HEADER = "x-zuplo-route";

export default async function testRouting(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const isTest =
    request.headers.get(ROUTE_HEADER)?.trim().toLowerCase() === "test";

  const backendUrl = isTest
    ? environment.BASE_URL_TEST
    : environment.BASE_URL_PROD;

  if (!backendUrl) {
    context.log.error("Backend base URL is not configured", {
      stage: isTest ? "test" : "prod",
    });
    return HttpProblems.internalServerError(request, context);
  }

  // The route's URL Forward handler reads this value as its baseUrl.
  context.custom.backendUrl = backendUrl;

  if (isTest) {
    context.log.info("Routing to test backend", {
      path: new URL(request.url).pathname,
    });
  }

  // Lets testers confirm which backend answered.
  const stage = isTest ? "test" : "prod";
  context.addResponseSendingHook((response) => {
    const tagged = new Response(response.body, response);
    tagged.headers.set("x-backend-stage", stage);
    return tagged;
  });

  return request;
}
```

Register it in `config/policies.json`:

```json title="config/policies.json"
{
  "name": "test-routing",
  "policyType": "custom-code-inbound",
  "handler": {
    "export": "default",
    "module": "$import(./modules/test-routing)"
  }
}
```

## Step 4: Point each route at the chosen backend

On every route that forwards to the backend, make two changes:

1. Set the [URL Forward handler](../../handlers/url-forward.mdx) `baseUrl` to
   `${context.custom.backendUrl}` instead of the environment variable.
2. Add `test-routing` to the inbound policies. Keep your existing policies. The
   routing policy only reads a header, so its position in the list doesn't
   matter.

```json title="config/routes.oas.json"
"/v1/products/{id}": {
  "get": {
    "x-zuplo-route": {
      "handler": {
        "export": "urlForwardHandler",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "baseUrl": "${context.custom.backendUrl}"
        }
      },
      "policies": {
        "inbound": ["<your-existing-policies>", "test-routing"]
      }
    }
  }
}
```

If the route connects to your backend with
[mTLS](../../articles/securing-backend-mtls.mdx), keep the `mtlsCertificate`
option you use today and configure the test backend to trust the same client
certificate. One setting then covers both backends.

Deploy this change to production once. From then on, every route, policy, and
rate limit is identical for both hostnames; only the backend differs.

## Test the setup

Send these requests and check the `x-backend-stage` response header on each:

| Request                                                                  | Expected `x-backend-stage`           |
| ------------------------------------------------------------------------ | ------------------------------------ |
| `curl -i https://api.example.com/v1/products/1`                          | `prod`                               |
| `curl -i https://api-test.example.com/v1/products/1`                     | `test`                               |
| `curl -i https://api.example.com/v1/products/1 -H "x-zuplo-route: test"` | `prod` (Akamai overwrote the header) |

If the third request returns `test`, the default rule from Step 1 isn't setting
the header. Fix that before you share the test hostname.

## Release flow

A backend release doesn't require a gateway change:

1. Your CI/CD pipeline deploys the new backend version to the test backend.
2. Testers and test builds of your frontend call `api-test.example.com`. Their
   requests go through the production Akamai property and the production gateway
   and its policies, to production data.
3. When testing passes, promote the same build to the production backend.

Keep using your non-production Zuplo environments to test gateway changes.
Changing the gateway and the backend in the same test makes a failure hard to
place. Serving both hostnames from one gateway also means the test and
production paths can't drift apart, which a separate Zuplo environment for
testing would allow.

## Guardrails

- **Only Akamai sets the header.** If `gateway.example.com` is reachable without
  going through Akamai, anyone could send `x-zuplo-route: test`. Restrict the
  gateway to Akamai traffic with a shared secret header or mTLS. See
  [Securing Zuplo from direct access](../../articles/waf-ddos-akamai.md#securing-zuplo-from-direct-access).
- **The test hostname uses production data.** Treat it as production: keep the
  same authentication and rate limits, and don't publish the hostname.

## Related resources

- [Akamai architecture](./architecture.mdx)
- [Controlling Akamai CDN caching](./caching.mdx)
- [User-based backend routing](../../guides/user-based-backend-routing.mdx)
- [ZuploContext `addResponseSendingHook`](../../programmable-api/zuplo-context.mdx#addresponsesendinghook)
