Networking and Publishing Ports
Why localhost inside a container is not your localhost. Publishing ports, user-defined networks, and how one container finds another by name.
Why localhost inside a container is not your localhost. Publishing ports, user-defined networks, and how one container finds another by name.
Your application starts cleanly, the logs look perfect, and the
browser says the connection was refused. Or your web service cannot
reach the database container sitting right next to it, even though
both are running and you can see them in docker ps.
Almost every one of these is the same misunderstanding: inside a
container, localhost does not mean what it means on your machine.
By the end of this lesson you will know what a container's network
actually looks like, how to publish a port so the outside world can
reach in, and how to let two containers find each other by name.
Remember that namespaces give a container its own private view of the system. That includes the network namespace: its own network interfaces, its own routing table, and its own loopback address.
So 127.0.0.1 — localhost — means something different depending
on where you say it. On your machine it means your machine. Inside a
container it means that container, and nothing else can reach it.
docker run -d --name web nginx
docker run --rm curlimages/curl curl -s http://localhost
# curl: (7) Failed to connect to localhost port 80The second container asked its own loopback for a web server, found nothing there, and was entirely correct. The nginx container is a different machine as far as it is concerned.
Each container does get a real IP address on a Docker-managed network:
docker inspect -f '{{.NetworkSettings.IPAddress}}' web
# 172.17.0.2You can use that address, and you should not — it is assigned when the container starts and changes the next time. Names are the answer, and they arrive shortly.
To reach a container from your own machine, you publish a port: Docker listens on a port on the host and forwards traffic to a port inside the container.
docker run -d -p 8080:80 --name web nginx-p 8080:80
^^^^ ^^
host containerThe order is host:container, always. Traffic arriving at port 8080
on your machine is forwarded to port 80 inside the container, where
nginx is listening.
Getting it backwards is the classic silent failure — -p 80:8080
listens on port 80 and forwards to a port nothing is on, so the page
never loads and no error is printed anywhere.
The host port is yours to choose; the container port is decided by the software inside. Run three copies of the same image on different host ports and each one works:
docker run -d -p 8081:80 --name web1 nginx
docker run -d -p 8082:80 --name web2 nginx
docker run -d -p 8083:80 --name web3 nginxSome variations worth knowing:
docker run -d -p 80 nginx # random free host port
docker run -d -P nginx # publish every EXPOSEd port
docker run -d -p 127.0.0.1:8080:80 nginx # host loopback only
docker run -d -p 8080:80/udp nginx # UDP instead of TCP
docker port web # what this container publishesPublishing a port is only half of it. The program inside has to be listening on an address the forwarded traffic can arrive at — and this is where the localhost confusion does real damage.
Bad — publishes correctly and still answers nothing:
Good — listens on every interface in the container, including the one Docker forwards to:
Two separate things have to be right, and getting one of them wrong looks exactly like getting the other one wrong.
Docker forwards host 8080 to container 8000.
You control this from the command line, and docker ps shows
you it happened.
Your server accepts what arrives there.
Bound to 127.0.0.1, it accepts connections only from the
container's own loopback, so forwarded traffic is refused
before your code ever sees it.
The logs show a healthy server, the mapping looks correct, and the browser reports a connection reset.
Almost every framework defaults to loopback because that default is right on a laptop and wrong in a container:
Publishing is for traffic from outside. Two containers talking to each other is a different mechanism, and it is where Docker gives you something genuinely convenient.
Every Docker install has a default bridge network that containers join automatically. It gives them IP addresses, but no name resolution — on the default bridge, one container cannot look another up by name.
Create your own network and that changes:
On a user-defined network, Docker runs an embedded DNS server,
and every container's name is a hostname. The api container can
open a connection to db:5432 and it resolves — no IP addresses, no
discovery service, nothing to configure.
Which address you use depends on both ends, and mixing the four up is the second most common networking mistake after the binding one.
The container's name, and its internal port. Needs a user-defined network.
A special hostname. On Linux, add
--add-host=host.docker.internal:host-gateway.
The published host port, not the container's. This is the
only cell where -p matters.
Ordinary networking. Docker is not involved.
That distinction has a consequence worth stating plainly: the
database container does not need a published port at all for your
application to reach it. Publishing 5432 is only for connecting
from your own laptop with a GUI client or psql. Leaving it
unpublished means nothing outside the network can reach your
database, which is the safer default.
Sometimes the container needs to talk to something running on your
machine — a service you have not containerised yet. localhost
will not do it, for the reason this whole lesson is about.
host.docker.internal is a special hostname that resolves to the
host. It works out of the box on Docker Desktop for macOS and
Windows. On Linux you have to ask for it:
Networks are objects like volumes, with their own small set of commands:
docker network inspect is the one that answers "why can these two
not see each other" — its Containers section lists exactly who is
on the network. Nine times in ten, one of them is not.
There are also two network modes worth recognising when you meet them:
host mode removes the isolation entirely: the container uses the
host's network directly, so no publishing is needed and no port
mapping exists. It is faster and occasionally necessary, it only
works on Linux, and it means the container can bind any port on your
machine. none is for a process that should have no network access
at all.
You can now run a database and an application that reaches it. What you cannot do yet is start them without typing two long commands in the right order with a network created first — which is exactly the gap Compose fills, two lessons from now.
Before that, configuration: the DATABASE_HOST=db you just passed
is one environment variable, and a real application needs a dozen,
some of which are secrets. That is the next lesson. Try wiring up a
database and an application on a network of your own first — the
moment db:5432 resolves, container networking stops being
mysterious.
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000)if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)# The equivalent fix in other stacks
uvicorn app:app --host 0.0.0.0 --port 8000
flask run --host 0.0.0.0
rails server -b 0.0.0.0
npm run dev -- --host 0.0.0.0docker network create appnetdocker run -d --name db --network appnet \
-e POSTGRES_PASSWORD=devpass \
-v pgdata:/var/lib/postgresql/data \
postgres:16
docker run -d --name api --network appnet -p 8000:8000 \
-e DATABASE_HOST=db \
hello-dockerdocker run --rm curlimages/curl \
curl -s http://host.docker.internal:3000docker run --add-host=host.docker.internal:host-gateway \
--rm curlimages/curl curl -s http://host.docker.internal:3000docker network ls # every network
docker network create appnet # create one
docker network inspect appnet # its subnet and members
docker network connect appnet web # attach a running container
docker network disconnect appnet web # detach it
docker network rm appnet # delete it
docker network prune # delete unused networksdocker run --network host nginx # share the host's network
docker run --network none alpine # no networking at all# Publishing: reaching a container from your machine
docker run -p 8080:80 nginx # host 8080 -> container 80
docker run -p 127.0.0.1:8080:80 nginx # only from this machine
docker run -p 80 nginx # random host port
docker run -P nginx # publish all EXPOSEd ports
docker port web # show this container's maps
# Inside the container, listen on 0.0.0.0, never 127.0.0.1
uvicorn app:app --host 0.0.0.0 --port 8000
flask run --host 0.0.0.0
# Container to container: user-defined network + names
docker network create appnet
docker run -d --name db --network appnet postgres:16
docker run -d --name api --network appnet -e DB_HOST=db myapi
# api connects to db:5432 — the container name is the hostname
# Which address to use where
# db:5432 from another container on the same network
# localhost:5432 from your machine, if 5432 was published
# host.docker.internal from a container, to your machine
# Managing networks
docker network ls
docker network inspect appnet # who is attached
docker network connect appnet web # attach a running container
docker network rm appnet
docker network prune
# Special modes
docker run --network host nginx # no isolation (Linux only)
docker run --network none alpine # no network at all