Railway assigns a port at runtime and passes it in as the PORT environment variable. Your server must read that variable and bind to 0.0.0.0. Getting either half wrong produces a build that succeeds, a container that runs, and a URL that never responds.
This failure is annoying specifically because nothing errors. The logs show your framework’s usual startup banner. The deploy is green. The URL times out or returns a platform-level error page, and there is nothing in your application logs to explain it, because from the application’s point of view everything is fine.
Two things have to be true, and they fail independently.
Table of contents
- Read the port from the environment
- Bind to 0.0.0.0, not localhost
- The Dockerfile CMD trap
- Configuration files that quietly override you
- Internal versus public, and the databases you should not expose
- How this fits the rest of the stack
- FAQ
Read the port from the environment
The platform picks the port, not you. Your job is to read it and fall back to something sensible for local development.
// Node / Express
const port = process.env.PORT || 3000;
app.listen(port, '0.0.0.0', () => {
console.log(`Listening on 0.0.0.0:${port}`);
});
# FastAPI with uvicorn, run programmatically
import os
import uvicorn
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8000))
uvicorn.run("main:app", host="0.0.0.0", port=port)
The int() conversion is not optional in Python. Environment variables are strings, and passing a string where a port number is expected produces a type error at startup that reads nothing like a port problem.
In Go and Ruby the shape is the same — read the variable, fall back locally, bind to all interfaces.
The fallback matters for a reason beyond convenience: it keeps local development working without a .env file, so a fresh clone runs. Just never let the fallback be the value production actually uses.
Bind to 0.0.0.0, not localhost
This is the half that catches people who already got the port right.
Binding to 127.0.0.1 or localhost means the server accepts connections that originate inside the container only. The platform’s proxy sits outside the container, so its connections are refused. Everything inside the container looks healthy — you could curl localhost from a shell in there and get a response — while nothing from outside can reach it.
Many frameworks default to localhost in development, which is a sensible default on a laptop and the wrong one in a container. The flags differ:
# uvicorn
uvicorn main:app --host 0.0.0.0 --port $PORT
# gunicorn
gunicorn app:app --bind 0.0.0.0:$PORT
# Django dev server (development only)
python manage.py runserver 0.0.0.0:$PORT
# Vite preview
vite preview --host 0.0.0.0 --port $PORT
# Next.js
next start -H 0.0.0.0 -p $PORT
The symptom is distinctive enough to diagnose from the logs alone. If your startup banner says http://127.0.0.1:8080 you have found the problem, whatever else is going on.
The Dockerfile CMD trap
A Dockerfile can get the port exactly right in the source and still fail, because of how CMD handles variable expansion.
CMD in exec form — a JSON array — does not run a shell. There is nothing to expand $PORT, so your server receives the four literal characters $PORT as its port argument:
Error: Invalid value for '--port': '$PORT' is not a valid integer.
That error message is at least honest about what happened, which puts it ahead of most failures in this category.
Three ways out, in increasing order of robustness:
# 1. Shell form -- a shell runs, so expansion works
CMD uvicorn main:app --host 0.0.0.0 --port $PORT
# 2. Exec form with an explicit shell
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port $PORT"]
# 3. An entrypoint script -- best for anything non-trivial
CMD ["./entrypoint.sh"]
The entrypoint script is the one to reach for when startup does more than one thing, because it gives you somewhere to put a default and somewhere to log:
#!/bin/sh
set -e
: "${PORT:=8000}"
echo "Starting on 0.0.0.0:$PORT"
exec uvicorn main:app --host 0.0.0.0 --port "$PORT"
The exec is worth keeping. Without it the shell stays as process 1 and does not forward termination signals, so your application never gets a chance to shut down cleanly and the platform kills it after a timeout on every single deploy.
Configuration files that quietly override you
There is a failure mode where the code is correct, the Dockerfile is correct, and a stale config file is overriding both. A railway.json or railway.toml left over from an earlier setup can specify a start command that no longer matches the project.
Debug in this order, because it goes from cheapest to most invasive:
# 1. What does the service actually have set?
railway variables
# 2. What did startup print?
railway logs
# 3. Is there a config file overriding the start command?
ls -la railway.* 2>/dev/null
cat railway.json 2>/dev/null
If railway variables does not show PORT, the service has not been given a domain yet — the variable is injected for services that are exposed. Generate a domain and it appears.
And read the startup banner in the logs against the two rules: does it name the injected port, and does it say 0.0.0.0? Those two questions resolve the large majority of these.
Internal versus public, and the databases you should not expose
Services on the same project can talk to each other over a private network without going out to the internet and back. This matters for cost, for latency, and for security.
The rule is simple: your API needs a public domain because browsers call it. Your database does not, because only your API calls it. Exposing a database publicly to make a connection string easier is a trade that never looks good afterwards.
The same logic applies to internal services — a worker, a queue consumer, a scheduled job. If nothing outside your own infrastructure calls it, it should not have a public address to be found on.
The general principle outlives any one platform: an interface that accepts connections is an interface someone will connect to. Every port you expose is a decision, and the default answer should be no.
How this fits the rest of the stack
Reading a port from the environment and binding to all interfaces is the same contract everywhere containers run. Learn it once and it transfers, which is the useful thing about this particular annoyance.
RunxBuild follows the same contract. A web service in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker deploys from your GitHub repository, gets a live route and a custom domain, and shows the build log and the runtime logs in the same place — so a server that came up on the wrong interface is visible in the startup output rather than inferred from a timeout. Managed MySQL and Postgres sit alongside on private networking, with connection limits and backups, and stay off the public internet by default. Autoscaling moves between a floor and ceiling plan you choose. To see what a service plus its database and storage add up to, the RunxBuild hosting calculator breaks them out as separate line items.
Useful related references:
- Deploy a Go Backend for Free on RunxBuild
- Deploy a Java Backend for Free on RunxBuild
- Railway vs Vercel: The Real Difference Is Not the Landing Page
- Services on RunxBuild
FAQ
What port should my Railway backend listen on?
Whatever the injected PORT environment variable says. The platform assigns it at runtime and routes to it, so hardcoding a number means the proxy sends traffic somewhere your server is not listening. Read the variable with a local fallback: process.env.PORT || 3000 in Node, or the equivalent in your language.
Why does my deploy succeed but the URL not respond?
Usually because the server bound to 127.0.0.1 instead of 0.0.0.0. A localhost bind only accepts connections originating inside the container, and the platform’s proxy is outside it. The container looks perfectly healthy from within. Check the startup banner in the logs — if it names 127.0.0.1, that is the whole problem.
Why does my container see the literal string $PORT?
Because CMD in exec form does not run a shell, so there is nothing to expand the variable and your server receives the characters $PORT as its argument. Use shell form, wrap it in sh -c, or move startup into an entrypoint script. For anything non-trivial the entrypoint script is the better answer.
Do I need to set PORT myself in the dashboard?
No, and setting it manually can cause conflicts. The platform injects it for services that have a domain. If railway variables does not list it, the service has not been exposed yet — generate a domain and the variable appears.
Should my database have a public port?
No. Only your API talks to it, and your API can reach it over the project’s private network. Exposing a database publicly to simplify a connection string turns an internal component into an internet-facing one. Public addresses belong to services that browsers call.