Docker Compose Networks and Ports: How Containers Talk

By Anders Vik ·

Docker Compose Networks: How Containers Actually Talk to Each Other

Two support tickets show up more than any others when a stack moves from a laptop to a server. The first: "my app gets connection refused talking to the database, and they're in the same compose.yaml." The second, worse one: "why can a stranger on the internet hit my Postgres instance." Both come from the same misunderstanding of how Docker Compose networks actually work, and neither is fixed by copying a networks: block from a tutorial without knowing what it does.

This article works backward from those two failures to the rules that prevent them. If you only need the short version, the site's own how-to-use-docker-compose guide covers the basics; this one goes deeper.

What Compose Sets Up Before You Write a Single Network Rule

Run docker compose up on any project and Compose does something you didn't ask it to: it creates a network. The name follows a pattern, <project-name>_default, where the project name comes from your directory (or an override set with --project-name or COMPOSE_PROJECT_NAME). Every service in the file gets attached to it automatically. You don't declare this network; it exists the moment your first service starts, according to Docker's own Compose networking documentation.

That default network does more than group containers. It registers each service's name with Docker's embedded DNS server, which sits at 127.0.0.11 inside every container on the network. This is why a service called db can be reached by other services simply by using the hostname db, no IP address required. There's no need for the old links: directive either; links predate embedded DNS, and if you see them in a tutorial written before roughly 2017, treat them as a fossil.

Here's the part that trips people up during a redeploy: container IP addresses are not stable. When Compose recreates a container (after a rebuild, a restart, a docker compose up with a changed image), it usually gets a new internal IP. The service name doesn't change, so any code that reconnects by name survives a redeploy, while code that cached an IP address does not. If your app hardcodes an IP anywhere for a database or cache connection, that's the bug waiting to happen.

services:
  web:
    build: .
    depends_on:
      - db
    environment:
      DATABASE_URL: postgres://db:5432/app
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

Nothing in that file declares a networks: block, and it doesn't need one. Both services land on the default project network automatically, web reaches db by name on port 5432, and neither is reachable from outside the host because no ports: key was published. That's the baseline every Compose file starts from.

Notice that POSTGRES_PASSWORD is pulled from a variable rather than typed into the file. That's not a networking concern, but the site's own guide to Compose environment variables covers how .env substitution keeps that password out of version control.

The three meanings of "localhost" in a Compose stack

This word means three different things depending on where you type it.

Inside a container, localhost refers to that container's own network namespace, nothing else. If your web service tries to reach the database at postgres://localhost:5432, it's asking its own container to serve Postgres, which it almost certainly isn't. This is the single most common cause of "connection refused" between two services that are both running fine. The fix is always the same: use the service name, not localhost, for container-to-container traffic.

From the host machine, localhost works correctly, as long as the container's port has been published. If you mapped 8001:5432 on the database service, then postgres://localhost:8001 on your laptop reaches the container.

From inside a container trying to reach something running on the host itself (a local dev API, a database outside Docker), localhost fails again for the same reason as the first case. The documented fix is host.docker.internal, a hostname Docker Desktop resolves automatically. On Linux Engine (not Desktop), you have to opt in with an extra_hosts entry:

services:
  web:
    build: .
    extra_hosts:
      - "host.docker.internal:host-gateway"

The host-gateway value resolves to the internal IP address of the host machine, a mechanism documented on Docker Desktop's networking documentation. One caveat: this resolution happens at container runtime, not during an image build, so don't expect it to work inside a RUN step in your Dockerfile.

docker compose ports vs expose: what each one actually does

These two directives get confused constantly, and only one of them does what most people assume both do.

DirectiveWhat it doesWho can reach the port
portsPublishes a container port to the host, binding it to a network interfaceAnything that can reach the host on that interface, including the public internet if unrestricted
exposeDocuments which port the container listens onNo change in reachability; other containers on the same network could already reach it

That second row surprises people, so it's worth being direct: expose doesn't lock anything down and doesn't open anything up. Per the Compose Specification, those ports "should not be published to the host machine," and any container on the same Docker network can already reach any port a neighbor is listening on, whether or not you wrote expose. The directive is closer to a code comment than a firewall rule: useful documentation for whoever reads your compose.yaml next, but not the security boundary some tutorials imply.

