0x55aa
← Back to Blog

Docker Networking: Why Your Containers Can't Talk to Each Other 🐳🌐

•7 min read

Docker Networking: Why Your Containers Can't Talk to Each Other 🐳🌐

True story: I once filed a GitHub issue on a database driver repo because I was convinced there was a bug in the connection pooling. The bug was me. I was passing localhost as the database host from inside a container. Spent four hours debugging what took thirty seconds to fix once I understood Docker networking.

If you've ever seen Connection refused when your app container tries to reach your database container — and they're sitting right next to each other in a docker-compose.yml — this post is for you.

The Lie That Is localhost Inside Docker 🧱

Here's the mental model that trips everyone up.

On your laptop, localhost means your machine. Your Node.js app, your MySQL server, your Redis instance — they all live on the same machine, so they talk over 127.0.0.1 without thinking about it.

When you containerize that same app, each container gets its own isolated network namespace. Your api container's localhost is the api container. Full stop. It has no idea your db container exists at 127.0.0.1 — because 127.0.0.1 inside db is the db container itself.

This is correct behavior. It's just not obvious until it punches you in the face.

āŒ What you think happens:

  [your machine] -- localhost --> [api container] -- localhost --> [db container]

āœ… What actually happens:

  [api container: 127.0.0.1 = api]
  [db container:  127.0.0.1 = db]
  (they are isolated. they cannot see each other by default)

Docker's Default Bridge Network šŸŒ‰

When you run containers without specifying a network, Docker attaches them to the default bridge network. Containers on the bridge network can reach each other — but only by IP address, and those IPs are dynamic and assigned by Docker at runtime.

# See what's on the default bridge network
docker network inspect bridge

# You'll see something like:
# "Containers": {
#   "abc123...": { "Name": "my_api", "IPv4Address": "172.17.0.2/16" },
#   "def456...": { "Name": "my_db",  "IPv4Address": "172.17.0.3/16" }
# }

So technically you could hardcode 172.17.0.3 in your app to connect to the database. Docker taught me the hard way that this is a terrible idea — restart a container and it might get a different IP. I've been paged at midnight because exactly this happened in a dev environment after someone ran docker-compose down && docker-compose up.

The default bridge network also doesn't give you DNS. You can't say "connect to db" — you have to know the IP. This is why the default network exists mostly for quick experiments and should never be what you use for real work.

Custom Networks: The Right Way šŸš€

Create a named network and attach your containers to it. Custom networks give you automatic DNS resolution — containers can reach each other by name.

# Create a custom network
docker network create my-app-network

# Run containers on it
docker run -d --name db --network my-app-network postgres:16-alpine
docker run -d --name api --network my-app-network -e DB_HOST=db my-api:latest

Notice DB_HOST=db. From inside the api container, Docker resolves db to the IP of the container named db. It just works. No hardcoded IPs, no DNS servers to configure.

Docker Compose Networking: Automatic and Invisible āš™ļø

Here's why docker-compose is magic: it creates a custom network for your entire stack automatically, and every service gets a DNS entry matching its service name.

# docker-compose.yml
version: "3.9"

services:
  api:
    build: .
    ports:
      - "3000:3000"         # expose to HOST on port 3000
    environment:
      - DB_HOST=db          # 'db' resolves to the db container
      - REDIS_HOST=redis    # 'redis' resolves to the redis container
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_PASSWORD=secret
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 5s
      retries: 5

  redis:
    image: redis:7-alpine

When you run docker-compose up, Docker creates a network called <project-name>_default. Every service — api, db, redis — joins it automatically. From inside api, you connect to db:5432 and redis:6379. Done.

The part that confused me for weeks: ports in docker-compose is about exposing to your host machine, not about container-to-container communication. Your api container doesn't need db to have ports defined to reach it. Internal container communication goes directly through the network, no port mapping needed.

# This is WRONG (and unnecessary for internal access):
db:
  image: postgres:16-alpine
  ports:
    - "5432:5432"   # ← only needed if your HOST machine needs to reach db directly

# This is RIGHT for container-to-container:
db:
  image: postgres:16-alpine
  # No ports needed. api can still reach db:5432 internally.

Exposing unnecessary ports is also a security risk. A CI/CD pipeline that saved our team: we caught that a developer had exposed Redis on 0.0.0.0:6379 in production docker-compose. It was publicly accessible with no auth. Remove the ports for services that only need to talk to other containers.

Connecting FROM a Container TO Your Host šŸ–„ļø

Sometimes you need the reverse: a container needs to reach a service running on your host machine (not another container). Maybe you're running a local mock server or a legacy service.

Use host.docker.internal:

# From inside any container:
curl http://host.docker.internal:8080

# In your app config:
LEGACY_API_HOST=host.docker.internal
LEGACY_API_PORT=8080

This is available automatically on Docker Desktop (Mac/Windows). On Linux, you might need to add:

# docker-compose.yml
services:
  api:
    extra_hosts:
      - "host.docker.internal:host-gateway"

Multiple Networks: Isolation Done Right šŸ”

For production setups, after countless deployments I've learned to segment networks. Not every service needs to talk to every other service.

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend
      - backend

  api:
    build: .
    networks:
      - backend

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

networks:
  frontend:    # nginx talks to the outside world
  backend:     # api and db live here, isolated from frontend

With this setup, your database is on backend only. Even if someone compromises nginx, it can't directly reach db. The blast radius shrinks significantly. This is the same pattern I use for production Laravel deployments on AWS ECS.

Common Pitfalls at a Glance 🪤

Pitfall #1: Using localhost inside a container Use the service name or container name instead.

Pitfall #2: Expecting order guarantees from depends_on depends_on waits for the container to start, not for it to be ready. Use condition: service_healthy with a proper healthcheck.

Pitfall #3: Forgetting that port mapping is host-to-container, not container-to-container Internal services don't need ports defined. Exposing them is optional (and risky).

Pitfall #4: Connecting to db before it's ready Databases need a few seconds to initialize. Always add a health check and retry logic in your app's startup sequence.

Before vs After šŸ“Š

Scenario Wrong Approach Right Approach
App connects to DB DB_HOST=localhost DB_HOST=db (service name)
Accessing Redis REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://redis:6379
DB accessible from host Hardcode IP Expose ports: "5432:5432" only when needed
Container → Host service Can't reach it host.docker.internal
Isolating sensitive services Single network for all Multiple networks, principle of least privilege

TL;DR šŸŽÆ

  • localhost inside a container refers to that container only, not the host or sibling containers
  • Custom networks (and docker-compose default networks) give you DNS by service name
  • Connect containers using their service name as the hostname: DB_HOST=db
  • ports exposes services to the host machine — internal container-to-container traffic doesn't need it
  • Use host.docker.internal to reach services running on your actual machine
  • Multiple networks = blast radius control when things go sideways

Docker networking feels like magic once it clicks. And it's one of those things where understanding it deeply saves you from hours of "why is connection refused" debugging at the worst possible time.


Got a Docker networking war story? I'd love to hear it. Find me on LinkedIn — I still laugh (and cringe) about the localhost incident.

Want to see real docker-compose setups? Check out my GitHub for production-grade configurations.

Now go grep your codebase for localhost in your Docker configs. I guarantee you'll find something. 🐳