From the team behind aidrop.it — one workspace to build, host, and keep changing your code.
The build succeeded. The logs say listening on http://localhost:3000. The platform says the deployment is running. And every request to your address returns a gateway error.
Nothing is broken. Your process is listening on an interface nothing outside the container can reach, or on a port nothing outside the container is looking at. Those are two different mistakes with the same symptom, and both are one line of code.
They are also the single most common reason a first deploy of otherwise working software fails, which is why it is worth being able to name them rather than rediscovering them.
Mistake one: binding to localhost
Inside a container, localhost and 127.0.0.1 mean this container and nothing else. A process bound there is reachable from a shell inside the container and from nowhere at all outside it — which is exactly the arrangement that makes it work on your laptop, where "outside" and "inside" are the same machine.
Bind to 0.0.0.0 instead. That means every interface the container has, including the one the platform routes to.
| Runtime | The change |
|---|---|
| Node / Express | app.listen(port, "0.0.0.0") |
| Next.js | next start -H 0.0.0.0 -p $PORT |
| Python / uvicorn | uvicorn main:app --host 0.0.0.0 --port $PORT |
| Python / gunicorn | gunicorn -b 0.0.0.0:$PORT app:app |
| Rails / Puma | bin/rails server -b 0.0.0.0 -p $PORT |
| Go | http.ListenAndServe(":"+port, nil) — the empty host is already every interface |
| Spring Boot | server.address=0.0.0.0, server.port=${PORT} |
Frameworks differ on the default, which is the trap: some already bind everywhere and some bind to loopback in development and keep doing it in production because nobody passed a flag.
Mistake two: choosing your own port
The second failure is quieter, because the process really is reachable — on a port nobody asked about.
Most platforms hand your container a PORT environment variable and route to that number. Read it, and fall back rather than assuming. A hard-coded 3000 works until the platform routes to 8080, at which point you have a healthy process and a dead address.
const port = process.env.PORT || 3000
Two details that turn this from a one-liner into an afternoon:
- Read the variable at start-up, not at import time in a module that gets bundled. A build-time read captures whatever was set during the build, which is usually nothing.
- The port in your
EXPOSEline is documentation, not behaviour. It does not open anything and it does not override what your code binds to. ADockerfilethat saysEXPOSE 8080while the app listens on3000is not the cause of the problem, but it is a reliable way to spend twenty minutes looking in the wrong place.
Why "it works in Docker on my machine" does not settle it
Running the image locally with -p 3000:3000 proves the process starts. It does not prove it binds to a reachable interface, because you published exactly the port it happened to choose and mapped it yourself.
The check that means something is to run the image the way the platform will: give it a port it did not choose, publish a different one, and ask for it from outside.
docker run --rm -e PORT=8080 -p 9000:8080 your-image
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:9000/
If that returns a status code, the container is reachable on a port it was told about, on an interface something else can reach — which is the actual contract. It is also the difference between a container that is running and one that answers, which a platform can only tell apart if you give it a health path to ask. If it hangs or refuses, you have reproduced the production failure on your own machine, where you can fix it in seconds.
This is the same class of assumption as an application writing to a disk that does not survive a restart: correct locally, quietly wrong under a platform that never promised the thing you relied on.
Two lines to add before the next deploy
Whatever you deploy with, these two make the failure impossible rather than diagnosable. Put them wherever your server starts.
// 1. Take the port from the environment, and say so in the log.
const port = Number(process.env.PORT) || 3000
// 2. Bind to every interface, not to loopback.
app.listen(port, "0.0.0.0", () => {
console.log(`listening on 0.0.0.0:${port} (PORT=${process.env.PORT ?? "unset"})`)
})
The log line is the part people skip and the part that pays. listening on 0.0.0.0:8080 (PORT=8080) and listening on 0.0.0.0:3000 (PORT=unset) are the same message in every other respect, and the difference between them is the entire bug. A log that prints the resolved value beside the source of that value turns a gateway error into a fact you can read — which is most of what a log is for when it is all you have.
A check that reads your repository can find both of these before a build runs, and a deploy tool worth using tells you what is missing rather than guessing past it.
FAQ
Why does my container return a 502 when the app is running? Almost always because the process is bound to 127.0.0.1 instead of 0.0.0.0, or is listening on a port the platform is not routing to. The process is genuinely healthy; nothing outside the container can reach where it is listening.
What is the difference between 127.0.0.1 and 0.0.0.0? 127.0.0.1 is the loopback interface — reachable only from inside the same container. 0.0.0.0 means every interface the container has, including the one the platform sends traffic to. On a laptop the distinction rarely shows, because inside and outside are the same machine.
Do I have to use the PORT environment variable? If the platform sets one, yes — it routes to that number regardless of what your code prefers. Read it with a fallback so the same image still runs locally when nothing sets it.
Does EXPOSE in my Dockerfile open the port? No. It is metadata. It documents intent for a human and for tooling; it neither opens a port nor changes what your process binds to.
How does aidrop.it handle this? PORT is injected into the container and the address routes to that number, so reading the variable is all that is required of your code. The bind address is still yours to get right — no check anywhere reads your source for a loopback bind, which is why the two lines above are worth adding rather than relying on.
Cover: https://commons.wikimedia.org/wiki/File:TW_%E5%8F%B0%E7%81%A3_Taiwan_%E5%8F%B0%E5%8C%97_Taipei_%E5%A4%A7%E5%AE%89%E5%8D%80_Da%27An_%E6%B0%B8%E5%BA%B7%E8%A1%97_Yongkang_Street_morning_March_2024_R12S_101.jpg by MAm ROFOW 022, CC0, via Wikimedia Commons. Cropped to 16:9 within the original frame. Alt text: A row of numbered doorways along a street, each entrance a different address.
Get it running from the session you are already in
Connect your coding agent over MCP and ask it to deploy. aidrop builds the repository and runs it on a public address; when a build or the app fails, the log says why, and your agent fixes it and builds again.