docker

Three Healthy Docker Containers. One Broken Application

Learn how to troubleshoot Docker networking issues when containers are running but services can't communicate. Understand Docker DNS, bridge networks, localhost mistakes, and practical fixes.

You have spent more than an hour setting up Docker Compose for your new project. It might be a three-tier application with Nginx, a Node.js API, and MySQL — or anything really. You run docker compose up, watch the logs scroll past, and see all three containers in the running state.

Docker Desktop confirms it. Everything is green.

You open the browser and hit the API endpoint. It fails.

You check the logs:

code
Error: connect ECONNREFUSED 127.0.0.1:3306

The database, API, and Nginx are all running. But the application is broken, and nothing in the error message tells you why.

This is what most beginners face while working on tasks like deploying a three-tier application via Docker Compose and making all containers communicate with each other.

In this article we will walk you through the entire thing — how to set up proper communication between containers, how to troubleshoot Docker networking issues, and how to prevent this error from ever happening again.

Three-tier application architecture: Browser → Nginx → Node.js API → MySQL through the Docker bridge network


Let's Rebuild the Incident

Let's build a minimal e-commerce API called ShopWave. The communication architecture is simple:

code
Browser
Nginx (port 80)
Node.js API (port 3000)
MySQL (port 3306)

This API exposes a single endpoint: GET /products. It connects to MySQL on startup. If the connection fails, the process exits.

The project structure:

code
shopwave/
├── docker-compose.yml
├── api/
│ ├── Dockerfile
│ ├── package.json
│ └── index.js
├── nginx/
│ └── nginx.conf
└── mysql/
└── init.sql

Setting Up the Three-Tier Application

Here is the initial setup exactly as many developers write it the first time.

docker-compose.yml

yaml
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- api
api:
build: ./api
environment:
DB_HOST: localhost
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: shopwave
DB_NAME: shopwave
depends_on:
- mysql
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: shopwave
MYSQL_DATABASE: shopwave
volumes:
- ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql

api/index.js

