Docker Networking Explained: Bridges, DNS, Ports and ufw
Docker networking explained: user-defined bridges, container names via Docker's DNS, published ports, why ufw misses them, and localhost in a container.
By Matthews WongReviewed on
Key takeaway
Docker networking is how containers reach each other and the world outside the host. Each container has its own network stack, loopback included. On a user-defined network, which Compose creates per project, containers find each other by name through Docker's embedded DNS. Publishing forwards a host port to a container port, on every host address unless you bind it to 127.0.0.1.
You will be able to
- Explain why containers on a user-defined network find each other by name, and which port they use to connect.
- Publish a port on loopback only, and say why a published port gets past ufw.
- Tell the container's localhost from the host's, and fix an app that listens only on its own loopback.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
How a request finds a container
A two-service Compose app on one host: containers find each other by name through Docker's embedded DNS, a published port is forwarded past ufw unless it is bound to loopback, and the app must listen beyond the container's own loopback to answer.
Keys: ← → step · Home / End · K play or pause
One Docker host. Compose put web and db on a user-defined bridge network with its own DNS. Outside it sit ufw, nginx, a published port and the internet.
web asks Docker's embedded DNS for db, then connects to db:5432, the container port. Nothing is published for this: containers on one network reach each other's ports directly.
postgres://db:5432Predict the next step ufw denies port 3002, and web is published with -p 3002:3000. A stranger connects to 3002. What happens? (ufw drops the packet · It reaches the container anyway)-p 3002:3000 listens on every host address. Docker's NAT rules divert the packet before it reaches the chains ufw uses, so the deny rule never sees it.
ports: - "3002:3000"127.0.0.1:3002:3000 listens only on the host's loopback, so remote hosts cannot reach it. nginx runs on the same host, so it can, and becomes the only way in.
- "127.0.0.1:3002:3000"Predict the next step Inside web, the app listens on 127.0.0.1:3000. Does nginx's request through the published port get an answer? (Yes, 127.0.0.1 is 127.0.0.1 · No, nothing answers)Each container has its own loopback. Docker forwards the published port to the container's network address, so an app listening only on the container's 127.0.0.1 never answers.
server.listen(3000, "127.0.0.1")This site's image sets HOSTNAME=0.0.0.0, so Next.js listens on every container address. Forwarded requests get answers, and the health check's 127.0.0.1:3000 still works inside.
ENV HOSTNAME=0.0.0.0
What is Docker networking?
Docker networking is the set of virtual networks that connect containers to each other and to the world outside the host. Each container runs in its own network namespace, which Linux uses to isolate network devices, protocol stacks, routing tables, firewall rules and port numbers. So a container has its own interfaces, its own loopback and its own ports. Docker's docs put it from the container's side: it only sees a network interface with an IP address, a gateway and a routing table.
Read more (1)Show less
Which network a container joins decides what it can reach. Run docker network ls on a fresh install and you see three: bridge, host and none. bridge is the default driver. host removes the isolation between the container and the host. none leaves the container with nothing but a loopback device. Overlay networks, which connect several Docker hosts for Swarm, are a topic of their own.
How do containers find each other by name?
Not on the default bridge. A container started without --network joins the network called bridge, where containers can only reach each other by IP address, unless you use the legacy --link option. Docker's own tutorial shows the result: ping by name fails with bad address. The same tutorial calls the default bridge a legacy detail of Docker that is not recommended for production.
Read more (2)Show less
On a user-defined network, the kind docker network create makes, Docker runs an embedded DNS server. Containers resolve each other by name or alias, and lookups for outside names are forwarded to the DNS servers the host is configured with. That is why a connection string between containers should carry a name, never an address.
Compose does this for you. For each project it creates a network named after the project, which defaults to the directory name, plus _default, and that network uses the bridge driver. Every service joins it and registers its name, so a service called db is reachable at the hostname db from every other service in the file.
Which port do containers use to talk to each other?
The container port. Compose's networking guide says it plainly: service-to-service communication uses the container port, and the host port is only used from outside the network. If db is published as 5433:5432, other services still connect to db:5432. Containers on the same user-defined network effectively expose all their ports to each other, with no publishing at all.
Read more (1)Show less
Networks are also how you keep things apart. Containers on different bridge networks can only reach each other through published ports. In Compose, the top-level networks key lets a proxy share one network with the app while the app shares another with the database, so the proxy has no route to the database at all.
What does publishing a port actually do?
Publishing, with -p HOST_PORT:CONTAINER_PORT or a ports entry in Compose, makes Docker add firewall rules that translate traffic from a host port to the container's port. With no host address, Docker publishes on every host address, 0.0.0.0 and [::], and its docs call that insecure by default: the port is open to the outside world, not only to the host. Put 127.0.0.1 in front and only the Docker host can reach it. The daemon's ip setting can make loopback the default.
Read more (1)Show less
EXPOSE 3000, near the end of this site's Dockerfile, publishes nothing. Docker's reference calls it documentation between the person who builds the image and the person who runs the container. Only docker run -P acts on it, publishing every exposed port to a high-numbered port on the host. Lesson 12 treats EXPOSE as a hardening topic; here the point is simply where the traffic goes.
Why does ufw not block a published port?
Because the packet never reaches ufw's rules. Docker's docs explain it: Docker routes container traffic in the nat table, so packets are diverted before they reach the INPUT and OUTPUT chains that ufw uses, effectively ignoring your firewall configuration. The Compose reference says the same of a port with no host address: it binds to all interfaces, bypassing host firewall rules. A ufw rule that denies 3002 does not stop traffic to a container published on 3002.
Read more (1)Show less
Do not fix it by editing Docker's rules; Docker's docs say you should not modify them. Bind any port that only a local proxy needs to 127.0.0.1, so there is nothing public to filter, and let the proxy on the host, behind ufw, be the only way in. That is what this site's compose file does.
Is localhost inside a container the host?
No. Inside a container, localhost and 127.0.0.1 are the container's own loopback, because the container has its own network namespace. The none driver makes that visible: a container on it gets a loopback device and nothing else. So a connection string that says localhost:5432 inside the app container points at the app container, not at a database on the host or in another container.
Read more (2)Show less
It works the other way too. A published port is forwarded to the container's network address, not its loopback, so an app that listens only on 127.0.0.1 inside the container never answers. In a test on Docker Engine 29.8.1, curl to the published port got an empty reply until the app listened on 0.0.0.0. That is why this site's Dockerfile sets HOSTNAME=0.0.0.0, which Next.js's standalone server reads as the address to listen on.
To reach a service on the host from inside a container, Docker Desktop resolves the name host.docker.internal for you; elsewhere, add --add-host host.docker.internal=host-gateway, which resolves to the host's internal address. The host network driver is the blunt option: the container shares the host's network namespace, gets no address of its own, and Docker discards any -p with a warning. It works on Docker Engine on Linux, and on Docker Desktop 4.34 and later once you enable it in Settings.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
ports:
# Bind to loopback only — nginx terminates TLS and proxies to this port.
- "127.0.0.1:${APP_PORT:-3002}:3000"Three parts, read right to left: container port 3000, host port APP_PORT (3002 when unset), and the host address 127.0.0.1. Because the host address is the host's loopback, remote hosts cannot reach the port and ufw has nothing to miss. nginx, on the same server, connects to it and is the only way in.
ENV PORT=3000
ENV HOSTNAME=0.0.0.0Next.js's standalone server reads PORT and HOSTNAME before it starts. This 0.0.0.0 lives inside the container's own network namespace: it means every address of the container, so forwarded traffic gets an answer. Which host addresses are open is decided separately, by the ports entry above.
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://127.0.0.1:3000/robots.txt",
]Docker runs this command inside the container, so 127.0.0.1:3000 is the container's own loopback and the app's real port, not the host's 3002. It works because the app listens on 0.0.0.0, which includes the container's loopback.
Mistakes that cost real time
ufw denies the port, and the internet still reaches it
A ports entry with no host address listens on every host address, and Docker's NAT rules route its traffic before ufw's chains run. The deny rule exists; it just never sees the packet. Bind to 127.0.0.1, or to the one interface that should listen, and confirm with docker port.
Empty reply from a published port
The port is published, the container runs, and docker exec with wget against 127.0.0.1 inside works, yet curl from the host fails. The app listens only on the container's loopback, and forwarded traffic arrives on the container's network address. Make it listen on 0.0.0.0: HOSTNAME for Next.js, or your server's own host setting.
bad address: a name that does not resolve
Containers started with a plain docker run sit on the default bridge, which has no name resolution between containers. Create a network, start both containers on it, and the same name works. In Compose, services on the project network resolve each other by service name out of the box.
localhost or the host port in a connection string
postgres://localhost:5432 inside the app container points at the app container itself. postgres://db:5433, using the published host port, misses too. Between containers, use the service name and the container port: db:5432.
| Network | Finds containers by name? | Ports | Docker describes it as |
|---|---|---|---|
| Default bridge | No, IP addresses only (or legacy --link) | -p publishes to the host | A legacy detail, not recommended for production |
| User-defined bridge (Compose's default) | Yes, by name or alias, through the embedded DNS | All ports open between members; -p for the host | Superior to the default bridge |
| host | No network of its own: it shares the host's | -p is discarded; the app listens on the host | Removes isolation between container and host |
| none | No, there is only a loopback device | Nothing to publish | Completely isolates the container |
Do it on your own machine
All you need is Docker on a laptop; every port below stays on 127.0.0.1. Each step shows one rule from this lesson, and the last one cleans up.
List the networks a fresh install has: bridge, host and none.
docker network lsOn the default bridge, a name does not resolve. Expect ping: bad address 'demo-a'.
docker run -d --name demo-a alpine:3 sleep 600 docker run --rm alpine:3 ping -c 1 demo-aOn a network you create, the same kind of lookup works: wget prints nginx's welcome page. From the default bridge, the name still fails.
docker network create demo-net docker run -d --name demo-web --network demo-net nginx:alpine docker run --rm --network demo-net alpine:3 wget -qO- http://demo-web docker run --rm alpine:3 wget -qO- -T 3 http://demo-webPublish with and without a host address, and compare what docker port prints: 0.0.0.0 and [::] for the first, 127.0.0.1 only for the second.
docker run -d --name demo-all -p 8081:80 nginx:alpine docker port demo-all docker run -d --name demo-lo -p 127.0.0.1:8082:80 nginx:alpine docker port demo-lo curl -I http://127.0.0.1:8082Run a tiny server that listens on the container's own 127.0.0.1, and give it a second to start. curl through the published port fails with an empty or reset reply, while the same request from inside the container works.
docker run -d --name demo-inside -e HOST=127.0.0.1 -p 127.0.0.1:8083:3000 node:22-alpine \ node -e "require('http').createServer((req, res) => res.end('hello\n')).listen(3000, process.env.HOST)" curl http://127.0.0.1:8083 docker exec demo-inside wget -qO- http://127.0.0.1:3000Start it again listening on 0.0.0.0, the fix this site's Dockerfile makes with HOSTNAME. Give it a second to start; now curl prints hello.
docker rm -f demo-inside docker run -d --name demo-inside -e HOST=0.0.0.0 -p 127.0.0.1:8083:3000 node:22-alpine \ node -e "require('http').createServer((req, res) => res.end('hello\n')).listen(3000, process.env.HOST)" curl http://127.0.0.1:8083Clean up the containers and the network.
docker rm -f demo-a demo-web demo-all demo-lo demo-inside docker network rm demo-net
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.
Two questions from earlier lessons
Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.
Answer in your head, then check
1.Why can't two containers started with plain docker run reach each other by name?Show the answer
Answer:They sit on the default bridge, where containers can only use IP addresses. Only user-defined networks get Docker's embedded DNS, and Compose creates one for every project, so its services resolve each other by name.
2.This site's compose file has 127.0.0.1 in the ports entry and in the health check. Do they mean the same machine?Show the answer
Answer:No. In the ports entry it is the host's loopback, so only the server can reach port 3002. The health check runs inside the container, so its 127.0.0.1:3000 is the container's own loopback and the app's real port.
3.What does EXPOSE 3000 in a Dockerfile do to the network?Show the answer
Answer:Nothing by itself. It documents the port the image expects to be published. Publishing happens with -p, a ports entry, or -P, which publishes every exposed port to a high-numbered host port.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.
- Docker docs: Networking overview (drivers, DNS services)
- Docker docs: Bridge network driver
- Docker docs: Networking with standalone containers
- Docker docs: Networking in Compose
- Docker docs: Port publishing and mapping
- Docker docs: Packet filtering and firewalls (ufw)
- Docker docs: Compose services reference (ports)
- Docker docs: Dockerfile reference, EXPOSE
- Docker docs: Host network driver
- Docker docs: None network driver
- Docker docs: docker container run (--add-host host-gateway, -p)
- Docker docs: Docker Desktop networking how-tos (host.docker.internal)
- Next.js docs: output standalone (PORT and HOSTNAME)
- man page: network_namespaces(7)