The same logic applies to the Dockerfile's EXPOSE instruction: it "doesn't actually publish the port." Only docker run -P or an explicit ports: mapping opens anything.

Docker compose port mapping uses HOST:CONTAINER short syntax, and this is where a nasty trap lives. Write ports: - 22:22 unquoted, and YAML's base-60 sexagesimal float parsing silently turns that into the number 1342 instead of the string "22:22". The Compose Specification is explicit that host-to-container port mappings should always be quoted:

services:
  ssh-proxy:
    image: some/ssh-image
    ports:
      - "22:22"
      - "8443:443"

If you skip the quotes on a port pair that happens to parse as a valid number, Compose won't error. It'll just do something you didn't intend, and you won't find out until the port doesn't respond.

Long syntax exists too, and it lets you control which host interface a port binds to:

services:
  db:
    image: postgres:16
    ports:
      - target: 5432
        published: 5432
        host_ip: 127.0.0.1

Left unset, host_ip defaults to 0.0.0.0, meaning every network interface on the host, not just loopback. That default is the root cause of the second ticket from this article's opening: a database meant to be a local dependency, reachable from any interface the host has, including a public one.

Who should reach what: a decision table

Most of the confusion around Compose networking disappears once you stop asking "which directive do I need" and start asking "who needs to reach whom."

Who's askingWhat they needWhat to write
A browser, hitting your app from outsideThe web service's port, publishedports: - "8080:80" on the web service only
The web service, reaching the databaseNothing published; the default network already connects themUse the service name in the connection string, e.g. db:5432
You, with a desktop SQL client, on your own machineA port bound to loopback onlyports: - "127.0.0.1:5432:5432"
Anyone on the open internet, reaching the database directlyNothing. Don't do this.No ports: entry on the database service at all
Two separate Compose projects on the same hostA shared network both projects joinAn external: true network referenced by both files
A container, reaching a service running on the host itselfA route to the host's gateway addressextra_hosts: ["host.docker.internal:host-gateway"]

Every networking decision in a Compose file reduces to one row of that table.

The port you publish is public

The default bind of 0.0.0.0 on published ports is documented behavior, not a bug, but it surprises people who assume a firewall is standing between their container and the internet. It usually isn't.

