DNS, discovery & publishing ports
How containers find and expose each other.
Every container on a user-defined network comes with a tiny receptionist built in. Ask for a coworker by name and you get told which desk they are at today, even if they moved desks this morning. That receptionist is an embedded DNS server. DNS (Domain Name System) is the naming layer the whole internet runs on: names go in, addresses come back. Docker runs a small one of its own at the address 127.0.0.11, reachable from inside every container attached to that network. Your app asks for db. The resolver hands back db's current IP (Internet Protocol) address. Destroy db, recreate it, let it come back on a completely different address, and the name still lands on the right container. That is service discovery, and it is why you never write a container's address into your config.
$ docker network create appnetc0ffee112233445566778899aabbccddeeff00112233445566778899aabbccdd$ docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:169a3f7c1d2e4b6a8f0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b$ docker run --rm --network appnet nicolaka/netshoot nslookup dbServer: 127.0.0.11Address: 127.0.0.11#53Non-authoritative answer:Name: dbAddress: 172.19.0.2
Names outlive IPs
Here is what that buys you. Halfway through a deploy the database container gets torn down and a fresh one takes its place. Docker's address pool hands the replacement whatever address happens to be free at that moment, so it can come back as 172.19.0.5 instead of .2. Your app never notices. It still asks for db, the resolver answers with the new address, and traffic keeps flowing. Run it yourself and watch the number change under you.
$ docker rm -f dbdb$ docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:167b21e9a4c6d8f0123456789abcdef0123456789abcdef0123456789abcdef012$ docker run --rm --network appnet nicolaka/netshoot dig +short db172.19.0.5
One container, several names
Sometimes one service has to answer to several names. Some old client is wired to reach postgres and nobody wants to redeploy it. Another team's config expects db.internal. A network alias is an extra name pinned onto a container, and the embedded resolver serves every alias exactly the way it serves the container name. You attach them when the container starts, as many as you need.
$ docker rm -f dbdb$ docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret \--network-alias postgres --network-alias db.internal postgres:164c8e0f2a1b3d5e7f9012345678abcdef0123456789abcdef0123456789abcdef$ docker run --rm --network appnet nicolaka/netshoot dig +short db postgres db.internal172.19.0.6172.19.0.6172.19.0.6
A useful side effect falls out of this. Hand the same alias to two different containers and a lookup comes back with both addresses, the resolver rotating which one leads each time you ask. That is round-robin load balancing, taking turns in order, with nothing installed and nothing configured. Swarm (Docker Swarm, Docker's own clustering mode) builds its service virtual addresses on the same machinery, so the model you are forming right now carries straight over to services spread across a cluster.
Prove it: a real client, no address anywhere
A clean nslookup tells you the name resolved. It says nothing about whether anything reached the database. Those are two different events, and confusing them costs people whole afternoons. So close the loop. Start a throwaway psql client (the Postgres command-line tool) on the same network and point it at the hostname db, with no address typed anywhere in the command. If discovery is doing its job, the query runs and Postgres answers.
$ docker run --rm --network appnet -e PGPASSWORD=secret postgres:16 \psql -h db -U postgres -c 'SELECT version();'version---------------------------------------------------------------------------PostgreSQL 16.9 (Debian 16.9-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc ...(1 row)
That is the promise made concrete. No address touched the command line. The client found Postgres by name alone, and the same command keeps working after db is destroyed and rebuilt on a different address. Point your application's connection string at db and you get identical behaviour in production, with no service registry to run and no extra plumbing to maintain.
Publishing: reaching in from outside
Everything so far happened inside the network. Containers on appnet find each other by name with zero ports published. Publishing does a different job. It punches a hole from the host into a container using NAT (Network Address Translation, the trick that rewrites addresses as packets cross a boundary), so something outside Docker can get in. Keep it for traffic that genuinely arrives from the outside world. The flag -p host:container maps a single port, and docker port prints what is currently live. Port mechanics get a lesson of their own next; here it is the outward-facing half of the same story.
$ docker run -d -p 8080:80 --name web --network appnet nginx:1.27d41d8cd98f00b204e9800998ecf8427e1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d$ docker port web80/tcp -> 0.0.0.0:8080$ curl -sI localhost:8080 | head -1HTTP/1.1 200 OK
Names beat IPs inside user-defined networks
The old way of doing this was --link, which wrote entries into /etc/hosts and dumped environment variables into the container at start time. Both are snapshots. Neither one updates when the target container is recreated, which is precisely the moment you need them to. User-defined networks replaced all of that with a resolver at 127.0.0.11 that answers live, so a name written into config on Monday still finds the right container on Friday. Aliases cover the cases where one container honestly needs to answer to several names.
Keep the two directions apart in your head. The -p flag is for ingress, traffic arriving from outside the host. DNS names are for east-west traffic, the sideways kind between containers on the same network. A database almost never needs the first one. Publishing 5432 to 0.0.0.0 offers it to anything that can route to that host, and a defender reading docker port output should treat a published database port as a finding until somebody explains it. Attach the client to the same user-defined network instead and leave the port unpublished.
When a name refuses to resolve, check network membership before you touch application config. Two containers on different networks cannot find each other by name no matter how correct the connection string looks. Inspect both, compare the networks they are attached to, and you have usually found the problem inside a minute. In a Compose file (Docker Compose, the format that describes a multi-container app), name your networks and aliases explicitly rather than leaning on the defaults, because those defaults shift with the project name and the directory you happen to be sitting in.
Give the services other teams depend on stable aliases, and treat every connection pool as something that has to survive an address change. Most tickets filed as "DNS is broken" turn out to be a client holding on to an A record (the DNS record type that maps a name to an address) long past its expiry. Write down, per language, how long your web and database client libraries cache a lookup and what they do when a connection fails. That page is worth more at three in the morning than any diagram.
Treat a change to how a service is discovered the way you would treat a change to a firewall rule, and leave a trail. Record which aliases the container carried before and after, which host you ran the commands on, and the exact docker port line you saw when things were healthy. Recreating a container is cheap. Recreating it with flags nobody wrote down is not. If a teammate cannot repeat your steps from the ticket alone, the ticket is not finished yet.
Try this
Run these on a lab engine. Docker 24 or newer is fine. Read the sample output first so you know what a healthy result looks like before you lean on any of it in production.
$ docker network create appnet$ docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:16$ docker run --rm --network appnet alpine:3.20 nslookup dbName: dbAddress 1: 172.18.0.2 db.appnet$ docker run --rm --network appnet alpine:3.20 wget -qO- db:5432 || true# connection attempt proves name→IP; use psql healthchecks in real stacks# STATUS: DNS READY for service discovery on appnet
Takeaway
Container-to-container traffic should travel over a user-defined network using DNS names. Publishing belongs to traffic arriving from outside the host and nothing else. When a name will not resolve, look at which networks the two containers are attached to before you start rewriting connection strings.
--network-alias web to two different containers. What does Docker's embedded DNS do when something looks up web?db by name. You redeploy db, its address changes, embedded DNS starts serving the new one, and the app keeps hitting the old dead address until somebody restarts it. What is going on?