log in
consulting hosting industries the daily tools about contact

Docker Compose named networks and the DNS trap that will bite you

The default bridge network in Docker Compose looks fine until you add a second Compose project and nothing can talk to anything. Here's what's actually happening.

I've debugged this exact problem three times in the last two years, for three different projects, and every time the root cause was the same thing: assuming Docker Compose's default bridge network behaves like a named network. It does not. The difference is subtle until it isn't, and when it breaks, it breaks in a way that looks like a DNS problem, a firewall problem, or application misconfiguration - anything but the obvious culprit.

What the default network actually gives you

When you write a docker-compose.yml and don't specify any networks, Compose creates a bridge network for you automatically. It's named something like myproject_default. Services on that network can reach each other by service name. That part works. You can curl http://app:8000 from inside a nginx container and it resolves. Fine.

The trap is this: that auto-created network is scoped entirely to that Compose project. The moment you want a container from a second Compose file - or a standalone container - to reach a service in the first project, you're stuck. The names don't resolve. You can't join the auto-created network by name from another file because the name is generated at runtime and not stable across environments. And even if you hard-code it, external containers don't get DNS entries on networks they didn't originate from.

I hit this hard last year on a project for a Seattle-area print management company. They had one Compose file for their web app (Laravel, queue workers, scheduler) and a separate one for a shared utility container running a PDF rendering service. Both lived on the same host. The web app needed to make HTTP calls to the renderer. Simple enough on paper. But the renderer was invisible to the web app by hostname. Every attempt to resolve renderer inside the app container returned nothing.

The actual DNS mechanism

Docker's embedded DNS server (at 127.0.0.11 inside containers) only resolves hostnames for containers that share a network with the requesting container and were started as part of the same Compose project or explicitly added to a named network.

This is the part nobody mentions clearly in the docs. The DNS is not host-wide. It's network-scoped. Two containers can be on the same physical host, both technically on bridge networks, and be completely invisible to each other by name.

Verify it yourself. Spin up two minimal Compose projects:

# project-a/docker-compose.yml
services:
  app:
    image: alpine
    command: sleep infinity
# project-b/docker-compose.yml
services:
  renderer:
    image: alpine
    command: sleep infinity

Start both with docker compose up -d in each directory. Then:

docker exec -it project-a-app-1 ping renderer

You'll get ping: bad address 'renderer'. The container exists. The host can see it with docker ps. But from inside project-a's network, it doesn't exist.

The fix: explicit named networks

The solution is to define a named network explicitly and attach both projects to it. Named networks are created and managed independently of any Compose project. Any Compose file can attach to them, and containers on a named network get DNS entries that are visible to all other containers on that same network.

Here's what the setup looks like in practice:

# Create the shared network once, manually or in a bootstrap script
docker network create shared-services

Then in each Compose file, declare the network as external and attach the relevant services:

# project-a/docker-compose.yml
services:
  app:
    image: php:8.3-fpm
    networks:
      - default
      - shared

networks:
  default: {}
  shared:
    external: true
    name: shared-services
# project-b/docker-compose.yml
services:
  renderer:
    image: my-renderer:latest
    networks:
      - shared

networks:
  shared:
    external: true
    name: shared-services

Now from inside project-a-app-1, ping renderer resolves. The DNS server sees both containers because they share the shared-services network.

In the Laravel app I was fixing, the Http facade call that had been silently timing out just... worked:

$response = Http::timeout(10)->post('http://renderer/pdf', [
    'template' => $template,
    'data' => $payload,
]);

No IP addresses, no host entries, no nginx proxy hacks. Just the service name.

The gotchas that will still get you

Network creation order. If you try to bring up a Compose project that references an external network that doesn't exist yet, Compose will error out immediately. You need the network created first. On a fresh environment (new dev machine, new server) you need that docker network create step in your setup docs or your bootstrap script. I keep a make up target that handles this:

up:
	docker network inspect shared-services > /dev/null 2>&1 || docker network create shared-services
	docker compose up -d

Service name collisions. DNS on a shared network resolves by service name. If project-a has a service named redis and project-b also has a service named redis, you have a conflict. The DNS resolution becomes unpredictable. Name your services specifically - app-redis, renderer-redis, whatever. Don't rely on generic names when sharing a network.

The default network is still useful, keep it. I keep services on both their project's default network and the shared network when I need inter-project communication. The default network is fine for internal project traffic (app talking to its own database, for example). You don't need to route everything through the shared network. Only the services that need to be reachable cross-project need to join it. In the example above, the renderer doesn't need to be on a default network at all since nothing in its own project talks to it - it just receives inbound calls from other projects.

Healthchecks don't cross networks. If you define a depends_on with a healthcheck condition, that condition check happens inside the Compose project. It won't wait for a service in another project to be healthy. You're on your own for cross-project startup ordering - usually retry logic in the application itself is the right answer here, not trying to get Compose to handle it.

Inspect first, guess second. When something isn't resolving, docker network inspect shared-services will show you exactly which containers are attached. That command has saved me 20 minutes of wrong debugging more than once.

docker network inspect shared-services --format '{{range .Containers}}{{.Name}} {{end}}'

If the container you're trying to reach isn't in that list, the DNS won't find it. Full stop.

When I reach for named networks

Any time I have more than one Compose file on a host that need to talk to each other, named networks are the only sane approach. That's the primary use case.

I also use them when I want to share infrastructure services (a Redis instance, an internal metrics collector, a mail catcher in dev) across multiple applications on a development machine. One docker-compose.yml for infrastructure, separate files per application, all joined to a named network. Works cleanly.

For a single self-contained application - one Compose file, all services in it - the default bridge is fine. Don't add complexity you don't need. If it never needs to talk cross-project, let Compose manage the network automatically.

I wouldn't use named networks as a substitute for proper service discovery if you're running anything resembling a cluster or scaling beyond a single host. At that point you're into Swarm or Kubernetes territory and their own DNS mechanisms take over. Docker Compose named networks are a single-host tool.

Closing

This is one of those things that's completely obvious once you understand that Docker's DNS is network-scoped and not host-scoped, but nothing in the getting-started docs emphasizes it. You just assume names resolve and then one day you have two Compose projects on the same box and the wheels come off. Spend 20 minutes setting up named networks correctly the first time you need cross-project communication. The alternative is spending an afternoon staring at curl: (6) Could not resolve host and blaming your application.

Related

Need help shipping something like this? Get in touch.