Railway injects about a dozen variables into every deployment that you never declared. RAILWAY_PUBLIC_DOMAIN holds your service's URL. RAILWAY_GIT_COMMIT_SHA holds the commit that triggered the deploy. You reference them, and each other's variables, with a ${{ }} template syntax that resolves before your app ever starts.
That resolution timing is the whole story. Railway hands variables to the build process as well as the running service, so a reference behind a VITE_ prefix doesn't stay a reference. It becomes a literal string in the JavaScript you ship to browsers.
TL;DR
Reference variables use ${{ SERVICE.VAR }} for another service, ${{ shared.KEY }} for project-level shared variables, and ${{ VAR }} for the same service. Railway also injects built-ins like RAILWAY_PUBLIC_DOMAIN, RAILWAY_PRIVATE_DOMAIN and RAILWAY_GIT_COMMIT_SHA that you can reference the same way. All of them resolve at build time as well as runtime, so any reference sitting behind a VITE_, NEXT_PUBLIC_ or PUBLIC_ prefix gets baked into your client bundle, sealed or not.
The three reference forms
Every Railway reference is a ${{ }} expression in a variable's value field. Which namespace you use depends on where the source value lives.
# Another service in the same project
DATABASE_URL=${{ Clickhouse.DATABASE_URL }}
# A project-level shared variable
STRIPE_SECRET_KEY=${{ shared.STRIPE_SECRET_KEY }}
# Another variable on this same service
AUTH_ENDPOINT=https://${{ BASE_URL }}/${{ AUTH_PATH }}
Two details bite people. The service name is the name on your project canvas, exactly as displayed, and it's case sensitive: ${{ postgres.DATABASE_URL }} won't resolve if the service is called Postgres. And references compose with plain text, so you build full URLs inline rather than storing a pre-assembled string that goes stale when a domain changes.
Referencing beats copying for one reason that shows up months later: rotation. If four services each hold their own copy of a Stripe key, rotating it means four edits and one you'll forget. Point all four at ${{ shared.STRIPE_SECRET_KEY }} and rotation is a single field.
RAILWAY_PUBLIC_DOMAIN
RAILWAY_PUBLIC_DOMAIN is injected into every build and deployment with no setup. It holds the public domain of the service, something like example.up.railway.app.
It contains the host only. No https://, no trailing slash. So this is the shape you want:
# On the backend service itself
WEBHOOK_URL=https://${{ RAILWAY_PUBLIC_DOMAIN }}/webhooks/stripe
# On the frontend service, pointing at the backend
API_URL=https://${{ backend.RAILWAY_PUBLIC_DOMAIN }}
That second line is the pattern worth internalizing. A frontend and backend deployed together in one project no longer need a hardcoded API URL per environment, because each environment resolves its own.
RAILWAY_PUBLIC_DOMAIN does not follow your custom domain. Attach api.yourapp.com to a service and the variable still returns the up.railway.app address. If you send that value out in an email link, a webhook registration, or an OAuth redirect URI, users see the Railway domain and OAuth providers reject the mismatch. Keep a separate APP_URL variable for the canonical address and reference that instead.
RAILWAY_PRIVATE_DOMAIN, and when to prefer it
Railway also gives each service a private DNS name in the form service-name.railway.internal, exposed as RAILWAY_PRIVATE_DOMAIN. Traffic between services over that network stays inside Railway and is Wireguard encrypted.
For a backend calling a worker, or an app talking to its own internal API, reference the private domain. There's no reason to route an internal call out to the public internet and back, and doing so puts an endpoint on a public hostname that you then have to authenticate against the whole world instead of against one network.
# Public: goes out to the internet and back in
WORKER_URL=https://${{ worker.RAILWAY_PUBLIC_DOMAIN }}
# Private: stays inside Railway
WORKER_URL=http://${{ worker.RAILWAY_PRIVATE_DOMAIN }}:8080
The git variables
When a deployment comes from a connected GitHub repo, Railway injects the commit metadata:
| Variable | Contains |
|---|---|
RAILWAY_GIT_COMMIT_SHA | Full commit hash that triggered the deploy |
RAILWAY_GIT_BRANCH | Branch the deploy came from |
RAILWAY_GIT_AUTHOR | Commit author |
RAILWAY_GIT_COMMIT_MESSAGE | Commit message text |
RAILWAY_GIT_COMMIT_SHA earns its keep in error tracking. Pass it as your release identifier and every stack trace maps to exact source, which turns "this broke sometime last week" into a diff.
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: process.env.RAILWAY_GIT_COMMIT_SHA,
environment: process.env.RAILWAY_ENVIRONMENT_NAME,
});
Alongside those, RAILWAY_PROJECT_NAME, RAILWAY_ENVIRONMENT_NAME, RAILWAY_SERVICE_NAME and RAILWAY_DEPLOYMENT_ID are available in every deployment. The environment name is the useful one for conditional behavior, since it tells a PR environment apart from production without you maintaining a flag.
Don't put RAILWAY_GIT_COMMIT_MESSAGE or RAILWAY_GIT_AUTHOR on a public-facing page or a client-side error widget. Commit messages routinely name internal ticket IDs, unreleased features, and occasionally the vulnerability you just patched. Author strings are real email addresses.
The build-time leak
Here's the failure this page exists for.
Railway provides variables in two phases: to the build process for each deployment, and to the running deployment. Both. Frontend bundlers treat a specific prefix as "safe to publish" and perform a literal text substitution at build time. Vite uses VITE_, Next.js uses NEXT_PUBLIC_, SvelteKit uses PUBLIC_.
Put those two facts together and a reference variable resolves during the build, then gets written into your JavaScript as a string constant:
# You wrote this in the Railway dashboard
VITE_DATABASE_URL=${{ Postgres.DATABASE_URL }}
// What lands in dist/assets/index-a3f9c2.js
const l = "postgresql://postgres:hT7nQ2xW@containers-us-west-42.railway.app:6543/railway";
The reference syntax makes this easier to do by accident than a plain paste would. Pasting a database password into a field named VITE_DATABASE_URL at least looks alarming while you're doing it. ${{ Postgres.DATABASE_URL }} looks like an abstraction, like a pointer that stays a pointer. It doesn't.
Sealed variables do not protect you here. Sealing means the value is never visible in the Railway UI and can't be retrieved through the API, and sealing is permanent. But the docs are explicit that a sealed value "is provided to builds and deployments". The build is where the inlining happens. A sealed secret referenced behind a VITE_ prefix is a secret nobody on your team can read and every visitor can.
We see the downstream version of this constantly in scans: a live app serving a bundle with a working Postgres URL or a Stripe secret key in it, and an owner who is certain the value was "in environment variables, not in the code". It was in environment variables. Then it was compiled.
Checking your own bundle
The check takes a minute and doesn't need any tooling:
Build locally the way Railway does, then grep the output for something private you know exists, like your database password or the first several characters of a Stripe key.
npm run build
grep -ri "postgresql://" dist/ .next/ build/ 2>/dev/null
If the app is already deployed, open it in a browser, view source, and search the loaded JS chunks. Whatever a browser can read, a stranger can read. There is no authentication step in front of a bundle.
For each hit, delete the public-prefixed variable and move the call server side. A browser should be talking to your API, not to Postgres.
FAQ
What is the RAILWAY_PUBLIC_DOMAIN environment variable?
It's a variable Railway injects into every build and deployment automatically, holding the service's public domain such as example.up.railway.app. There's no scheme in it, so write https://${{ RAILWAY_PUBLIC_DOMAIN }} for a full URL. You can't set or override it. It also does not track custom domains, so keep your own APP_URL variable for the canonical address.
How do I reference another service's variable in Railway?
Use ${{ SERVICE_NAME.VARIABLE_NAME }} in the value field. Railway's documented example is DATABASE_URL=${{ Clickhouse.DATABASE_URL }}. The service name must match the name on your project canvas exactly, including case. For project-level shared variables the namespace is the literal word shared, as in ${{ shared.STRIPE_SECRET_KEY }}.
What is RAILWAY_GIT_COMMIT_SHA used for?
It's the full commit hash that triggered the deployment, injected when the service deploys from a connected GitHub repo. The standard use is passing it as the release identifier to an error tracker so stack traces resolve against exact source. Railway injects RAILWAY_GIT_BRANCH, RAILWAY_GIT_AUTHOR and RAILWAY_GIT_COMMIT_MESSAGE alongside it.
Do Railway reference variables resolve at build time?
Yes. Railway supplies variables to the build process and to the running deployment. Anything with a VITE_, NEXT_PUBLIC_ or PUBLIC_ prefix gets resolved and written into your JavaScript bundle as a literal string. Sealing the source variable doesn't help, because sealed values are still provided to builds.
Should I use RAILWAY_PRIVATE_DOMAIN or RAILWAY_PUBLIC_DOMAIN?
Private for anything inside your project, public for anything a browser or third party has to reach. RAILWAY_PRIVATE_DOMAIN resolves to service-name.railway.internal and inter-service traffic on it is Wireguard encrypted, so a backend calling a worker never touches the public internet.
Not sure what your bundle is shipping?
CheckYourVibe scans your deployed app for secrets compiled into client-side JavaScript, exposed endpoints, and missing security headers.