Vite Reads Env Vars at Build Time, Which Changes Where They Have to Live
TL;DR
- The Remix app 500’d on Railway with a hydration error and an undefined URL variable.
VITE_SERVER_PROXY_URLwas duplicating protocol and port, andimport.metawasn’t pulling the expected values.- SSH’d into the Railway container to test connectivity, which confirmed the network was fine.
- The real answer: Vite bakes client env vars at build time, so setting them at runtime does nothing.
- They have to be
ARG/ENVin the Dockerfile and declared in.gitlab-ci.ymlto reach the build.
Continuing yesterday’s deployment problem. The Railway ecosystem itself has been a pleasure — standing up the server, the client, and a Postgres database with environment variables straight from the dashboard is genuinely easy. There was a warning about my Postgres instance being deleted that resolved itself, but the backend was solid.
The Remix app at angus.ryer.io was not. A 500, a hydration error, and an undefined URL environment variable.
Confirming it wasn’t the network
The client’s VITE_SERVER_PROXY_URL was wrong — duplicating protocol and port information — and the logs showed the import.meta approach wasn’t pulling the values I expected. In Docker, client-server communication goes through network names, so I lined up server.js logging with Railway’s internal configuration and fixed the duplicated URL.
Redeployed. Still wouldn’t load.
At that point I SSH’d in, which Railway makes easy by offering a copyable SSH command from the service interface. Connected, navigated to /app, confirmed access with ls. Pinging localhost worked. Pinging ryerio-client.railway.internal returned an IPv6 address, so internal connectivity was healthy.
Reaching that same hostname from Postman over HTTPS returned “address not found” — which is correct behaviour for a private network, much like AWS’s internal model, and not a bug. Useful to have proven rather than assumed.
So the network was fine and the variables were still wrong. I enabled viteClientProxyURL in the client environment variables array in remix.config.js, which had been commented out, committed, and watched the CI logs.
The thing I was missing
Vite pulls client-facing environment variables at build time, not runtime. There is no runtime environment for Vite on the client side — the values are baked into the bundle when it’s compiled.
That reframes the entire problem. Setting variables in the Railway dashboard configures the running container, which is far too late. By the time the container starts, the client bundle already contains whatever was present when it was built — which was nothing.
Where they actually have to go
Into the Docker build, as an ARG and then an ENV:
|
|
That alone still gave me blank strings on deploy, because the build had the plumbing but nothing was feeding it.
Into GitLab CI, which is where the build actually happens. Since I can’t pass environment variables directly into Docker on Railway, every VITE variable comes out of .env and gets declared in gitlab-ci.yml:
|
|
These aren’t secrets. They’re compiled into frontend code that ships to every browser, so treating them as sensitive would be theatre. Anything genuinely secret must never take this path.
The takeaway
Blank strings and undefined are different failures and I conflated them. Undefined meant the variable never reached the build; blank string meant the mechanism worked but had nothing to pass. Reading that difference correctly would have got me to build-time versus runtime hours earlier.
ryer.io