Railway, measured on 2026-09-16 and 2026-09-17 on two of our Shopify apps; the fix is now in all eight of them. Railway's headers, variables and region codes may change after this.
Our countdown app's privacy policy said customer data was processed in Singapore, outside the European Economic Area. The container was in Amsterdam. Nobody had lied; someone had read the wrong header, turned it into a constant on 28 August, and for nineteen days the constant looked like a fact.
The header that fooled us
Every response from a Railway service carries an x-railway-edge header. From Korea, ours said sin1. The policy's source file even documented its own evidence: a comment next to HOSTING_REGION = "Singapore" saying the value came from x-railway-edge: sin1. The sentence about the EEA had then been derived from that constant.
sin1 is the edge point of presence nearest the requester. On 2026-09-16 we checked the sibling app on the same account, which we knew ran in the United States, and it answered sin1 too. Both services we checked, one in Europe and one in the US, said sin1 to a request from Korea. The header describes the network path on your side of the request, not the machine on the other side.
Asking the container instead
Railway sets RAILWAY_REPLICA_REGION in the environment of the running container. Two things we tried first didn't show it:
-
railway variableslists the variables you configured on the service. Runtime-injected values aren't in that list. - Railway's GraphQL API answered 403 with the CLI's stored token. A month later the same token worked against the public v2 endpoint for a different call, so the 403 was probably about the endpoint rather than the token; we haven't gone back to re-check it for the region.
So we made the app report on itself. The privacy route adds a response header with the raw value, and the body reads the same variable:
// headers of the /privacy response
"X-Steady-Region": process.env.RAILWAY_REPLICA_REGION || "unknown",
curl -sI https://<your-app>.up.railway.app/privacy | grep -iE '^x-[a-z]+-region'
The answer on 2026-09-16 was ams. Amsterdam, inside the EEA, under a policy stating the opposite.
The next day we ran the same check on our search app: us-east4-eqdc4a, Virginia. That one matched its deploy notes, because the notes recorded a deliberate move from EU West (Amsterdam) to US East on 2026-09-15. The two apps had the same host, the same account and the same author. What separated them was the kind of evidence behind the sentence: one had a document recording a decision, the other had a header somebody glanced at.
Region codes come in two spellings
The dashboard shows GCP-style names like europe-west4-drams3a. The runtime variable gave us ams. We don't know when Railway uses which form, or whether it will stay that way, so the lookup table holds both and matching is longest-prefix: us-east4-eqdc4a has to beat us-east4, and a future ams-something should still map to the Netherlands.
const REGIONS = [
// prefix, place, inEEA, inUK
["ams", "the Netherlands", true, false],
["sin", "Singapore", false, false],
["iad", "the United States", false, false],
["pdx", "the United States", false, false],
["us-east4", "the United States", false, false],
["europe-west2", "the United Kingdom", false, true],
["europe-west4", "the Netherlands", true, false],
// ... the other GCP-style names in our table
] as const;
When the value is missing or not in the table, the policy names no country at all and the server logs an error. The module's own comment puts it this way: a policy that names no country is thin, and one that names the wrong country is false. We'd just spent nineteen days on the false side.
What the fix looks like in code
One module turns the raw code into { place, inEEA, inUK, known }. The transfer sentence ("inside the European Economic Area and outside the United Kingdom") is computed from that same object, so the country and the EEA claim can't drift apart the way the old constant and its derived sentence did.
Most of the tests pin the shape rather than particular values, though one does pin ams to the Netherlands and the EEA, because that was the mistake. Two assert that the privacy route reads the region from the environment and contains no hand-written country or transfer claim. Another asserts the policy talks about hosting in exactly one place. One more feeds unknown codes and expects no country and no transfer sentence, and a last one checks the route source still contains the console.error for that case, since an unknown region should be loud rather than quietly skipped.
By 18 October the same module and header were in seven of our Railway-hosted apps, and the eighth was born with it. Five of them carry a header name copied from the first app we cloned it into, so they answer X-QB-Region instead of something with their own initials. The header is in the code and we haven't bothered to rename it. We've only curl-checked the live value on the first two apps, not on all eight.
Checking your own service in three curls
-
curl -sIyour app from two places, say a laptop and a VPS in another country, and comparex-railway-edge. Those two answers should differ for the same service; that difference is what the step shows. - Add a response header that echoes
RAILWAY_REPLICA_REGIONon a harmless route. Deploy.curl -sIagain. - Compare with Regions & Replicas in the dashboard. Expect the same place in a different spelling.
If step 2 prints unknown, the variable isn't reaching your process, and that's worth knowing before a policy says anything about it.
Moving the region, since you'll probably want to
Two days before any of this, on 2026-09-15, we had moved the search app from Amsterdam to US East from the Railway dashboard; the 09-17 check is what confirmed it had landed. The reasoning behind the move, and what happened:
Our census of Shopify App Store reviewers put the United States at 35%, the United Kingdom at 9.4%, Australia 5.3%, Canada 4.6% and Germany 4.2%. North America is around 40% of that audience and the UK plus Germany around 14%. With a single region, US East is the better compromise, and it's closer to Europe than US West would be.
Railway moves a service in place from Regions & Replicas. With a volume attached it warns about downtime while the volume's contents move. The data did follow: after the move, the migration step reported nothing pending and all 17 indexed items were still there.
Two things we'd underline. The app and its database must move together; an app in Virginia querying Postgres in Amsterdam would cross the Atlantic on every search. And right after the move, search was 236 ms slower than baseline. That was a cold Postgres cache: after a few dozen queries it was back to +7 ms, and the same thing happens after every redeploy.
We moved while we had no paying stores; after that, the same move means downtime on a merchant's shop.
Loose ends
We don't know the full set of short codes Railway can return; the table has the short codes we've seen plus the GCP-style names, and anything else resolves to "unknown" on purpose. We also haven't checked whether x-railway-edge ever happens to equal the compute region when the requester is nearby. Even if it did, it would still be the wrong thing to quote in a legal page, since it would change with the next requester.
What we'd want behind any sentence in a legal page that names a place is something the running process said about itself, with a date on it, that anyone can re-read with one curl. The edge header was never that, and we'd been quoting it for nineteen days.
Written by a backend engineer with ten years of experience, building small Wix and Shopify apps as operix.
The countdown app whose policy got this wrong, and now reads the region at run time, is on the Shopify App Store: https://apps.shopify.com/steady-countdown?utm_source=blog&utm_medium=article&utm_campaign=railway-edge-header-is-not-your-region
More from the same notebook: Your Wix widget loads from whichever deployment rendered the page, and that render can be cached for 35 hours ยท npx wix release does not build
Top comments (0)