javascript
const express = require('express');
const mysql = require('mysql2');
const app = express();
const db = mysql.createConnection({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
db.connect((err) => {
if (err) {
console.error('[DB] Connection failed:', err.message);
process.exit(1);
}
console.log('[DB] Connected to MySQL successfully');
});
app.get('/products', (req, res) => {
db.query('SELECT * FROM products', (err, results) => {
if (err) return res.status(500).json({ error: err.message });
res.json(results);
});
});
app.listen(3000, () => console.log('[API] Running on port 3000'));

mysql/init.sql

sql
CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
INSERT INTO products (name) VALUES ('Laptop'), ('Mouse'), ('Keyboard');

nginx/nginx.conf

nginx
server {
listen 80;
location / {
proxy_pass http://api:3000;
}
}

Now spin everything up:

bash
docker compose up --build

The screenshot below shows all three containers in the running state.

docker compose up output showing nginx, api, and mysql containers running


Troubleshooting the Bug

Did you spot the bug in the compose file above?

Look at this section:

yaml
environment:
DB_HOST: localhost

This is the bug. If you are new to Docker Compose you might feel it is correct and reasonable. You connect to MySQL using localhost all the time in local development. Why would Docker be any different?

Run this to see all container statuses:

bash
docker ps

docker ps output showing all containers as healthy and running

Everything looks healthy. Now hit the API:

bash
curl http://localhost/products

You will get a 502 Bad Gateway from Nginx because the API request fails. Or the API container exits immediately. Check the logs:

bash
docker logs shopwave-api

docker logs shopwave-api showing ECONNREFUSED 127.0.0.1:3306 connection error

We know all container services are up and running. But the API cannot reach the database.


Why Was This Happening?

Before understanding the answer, let's go through the questions most engineers ask at this point.

"Is MySQL actually running?"

bash
docker ps

Yes. The MySQL container is Up.

"Are the ports wrong?"

Review the docker-compose.yml. MySQL is not even exposing a host port — it doesn't need to, since the API is supposed to talk to it internally. The configuration looks correct.

"Is there a startup race condition?"

Possibly, but depends_on is set. And the error says ECONNREFUSED, not a timeout. Something is actively refusing the connection.

"Is it a credentials issue?"

No — ECONNREFUSED means the TCP connection was rejected before authentication even started.

"Is Docker networking broken?"

bash
docker network ls

docker network ls showing shopwave_default bridge network

Docker created a network called shopwave_default. The containers are on it.

bash
docker network inspect shopwave_default

docker network inspect output showing each container's IP address on the bridge network

Every container has its own IP address on the network. Docker networking is functioning fine.

So what is wrong here?


The Hidden Truth About localhost Inside a Container

Here is what most Docker tutorials skip past without explaining it clearly.

Inside a container, localhost refers to that container — and only that container.

Not the host machine. Not Docker Desktop. Not other containers. Just itself.

This is because each container has its own isolated network namespace. Think of it as every container being its own tiny computer with its own loopback interface.

code
API Container
localhost → 127.0.0.1 → API Container itself
MySQL Container
localhost → 127.0.0.1 → MySQL Container itself

Diagram showing API container and MySQL container each having their own isolated localhost — the API's localhost points to itself, not to MySQL

So when the API tries to connect to localhost:3306, it is looking for MySQL running inside the API container itself. There is no MySQL there. Port 3306 is not open. The OS-level TCP stack says: connection refused.

MySQL is running — just in a completely different network namespace that localhost inside the API container cannot see.

This is the mental model shift that makes everything else make sense.

💡
The containers are healthy. The application is broken because the code is looking for the database in the wrong place. localhost inside one container is completely invisible to every other container.

How Docker DNS Actually Works

So how are containers supposed to talk to each other?

When you use Docker Compose, it automatically creates a bridge network and registers every service on that network using its service name as a DNS hostname.

The service name in your docker-compose.yml:

yaml
services:
mysql: # ← this name becomes a DNS hostname
image: mysql:8.0

Inside any container on the same Compose network, the hostname mysql resolves directly to the MySQL container's IP address.

Docker has a built-in DNS server running on every bridge network. When the API container looks up mysql, Docker DNS intercepts the query and returns the correct container IP — no manual configuration, no /etc/hosts editing, no static IPs.

code
API Container
mysql → Docker DNS → 172.20.0.2 (MySQL Container IP)

Docker DNS flow: API container sends DNS query for "mysql", Docker DNS resolver returns the container IP, connection succeeds

You can verify this from inside a running container:

bash
docker exec -it shopwave-api sh
nslookup mysql

nslookup mysql inside the API container showing Docker DNS resolving the service name to an IP address

It resolves. The DNS is working. The network is working. The only problem was the code using localhost instead of mysql.


The One-Word Fix

One word change. Open docker-compose.yml and update the environment variable:

yaml
environment:
DB_HOST: mysql # ← was: localhost
DB_PORT: 3306
DB_USER: root
DB_PASSWORD: shopwave
DB_NAME: shopwave

Restart the stack:

bash
docker compose down
docker compose up --build

docker compose up output after the fix showing API successfully connecting to MySQL

Now test it:

bash
curl http://localhost/products
json
[
{ "id": 1, "name": "Laptop" },
{ "id": 2, "name": "Mouse" },
{ "id": 3, "name": "Keyboard" }
]

The API connects. The data comes back. Everything works.


How Docker Bridge Networking Works

Now that the fix is in place, let's understand what Docker is doing underneath.

When you run docker compose up, Docker creates a bridge network for your project:

code
shopwave_default (bridge network)
├── nginx → 172.20.0.4
├── api → 172.20.0.3
└── mysql → 172.20.0.2

A bridge network is a virtual switch inside the Docker host. Containers connected to the same bridge can reach each other directly — using their container IP addresses, or using their service names via Docker DNS.

Docker bridge network topology showing Nginx, API, and MySQL containers connected to the shopwave_default bridge, which sits on the Docker host

Containers on the same bridge network:

  • Can reach each other by service name (mysql, api, nginx)
  • Communicate on container ports, not host ports
  • Are isolated from containers on other networks

Docker Port Mapping Misconception

When you write:

yaml
ports:
- "80:80"

This means: map host port 80 to container port 80.

This is for external access — letting traffic from your laptop or the internet reach a container.

It has nothing to do with container-to-container communication.

When Nginx proxies to the API, it does not go through host port 3000. It goes directly through the bridge network to the API container's internal port 3000.

nginx
# Correct
proxy_pass http://api:3000; ← container port, service name
# Wrong
proxy_pass http://localhost:3000; ← localhost inside nginx container
proxy_pass http://host.docker.internal:3000; ← unnecessary for same-network containers

If you publish a port (3000:3000) for a container and then use that host port from another container, you're routing traffic out of Docker, through the host machine, and back in — completely unnecessary when both containers are on the same bridge.


Common Docker Networking Mistakes

Mistake 1: Using localhost between containers

yaml
# Wrong
DB_HOST: localhost
# Correct
DB_HOST: mysql # use the service name

localhost inside a container is that container's own loopback. It never reaches another container.

Mistake 2: Using host ports for internal communication

nginx
# Wrong
proxy_pass http://localhost:3000;
# Correct
proxy_pass http://api:3000;

Service names work directly on bridge networks. Host ports are for external access only.

Mistake 3: Using the wrong container port

yaml
# Wrong
DB_PORT: 3360 # typo, wrong port
# Correct
DB_PORT: 3306 # the port MySQL listens on inside its container

When communicating between containers, always use the container's internal port — not a remapped host port.

Mistake 4: Trusting container health as application health

bash
docker ps → all containers Up

A container being Up means the process inside it started. It does not mean your application is correctly wired together. Misconfigured environment variables, wrong service names, or missing network connections can all cause application failures even when every container shows as healthy.

Always check docker logs <container> when a service that looks healthy isn't responding. Container health and application health are two different things.

Quick Command Reference

bash
# View all Docker networks
docker network ls
# Inspect a specific network — see which containers are on it
docker network inspect shopwave_default
# Inspect a container's full networking config
docker inspect shopwave-api
# Test DNS from inside a running container
docker exec -it shopwave-api sh
nslookup mysql
ping mysql
# View live logs for a specific container
docker logs -f shopwave-api
# View running containers
docker ps
# Create a custom network manually
docker network create app-network
# Connect an existing container to a network
docker network connect app-network nginx
# Disconnect a container from a network
docker network disconnect app-network nginx

Summary

We fixed the issue with a one-word change — replacing localhost with mysql.

But the real lesson wasn't the fix.

Docker wasn't broken. The containers were healthy. The application failed because the code was built on an assumption that doesn't hold inside containers: that localhost is a shared address that everything on the same machine can see.

Inside a container, each process lives in its own isolated network namespace. localhost belongs only to that namespace — not to Docker, not to the host machine, and not to the other containers running beside it.

Once that assumption changes, Docker networking stops feeling like a black box. You stop asking:

"Why is Docker broken?"

and start asking the right question:

"Which hostname should this service be using to reach that service?"

More often than not, the answer is simply the service name.

And the next time your containers look healthy but your application doesn't, ask yourself one more question:

"Am I talking to the right service?"

That single question can save you hours of debugging.


Frequently Asked Questions

Why can't Docker containers communicate with each other?

Containers often fail to communicate because of incorrect hostnames, isolated network namespaces, or misunderstanding how Docker networks work. In many cases, using localhost instead of the service name is the root cause.

Does localhost refer to the host machine in Docker?

No. Inside a container, localhost refers only to that container itself. It does not point to the host machine or other containers.

How does Docker DNS work?

Docker automatically provides an internal DNS service that allows containers on the same network to discover each other using their service or container names. No configuration is required — it works out of the box with Docker Compose.

Should containers communicate using exposed host ports?

No. Containers on the same Docker network should use the container's internal port and service name. Host ports are primarily for external access from outside Docker.

What network does Docker Compose create by default?

Docker Compose automatically creates a bridge network named {project_name}_default for the application, allowing all services in the same Compose file to communicate with each other.


Notes

  • The example in this walkthrough uses a three-container setup with Nginx, Node.js, and MySQL — but the same localhost mistake and fix apply to any container stack: Python + Redis, Go + PostgreSQL, PHP + MariaDB.
  • Docker Compose automatically names its bridge network {project_name}_default. You can define custom named networks in your Compose file for more control over which services can reach each other.
  • For Kubernetes, the equivalent concept is a Service — pods use the Service name as the DNS hostname to reach other pods, the same way containers use service names in Docker Compose.

Related articles