Francisco Javier Palacios PérezFco. Javier Palacios Pérez
Software Developer
Docker Compose networking: networks, services, and isolation

Docker Compose networking: networks, services, and isolation

Docker Compose networking: networks, services, and isolation

Docker Compose networking: networks, services, and isolation

There is a very Docker Compose moment: Postgres is running, the API is running, the logs look reasonable, and the API still says it can’t connect to localhost:5432.

You check docker compose ps. Postgres is Up. You check the docker-compose.yml. The port is there. You stare at the screen with that specific mix of suspicion and tiredness that only appears when a tool is technically telling the truth in the least helpful way possible.

Is Postgres down? No. Is the port wrong? Maybe, but probably not. Is localhost a trap with good branding? Exactly.

In Docker Compose, each container lives in its own little network world. Once you understand that, the rest stops feeling like dark magic with YAML.

Compose’s default network

When you run docker compose up, Compose automatically creates a network for the project. If your directory is called myapp, that network is usually named something like myapp_default.

All services in the docker-compose.yml join that network by default.

services:
  api:
    image: myapp/api

  db:
    image: postgres:16-alpine

Even though you didn’t write networks: anywhere, Compose did some work for you:

docker compose up -d
docker network ls

You’ll see a network like this:

NETWORK ID     NAME            DRIVER    SCOPE
abc123def456   myapp_default   bridge    local

Inside that network, services can find each other by service name. The API can connect to Postgres using db as the hostname.

postgresql://myapp:secret@db:5432/myapp

Don’t use the container IP. Container IPs can change when services are recreated. Using container IPs in Compose is like writing down where you parked three weeks ago on a napkin: technically useful for a few minutes.

Use service names.

localhost inside a container is not your machine

This is the main trap.

From your machine, localhost means your machine.

From inside a container, localhost means that container.

services:
  api:
    image: myapp/api
    environment:
      DATABASE_URL: postgresql://myapp:secret@localhost:5432/myapp

  db:
    image: postgres:16-alpine

That is wrong for container-to-container communication. The API will look for Postgres inside the API container itself. Spoiler: it is not there. If it were, you’d have another problem, probably with a suspicious architecture smell.

The correct version uses the service name:

services:
  api:
    image: myapp/api
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp

  db:
    image: postgres:16-alpine

Mental model:

Connecting fromCorrect host
Your browser to the APIlocalhost if the port is published
API to Postgresdb
API to Redisredis
One container to anotherThe service name

Don’t stress if this feels counterintuitive at first. localhost has spent years looking innocent while ruining entire afternoons with a smile.

ports is not how containers talk to each other

Another classic confusion: thinking you need to publish a port so another container can connect to it.

You don’t.

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp

  db:
    image: postgres:16-alpine

The ports: "3000:3000" line publishes the API port to your machine. It lets you open http://localhost:3000 from your browser.

But the API does not need Postgres to have ports: "5432:5432" to connect to db:5432. Both services are already on the internal Compose network.

This matters for security. If you publish Postgres like this:

services:
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"

You’re exposing the database to the host. In development that can be useful if you want to connect with TablePlus, DBeaver, local psql, or another external tool. But don’t do it “just in case”. “Just in case” in infrastructure is a polite way of saying “I’ll leave this door open and see what happens”.

If only the API needs to talk to the database, don’t publish the port.

Custom networks: separate rooms

The default network is fine for small projects. All services can see each other and life goes on.

But in a more realistic application, you might have:

  • web: frontend or reverse proxy.
  • api: backend.
  • db: database.
  • redis: cache.

There is no reason for web to connect directly to db. The frontend talks to the API. The API talks to the database. The database should not be standing in the lobby waving at the entire building.

You can model that with custom networks:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    image: myapp/api
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    networks:
      - backend

networks:
  frontend:
  backend:

Now the rules are clear:

ServiceNetworksCan talk to
webfrontendapi
apifrontend, backendweb, db
dbbackendapi

web and db do not share a network, so they cannot resolve each other by name or connect directly. This does not replace authentication, firewalls, permissions, or common sense, but it reduces surface area. And reducing surface area is one of those boring things that prevents interesting incidents. Interesting incidents are overrated.

expose vs ports

Compose also has expose:

services:
  api:
    image: myapp/api
    expose:
      - "3000"

expose documents that a service listens on a port for other services on the network, but it does not publish that port to your machine.

In practice, services on the same network can connect to a container port even if you don’t write expose. That’s why many teams don’t use it. It can be useful as documentation inside the docker-compose.yml, but don’t confuse it with security.

The important difference:

FieldPublishes to hostMain use
portsYesAccess from your machine or outside
exposeNoDocument internal ports

If you want to open something from the browser, use ports. If only containers talk to each other, you usually don’t need anything.

External networks

Sometimes you want several Compose projects to share a network. For example, a common reverse proxy (traefik, nginx, caddy) routing multiple local apps or services on the same server.

First create the network manually:

docker network create shared-proxy

Then declare it as external:

services:
  app:
    image: myapp/api
    networks:
      - shared-proxy

networks:
  shared-proxy:
    external: true

external: true means: “Compose, don’t create this network; it already exists, use it.”

If the network doesn’t exist, Compose fails. And that’s good. Better to fail than silently create a new network and leave you wondering for half an hour why the proxy can’t see your app. Docker already has enough material to confuse you without extra help.

Network aliases

By default, each service resolves by its name: api, db, redis.

Sometimes you want additional names. That’s what aliases are for.

services:
  api:
    image: myapp/api
    networks:
      backend:
        aliases:
          - api-service
          - internal-api

  worker:
    image: myapp/worker
    networks:
      - backend

networks:
  backend:

From worker, these hostnames point to the api service:

api
api-service
internal-api

Use this carefully. An alias can help when a legacy application expects a specific hostname. But if you start adding three names per service “for convenience”, you’ll end up with a tiny home-made DNS system inside YAML. Like having five nicknames for the same person in a group chat: funny until someone new tries to understand the conversation.

Complete example

A reasonable stack with reverse proxy, API, database, and cache:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    build: ./api
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    networks:
      - backend

volumes:
  db-data:

networks:
  frontend:
  backend:

Notice three details:

  • Only web publishes a port to the host.
  • api talks to db and redis using service names.
  • db and redis are not on frontend, so web cannot talk to them directly.

This does not turn your app into Fort Knox. But you’re no longer putting every service in the same room with a sticky note that says “be nice”.

Practical challenge

Take the docker-compose.yml from the previous tutorial and split it into two networks: frontend and backend. The API should be on both. Postgres and Redis should only be on backend. If you have a web or proxy service, put it only on frontend and publish only its port.

Then verify that your internal URLs use service names (db, redis, api) and not localhost. If you find a localhost inside an environment variable used between containers, don’t look at it fondly: change it.


You now have the right mental map: Compose creates a default network, services discover each other by name, ports is for reaching your machine, and custom networks let you isolate what should not talk directly. In the next tutorial we’ll close the Compose module with best practices: environment-specific files, secrets, resource limits, logging, and the small details that separate a useful docker-compose.yml from one that looks written during a power outage.

Never stop coding!