NestJS Won't Start on Railway — Missing PORT Environment Variable

NestJS won't start on Railway, the deploy log says everything succeeded, and you're staring at a crashed or "unhealthy" service with no stack trace to blame. Before you start suspecting your database connection or your AppModule imports, check one line: app.listen(3000). That hardcoded number is very likely the entire problem.
Short answer: Railway assigns your container a dynamic port through the PORT environment variable, and if your NestJS bootstrap ignores it in favor of a hardcoded number, your app binds to a port Railway's networking layer was never told to check. The fix is one line — await app.listen(process.env.PORT ?? 3000) — with the hardcoded value kept only as a local-dev fallback, never as the real answer.

Why NestJS Won't Start on Railway: The Mechanism
Most NestJS starters ship with main.ts looking roughly like this:
1// The version that works everywhere except a real PaaS
2async function bootstrap() {
3 const app = await NestFactory.create(AppModule);
4 await app.listen(3000);
5}That's fine on your laptop, because nothing else is competing for port 3000 and nothing external needs to find it. Railway is a different situation entirely: it assigns your container a dynamic port at runtime, exposed through the PORT environment variable, and its health check and routing layer probes that port specifically — not 3000, not whatever you assumed, whatever value it actually injected. If your app is happily listening on 3000 while Railway is checking a completely different number, the platform sees a service that never responds, and marks the deployment as failed or unhealthy. The process might genuinely be running the whole time. Railway just can't find it.
(I've watched a client's team spend an entire afternoon adding retries and healthcheck delays to "fix" this, convinced their app was slow to boot. It booted in under a second. It was just listening on the wrong door.)
The Exact Fix
1// main.ts
2import { NestFactory } from '@nestjs/core';
3import { AppModule } from './app.module';
4
5async function bootstrap() {
6 const app = await NestFactory.create(AppModule);
7
8 // Railway (and Render, Heroku, most PaaS providers) inject PORT dynamically.
9 // The ?? 3000 fallback exists only for local development.
10 await app.listen(process.env.PORT ?? 3000);
11}
12bootstrap();process.env.PORT is read the same way Node.js reads any environment variable — there's no NestJS-specific magic here, which is exactly why this fix is identical across every framework and every PaaS provider that follows the same convention. That's the whole fix. Don't set PORT manually in Railway's environment variable dashboard to "make it match" — Railway already injects it, and adding your own conflicting value is how you turn one bug into two.

The Secondary Cause: Binding to localhost Instead of 0.0.0.0
If the PORT fix alone doesn't resolve it, check the second half of the same bind call. app.listen() accepts a host argument, and Nest's underlying HTTP adapter defaults to binding on all interfaces — but if a starter template or a past "fix" explicitly pinned it to 'localhost' or '127.0.0.1', that binding is invisible from outside the container. Railway's networking layer reaches your container from outside its own loopback interface, and a service only listening on localhost might as well not be listening at all from Railway's point of view.
1// Explicit and safe — binds on all interfaces, not just loopback
2await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
Verifying the Fix With Railway's Logs and Shell
Before you redeploy and hope, confirm what your app is actually doing:
- Check the deploy logs for the exact line your app logs on startup — most NestJS boilerplates log
"Application is running on: ..."with the port baked in. If it says3000while Railway's assigned port is something else, that's your confirmation. - Open Railway's shell for the running (or crash-looping) service and run
echo $PORT— this tells you exactly what value your code should have been reading and clearly wasn't. - Redeploy after the fix and watch the same startup log line — it should now print whatever numeric value Railway actually assigned, which will likely be different from 3000 and different again on your next deploy. That variability is expected; that's the whole point of a dynamically assigned port.
The Opinion Part: Hardcoded Ports Are a Code Smell, Not a Convenience
Here's the position worth stating plainly: a hardcoded port number in a service you intend to deploy anywhere is a bug waiting for its debut, not a harmless shortcut. app.listen(3000) is a fine default for local development, but the moment it ships as the only value your code will ever consider, you've written code that works by coincidence on your laptop and by luck everywhere else. Treat process.env.PORT ?? <local-default> as the non-negotiable pattern for any deployable Nest service — not just for Railway, but because you don't actually know today which platform this app lands on next quarter.
This is also, quietly, a cheap bug to avoid catching late. Fixing an oversight like this in code review costs a comment and thirty seconds; discovering it after a failed production deploy — with a client watching the dashboard — costs an afternoon of "is the database slow?" theories before someone finally reads the one line that mattered. Defects genuinely do get more expensive the later you find them, and this is about as low-stakes a version of that lesson as you'll get: a one-line fix that's free before you deploy and mildly embarrassing after.
Conclusion
If NestJS won't start on Railway and the logs give you nothing to work with, stop looking at your business logic and look at main.ts. Nine times out of ten it's a hardcoded port fighting Railway's dynamically assigned one, and the fix is the one-liner above, not a longer health-check timeout or a retry loop that just delays the same failure. Read the port from the environment, bind to all interfaces, and this stops being a Railway problem — it stops being a problem anywhere you deploy next.
Related reading: our guide on environment variable management for SaaS production covers the broader pattern this bug is a symptom of, and if you've fixed this one and are now deploying the same NestJS app somewhere serverless, WebSockets specifically break for a different, unrelated reason worth knowing about before you hit it.
Fix the one line, redeploy, and watch the health check finally turn green — probably faster than it took to read this far.
Frequently Asked Questions
Almost always because the app is hardcoded to listen on a fixed port (commonly 3000) instead of the port Railway assigns dynamically through the PORT environment variable. Railway's health check probes the port it told your container to use; if your app never binds to that port, the health check fails and the deployment gets marked as crashed, even though the process itself may be running fine internally.
Whatever value is in the PORT environment variable at runtime — it's assigned dynamically per deployment and isn't guaranteed to be the same number twice. Your app needs to read process.env.PORT at startup and listen on that, with a hardcoded fallback (like 3000) only for local development where Railway isn't injecting anything.
No — and you shouldn't. Railway injects PORT automatically; manually setting it yourself can conflict with the value Railway's networking layer actually expects and cause the exact crash you're trying to fix. The fix belongs in your code (process.env.PORT ?? 3000), not in Railway's variable settings.
Yes — it's not Railway-specific. Render, Heroku, and most PaaS providers assign a dynamic port through the same PORT environment variable convention. A NestJS app fixed for one of these platforms is fixed for all of them, because the underlying cause (a hardcoded listen port) and the fix (process.env.PORT ?? 3000) are identical across providers.
Locally, nothing is setting a PORT environment variable, so your hardcoded app.listen(3000) works by coincidence — there's no conflicting value to override it. On Railway, the platform sets PORT and expects your app to use it; a hardcoded port ignores that value entirely, so the app binds somewhere Railway isn't checking, and the deployment looks crashed from the outside even if the process is technically alive.