Docker manages its own iptables rules to make published ports work, and those rules sit in the NAT table, ahead of the chains that tools like ufw use to filter traffic. Docker's documentation states it without hedging: "Docker and ufw use firewall rules in ways that make them incompatible with each other," as covered in the packet filtering and firewalls page. A published container port can be reachable even while ufw reports it as blocked. This isn't theoretical; it's a long-running, still-open report on Docker's GitHub tracker (issue #690, filed in 2019).

Three mitigations actually work, per Docker's own guidance: bind sensitive services to 127.0.0.1 instead of 0.0.0.0, don't publish the port at all if nothing outside the container network needs it, or write your filtering rules into Docker's DOCKER-USER iptables chain. Disabling Docker's iptables management entirely (--iptables=false) is not a working option; it breaks container-to-container networking. To see what a published port looks like from outside your network, httpcheck.tools shows you the response the way the internet sees it, response headers included.

When you actually need a networks: block

The default network handles most stacks fine, which is why the honest answer to "do I need to declare networks" is usually no. Three situations change that answer.

The first is isolating a tier that shouldn't be reachable from certain other services in the same project. A database that only your backend should touch, not your admin dashboard or static file server, benefits from its own network with internal: true set, which, per the Compose Specification's networks reference, creates a network with no route to the outside at all.

services:
  web:
    build: .
    networks:
      - front
      - back
    ports:
      - "8080:80"
  admin:
    build: ./admin
    networks:
      - front
  db:
    image: postgres:16
    networks:
      - back

networks:
  front:
  back:
    internal: true

Here, admin can reach web but never db, because they don't share a network. And back can't reach the internet at all, because internal: true cuts external connectivity for every container on it, outbound as well as inbound. That db service will also want somewhere durable to keep its data; the site's own guide to Compose volumes covers named volumes versus bind mounts.

The second situation is a reverse proxy that lives in its own Compose project and needs to route to services in a different one. That requires an external: true network, created ahead of time with docker network create, that both projects reference by name (that proxy is also the natural place to set the security headers for every site behind it). The third is when you need a stable network alias more descriptive than the Compose service name, which you set with aliases:. Outside those three cases, a networks: block is extra YAML doing nothing the default network wasn't already doing for free.

docker compose network host: when host networking is the right call

Setting network_mode: host removes Docker's network namespace isolation for that service entirely. The container shares the host's network stack directly: no NAT, no userland proxy translating ports, and no separate IP address. That's also why it comes with hard restrictions. Per the Compose Specification, port mapping must not be used with network_mode: host (it causes a runtime error), and the networks: attribute is rejected too.

services:
  homeassistant:
    image: homeassistant/home-assistant:stable
    network_mode: host
    restart: unless-stopped
    volumes:
      - ./config:/config

Home Assistant's own installation guide ships this exact pattern, and the reason is instructive: device discovery protocols like mDNS and Avahi rely on multicast traffic that doesn't cross a bridge network cleanly. The same logic applies to a VPN or mesh networking container, or a workload where the small latency cost of Docker's userland proxy matters. Per Docker's host networking driver documentation, storage, process, and user namespaces stay isolated even in host mode; only networking is shared.

One fact worth correcting if you read older advice: host networking used to be Linux-only. Docker Desktop 4.34, released in September 2024, brought it to Mac and Windows as well, enabled under Settings, Resources, Network, and requiring a signed-in Docker account.

The tradeoff is real, though. Host mode gives up service-name DNS resolution, since there's no user-defined bridge network providing it. It also gives up the isolation that stops one container's port 8080 from colliding with another container's, or the host's own. Use it when you have a specific reason on that list, not as a default because "connection refused" was annoying to debug the normal way.

Troubleshooting connection refused and port collisions

Four commands cover almost every networking problem in a running Compose stack.

# See the resolved config Compose is actually using
docker compose config

# Confirm which containers are on the project's default network
docker network inspect <project-name>_default

# Confirm a service resolves another service's name from inside
docker compose exec web getent hosts db

# Find what's already holding a port before you blame Compose
docker ps --filter "publish=8080"

If getent hosts db comes back empty, the two services aren't on the same network, usually because one of them was pinned to a custom networks: list that doesn't include the other. If the name resolves but the connection still fails, check whether the app inside the container is listening on 0.0.0.0 or on 127.0.0.1. A server bound to its own loopback address is invisible even to other containers on the same network; it's a common default in dev servers that assume they're running directly on a laptop.

"Bind for 0.0.0.0:8080 failed: port is already allocated" almost always means a previous container, often from a renamed or orphaned project, still holds that port after a docker compose down that didn't fully clean up. The docker ps --filter publish= command above finds the culprit. On Windows, a port can also be reserved by the OS itself; netsh interface ipv4 show excludedportrange protocol=tcp shows those ranges.

Once you know which of these rules apply, let the Compose generator handle the YAML: it flags colliding host ports and unquoted port strings in its conflicts box before they become a 2 a.m. ticket.

Frequently Asked Questions

Do I need to define a network in Docker Compose?

No, not by default. Compose creates a project-wide network automatically and attaches every service to it, giving you name-based DNS resolution between services with zero configuration. Write your own networks: block only when you need to isolate a tier from other services in the same project, connect services across two separate Compose projects, or assign a custom network alias.

What is the difference between ports and expose?

ports publishes a container's port to the host machine, making it reachable from outside the Docker network, including the public internet if the host is exposed to it. expose only documents which port a container listens on; it changes nothing about reachability, because containers on the same network can already reach each other's listening ports. Treat expose as a note for the next person reading your compose.yaml, not as an access control mechanism.

How do containers reach each other by name?

Compose attaches every service in a file to the same user-defined network by default, and that network runs an embedded DNS resolver at 127.0.0.11 inside each container. Each service's name gets registered with that resolver automatically, so a service called db can be reached at the hostname db from any other service on the same network, with no IP addresses or links: entries required.

When should I use network_mode host?

Use it when a container genuinely needs the host's own network stack: device discovery protocols like mDNS or Avahi that don't cross a bridge network, VPN or mesh networking software, or workloads where Docker's userland proxy adds a measurable performance cost. It's available on Linux, and since Docker Desktop 4.34 (September 2024), on Mac and Windows too. It comes at a real cost: no service-name DNS, no ports: mappings (Compose rejects the combination as a runtime error), and no networks: block, since host mode and custom networks can't coexist in the same service.