The Anyport Team
A preview environment for the clients that cannot change their URL
A preview URL per pull request works for anyone with a browser. A mobile build, a frontend deployed elsewhere and a test suite all have the API address baked in. Routing by header on the real hostname fixes that, and the design is mostly about what happens when the header is wrong.
Preview environments are sold with a screenshot of a pull request comment and a link: api-pr-42.example.dev, click it, and the branch is running. For the person reviewing a web page that is the whole story. For everyone else who needs to exercise the change, it is the start of a detour.
The mobile build points at api.example.com, because that is the address compiled into it, and producing a build per pull request that points somewhere else takes longer than the review. The frontend lives on another platform with its API address in an environment variable, and it has its own preview per pull request, which points at production. The QA team's Postman collection, the contract tests in CI, the partner who is integrating against the new endpoint: every one of them has the address, and the address is not the preview's.
Three ways to get the request to the right place
The first is to change the client. Rebuild the app, redeploy the frontend, edit the collection, once per pull request. It works and it is what most teams quietly do, which is why the preview gets tested by fewer people than the ones who could test it.
The second is DNS: a resolver or a hosts file that sends api.example.com to the preview for one machine. It holds for a laptop and falls apart for a phone, a CI runner or a colleague, and the TLS certificate for the real hostname is not on the preview's ingress, so it fails in a way that looks like a security warning.
The third is to leave the address alone and route on something the client can add. The request goes to api.example.com as always, with one extra header naming the preview, and the ingress sends it to the preview's app instead of the base's. The certificate is the base hostname's own, so TLS is untouched, and so are CORS and cookie domains, which were written for that hostname. Every HTTP client ever written can set a header. That is why this is the one worth building.
The miss matters more than the hit
Getting the right request to the preview is one routing rule. The harder decisions are all about requests that are slightly wrong.
A key that matches nothing must not fall through to the base project. The obvious implementation does exactly that, since a request with an unknown header is still a request for api.example.com. The result is a tester whose preview was closed yesterday, still sending the header, still getting answers, testing production and believing it is the branch. The miss has to be loud: a status no application returns by accident, and a response header saying the key matched nothing.
The header must not reach the application. It is routing information, and an app that starts reading it has grown a second, undocumented way to behave differently per request.
And the base project must be unaffected by all of it. A request without the header gets exactly what it got before the feature existed, which is what lets the switch be on in the project where the real traffic is.
What the key is, and is not
The first version of this we built used a random-looking key per pull request, derived with a secret salt so it could not be guessed. It lasted a day. The key had to be copied from the pull request comment into the mobile build's debug settings, which is exactly the per-pull-request chore the feature exists to remove, and it protected nothing: the preview's own URL, api-pr-42, was already guessable by anyone who could count. Access control was never the key's job. It belongs to the app's own authentication, or to a lock in front of the hostname.
So the key is the pull request's name, pr-42, the same label the preview's URLs carry. A build script can set it from the pull request number with no lookup, and nobody mistakes it for a password, because it plainly is not one. The honest description of it is an address.
Limits worth stating
A browser cannot add a header to a page navigation, so this is for API clients, and a person clicking around keeps using the preview's own URL. Previews built from forks should never get it, because it would let an outside contributor's code answer on your real hostname. The routing needs an ingress that can match on headers, which rules out some controllers. And it only covers the app a request first reaches: from there, the preview's other apps and services are reached the way they always are, inside the preview.
How this works in Anyport
In a project's preview settings, switch on Also serve previews on this project's hostnames. Each preview then also answers on the base project's hostnames, custom domains included, for any request carrying its key:
curl -H 'X-Anyport-Preview: pr-42' https://api.example.com/
The same request without the header reaches the base project. A key that matches no live preview is answered 418 with an X-Anyport-Preview-Miss header, never by the base. The header is removed before the request reaches your app. Keyed routes follow the base route, so moving its hostname moves them. Fork previews never get one, and the settings panel says when the cluster cannot serve them: the routing needs traefik, the default ingress on k3s, and the preview on the same cluster as its project. The key is on the pull request comment and the Previews tab.
The documentation has the details. The point of the feature is small and easy to lose: a preview nobody can reach from the client they actually use is a preview only the web reviewer tests.