There is a line in almost every Python web tutorial that nobody explains:
uvicorn main:app --host 0.0.0.0 --port 8000
I copied it for weeks without thinking about it. Then I deployed the same application three times — to a local VM, to a production server, and into a container — and the correct value was different every time.
Twice it was 0.0.0.0. Once, in the place that mattered most, it was not.
That gap is worth writing about, because the setting itself is trivial and the reasoning behind it is not.
What the Flag Actually Controls
A server process doesn't "open a port." It creates a socket and binds it to an address. The bind address answers one question: which network interfaces should this socket accept connections from?
A machine has more than one interface:
-
lo(loopback) — reachable only from inside the machine (127.0.0.1). Packets addressed there never reach a physical network card; the kernel loops them straight back. -
0.0.0.0— a wildcard meaning every interface this machine has, including ones added later.
So the flag isn't about security or convenience. It's about reachability — and reachability depends entirely on what sits in front of the process.
Case 1: The Local VM — 0.0.0.0
I was running the service inside a Multipass VM and wanted to hit it from the browser on my laptop.
The laptop is outside the VM, so binding to loopback would have made the service invisible to it. curl inside the VM would work; the browser outside would get connection refused.
Decision: wildcard bind. Nothing sits in front of the process, and nothing needs protecting.
Case 2: Production — 127.0.0.1
Here I copied the same line at first, and it was wrong.
The production box has a public IP. Binding to 0.0.0.0 there means the application is directly exposed to the internet: no TLS, no rate limiting, no authentication. Within hours of provisioning that server, its SSH logs showed hundreds of automated login attempts against usernames like admin and oracle. The same scanners try HTTP ports.
So the application binds to loopback, and Caddy — the reverse proxy — binds ports 80 and 443. Requests arrive at Caddy, get their TLS terminated, and are forwarded to 127.0.0.1:8000. That forwarded connection never leaves the machine.
Why a Bind Address Beats a Firewall Rule
This is stronger than a firewall rule. I could have bound to 0.0.0.0 and blocked port 8000 in ufw. Same outcome, on paper.
But a firewall rule is a second system that has to be correct, stay correct, and survive every future change someone makes to it. A loopback bind isn't a rule about the socket — it's a property of the socket. There's no configuration to get wrong later.
Port 8000 isn't in my firewall rules at all. It doesn't need to be.
Case 3: Inside a Container — 0.0.0.0 Again
And here it flips back, for a reason that has nothing to do with the previous two.
A container gets its own network namespace. It has its own lo, its own interfaces, and its own view of the network — and its 127.0.0.1 is not the host's 127.0.0.1. They are different loopbacks in different namespaces.
Bind to 127.0.0.1 inside a container and the service becomes unreachable from the host entirely. Not "exposed but firewalled" — unreachable.
# Inside the container: bind to wildcard
uvicorn main:app --host 0.0.0.0 --port 8000
# On the host: explicitly control exposure
docker run -p 127.0.0.1:8000:8000 myapp
So inside the container you bind to 0.0.0.0, which is a much smaller claim than it sounds — it means every interface the container has. What actually gets exposed to the outside world is decided separately, by the runtime port mapping. The isolation decision moved to the container boundary.
The Mental Model: Ask the Question, Don't Memorize the Value
For a while I wanted a rule. Always use loopback in production felt like the lesson.
But that rule breaks in containers, breaks behind a load balancer on a private network, and breaks again in a Kubernetes pod where the network namespace is shared across containers.
The lesson isn't a memorized value. It's a question:
What sits between this socket and the untrusted network, and is it doing its job?
If something trustworthy is in front — a reverse proxy on the same host, or a container boundary with explicit port mapping — bind narrowly and let that layer decide exposure.
If nothing is in front, either bind narrowly or accept that you are publishing the service to the world.
A memorized value gives you the right answer in one context. The question gives you the right answer in all of them.
Running example: a FastAPI service on a €6/month VPS, behind Caddy, supervised by systemd. Code and architecture notes: fastapi-vps-deploy on GitHub
Top comments (1)
the question about what sits between the socket and the untrusted network is the useful rule. i would add a small deployment check that tests local access, host access, container access, and public access for each environment. record the expected result and check both ipv4 and ipv6 binds, because a service can look private on ipv4 while listening on ipv6. this makes the network boundary part of the release test.