“It’s Always DNS” Is Wrong. In Docker, It’s Always the Default Bridge.
Why your container can’t find the database, what EXPOSE actually does (nothing), and the port-publishing default that quietly changed under everyone in Docker 28.
There’s a support conversation I have had, by my rough count, somewhere north of two hundred times. It goes like this:
“My app can’t connect to Postgres.”
“How is it addressing it?”
“postgres:5432."
"How did you start them?"
"docker run -d --name postgres ...anddocker run -d --name app ..."
And there it is. Both containers are on the default bridge network, where container names do not resolve, and no amount of staring at connection strings will fix it. The fix is one flag. The understanding is worth more than the flag, so let’s do the understanding.
The default bridge is a legacy artifact, and you should treat it like one
When you docker run without --network, your container joins a pre-existing network called bridge. It gets an IP, it can reach the internet, it can reach other containers on that same bridge by IP. What it cannot do is resolve their names.
The docs are unambiguous about this: “Containers on the default bridge network can only access each other by IP addresses, unless you use the --link option." And about --link: it's legacy, and "These links need to be created in both directions, so you can see this gets complex with more than two containers which need to communicate."
Create your own network and the behaviour changes entirely — “User-defined bridges provide automatic DNS resolution between containers.”
docker network create appnet
docker run -d --name db --network appnet postgres:17
docker run -d --name api --network appnet myapi
# inside api: psql -h db ← worksThat’s the whole fix. One docker network create and a --network on each container.
My actual opinion: never put a container on the default bridge on purpose. Not “prefer user-defined networks” — never. The default bridge has a second problem that’s worse than the DNS thing, and the docs say it out loud: “All containers without a --network specified, are attached to the default bridge network. This can be a risk, as unrelated stacks/services/containers are then able to communicate." Your side project's Redis and your client's staging database, sitting on the same L2 segment, because you forgot a flag. The docs call the default bridge "considered a legacy detail of Docker." Believe them.
(While I was rereading that page I found a limit I genuinely did not know: “Bridge networks become unstable and inter-container communications may break when 1000 containers or more connect to a single network.” A kernel limitation, not a Docker one. You will probably never hit it.)
Where the name resolution actually happens
Not in /etc/hosts. Not in some magic Docker-aware libc. There's a real DNS server, at a real address, and you can query it.
Docker runs an embedded DNS resolver at 127.0.0.11 and injects it into /etc/resolv.conf for containers on user-defined networks. Containers on the default bridge instead "receive a copy of" the host's resolv.conf — which is exactly why names don't resolve there. There is no resolver in the path to resolve them.
Two details worth pocketing:
- There’s no IPv6 version. Per the docs: “There is no IPv6 equivalent; the IPv4 address works even in IPv6-only containers.” If you’ve ever hardcoded a nameserver in an app config and broken container DNS, this is the address you needed.
- On user-defined networks the embedded server “queries upstream servers in order and stops after a successful response or an NXDOMAIN response” — subtly different failure semantics than your resolver library’s own strategy. It matters when a search-domain-heavy corporate DNS setup is in play.
And you can add extra names: --alias, or --network-alias, gives a container additional resolvable names on that network. This is how you run db and have it also answer to postgres-primary during a migration, without touching a single connection string.
dig db from inside a container, and look at the SERVER: line. Seeing 127.0.0.11#53 answer is the moment the whole thing stops being magic.
Networks are real isolation, and a container can sit on several
Two user-defined networks are genuinely separate. A container on net-a cannot reach a container on net-b, by name or by IP. That's not a firewall rule you configured; that's just how it is.
The thing people underuse is that membership isn’t exclusive:
docker network create frontend
docker network create --internal backenddocker run -d --name db --network backend postgres:17
docker run -d --name api --network backend myapi
docker network connect frontend api
docker run -d --name proxy --network frontend -p 443:443 nginxRead that topology. The API sits on both networks. The database sits on exactly one, and that one is --internal, which means no outbound route to the wider world at all. There is no host port anywhere near the database, and there is no path from the proxy to it. You just built a DMZ with four commands and zero iptables.
Get Vin’s stories in your inbox
Join Medium for free to get updates from this writer.
I bring this up because I keep seeing single-network compose stacks where every service can reach every other service, and then a lot of anxiety about securing them. Segment first. It’s free.
Publish vs. expose: one of them does nothing
This is the other half of the perennial confusion, and it’s worth being blunt.
EXPOSE 8080 in a Dockerfile opens nothing. It is metadata. A comment with a schema. Its only functional effect is that docker run -P (capital P) will pick ephemeral host ports for the exposed ones. That's it. An image with no EXPOSE line is just as reachable as one with it.
-p 8086:80 is the thing that does something: it creates a host→container forward, so traffic arriving at your machine's port 8086 reaches the container's port 80.
The corollary that saves the most time in practice: container-to-container traffic needs no published ports whatsoever. Two containers on a shared network talk directly on the container port. If your compose file publishes Postgres on 5432 so that “the app can reach it,” delete that line — the app was never using it, and you’ve been exposing your database to the LAN for nothing. Per the docs, published ports are about reaching things from outside the host (and from containers on other bridge networks): “Use the --publish or -p flag to make a port available outside the host."
Where -p gets genuinely dangerous
-p 8086:80 binds 0.0.0.0. Everyone on your network can reach it. -p 127.0.0.1:8086:80 binds loopback, so only your own machine can. That difference is a coffee-shop-wifi-shaped hole in a lot of dev setups.
Worse, on Linux: Docker’s published ports bypass your host firewall. Not “can conflict with” — bypass. The docs, verbatim: “When you publish a container’s ports using Docker, traffic to and from that container gets diverted before it goes through the ufw firewall settings.” The mechanism is that Docker does its work in the nat table, so "packets are diverted before it reaches the INPUT and OUTPUT chains that ufw uses."
So ufw deny 5432 plus -p 5432:5432 equals an open database, and a firewall config that lies to you when you audit it. If you take one operational habit from this post: bind published ports to 127.0.0.1 unless you have specifically decided otherwise, and put a real reverse proxy in front of anything that needs to be public.
The default that changed under all of us (Docker 28)
Here’s my favourite kind of fact — the kind that makes half the tutorials on the internet wrong.
Before Docker Engine 28, on a Linux host, a container’s IP could be reachable from other machines on your LAN even for ports you never published. Not by accident of Docker, exactly, but as a consequence of IP forwarding plus a permissive FORWARD chain plus another host on the subnet having a route to the container network. Unpublished didn't mean unreachable; it meant "not reachable via the host's port, but possibly reachable at the container's own IP if the network conditions line up." In a multi-tenant LAN, anyone on the same RFC1918 subnet who could guess a container IP could knock on ports you thought were private.
Docker 28 changed the default: unsolicited inbound traffic to a container’s IP is now dropped unless that port was explicitly published. Local connections from the Docker host itself still work. Published ports still work exactly as before. And if you were depending on the old behaviour, you now have to opt back into it (ip-forward-no-drop, or a nat-unprotected network).
Two things I want you to take from this. First: if you read a networking blog post written before 2025, its threat model is out of date. Second: this is the third Docker default I’ve had to re-learn. Check the current docs before you trust the shape of your own memory. I write these posts partly to force myself to do exactly that.
About --network host on a Mac
Old advice: “host networking doesn’t work on Docker Desktop, it’s Linux-only, stop trying.”
That advice is now stale, and I had to correct my own notes while writing this. Docker Desktop 4.34 and later does support host networking on Mac and Windows — but it’s opt-in (Settings → Resources → Network → “Enable host networking”), and it comes with real caveats worth quoting:
- “The host network feature of Docker Desktop works on layer 4. This means that unlike with Docker on Linux, network protocols that operate below TCP or UDP are not supported.” So no raw sockets, no ICMP games, no
tcpdump-style tricks that assume L2/L3 parity with Linux. - “Processes inside the container cannot bind to the IP addresses of the host.”
- It doesn’t work with Enhanced Container Isolation, which the docs justify neatly: those two features “contradict each other.”
- Linux containers only.
So: it exists, it’s usable, and it is not the same primitive as --network host on Linux. If you're following a Linux tutorial that leans on host networking for something below L4, it'll still let you down — just for a different reason than before.
The five-line summary
- User-defined network, always. The default bridge has no DNS and no isolation, and it’s legacy.
- Names resolve via the embedded DNS at
127.0.0.11, and only on user-defined networks. EXPOSEis a comment.-pis the mechanism. Container-to-container needs neither.- Bind published ports to loopback by default; on Linux they route around ufw entirely.
- Segment with multiple networks and
--internal. It costs nothing and removes whole classes of exposure.










