
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 from | Correct host |
|---|---|
| Your browser to the API | localhost if the port is published |
| API to Postgres | db |
| API to Redis | redis |
| One container to another | The 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:
| Service | Networks | Can talk to |
|---|---|---|
web | frontend | api |
api | frontend, backend | web, db |
db | backend | api |
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:
| Field | Publishes to host | Main use |
|---|---|---|
ports | Yes | Access from your machine or outside |
expose | No | Document 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
webpublishes a port to the host. apitalks todbandredisusing service names.dbandredisare not onfrontend, sowebcannot 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!