DEV Community

Cover image for Building a Multi-Container App with Docker Compose: 10 Real-World Gotchas and Lessons Learned
Alan Varghese
Alan Varghese

Posted on

Building a Multi-Container App with Docker Compose: 10 Real-World Gotchas and Lessons Learned

When you read basic Docker Compose tutorials, orchestrating a multi-tier application looks effortless: write a quick docker-compose.yml, run docker-compose up, and everything just magically talks to each other.

Then you actually build a full-stack project with Nginx (Frontend), Flask (Backend API), and PostgreSQL (Database) inside a modern containerized environment like DevPod / Dev Containers—and reality sets in.

Suddenly, you're wrestling with:

  • Docker socket permission errors inside your dev container.
  • Host port vs. container port confusion across multiple environments.
  • Browser CORS errors on requests that returned HTTP 200.
  • Database connections reporting "disconnected" despite PostgreSQL running fine.
  • Containers starting before the database is actually ready to accept queries.
  • Data disappearing because of a single careless CLI flag.

In this post, I will walk you through the architecture of a resilient multi-container web application and break down 10 critical bugs, troubleshooting workflows, and practical lessons learned while building it.


🏗️ The Application Architecture

The stack consists of three isolated services running on an internal Docker bridge network:

                         Host Machine (Browser / curl)
                                     |
               +---------------------+---------------------+
               |                                           |
               | http://localhost:8081                     | http://localhost:5002
               v                                           v
       +---------------+                           +---------------+
       |   Frontend    |  (Client-side Fetch)      |    Backend    |
       |  Nginx Alpine |-------------------------->|  Flask (Py3)  |
       |  (Port: 80)   |                           |  (Port: 5000) |
       +---------------+                           +-------+-------+
                                                           |
                                                           | database:5432
                                                           v
                                                   +---------------+
                                                   |   Database    |
                                                   | PostgreSQL 16 |
                                                   |  (Port: 5432) |
                                                   +-------+-------+
                                                           |
                                                           v
                                                  [ postgres_data ]
                                                   (Named Volume)
Enter fullscreen mode Exit fullscreen mode

Port Mappings at a Glance

Service Container Port Host Port Purpose
Frontend (Nginx) 80 8081 Serves static HTML/JS UI
Backend (Flask) 5000 5002 REST API (/health, /db-health, /users)
Database (Postgres) 5432 Internal Data persistence

💥 10 Real-World Gotchas & Lessons Learned

1. The DevPod / Docker Socket Permission Trap

When developing inside a containerized dev environment (like DevPod or VS Code Dev Containers) that mounts the host Docker socket (/var/run/docker.sock), running docker ps can throw this classic roadblock:

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
Enter fullscreen mode Exit fullscreen mode

Why It Happens

The non-root container user (vscode) doesn't belong to the group that owns the mounted socket file on the host.

The Wrong Fix vs. The Right Fix

Don't do: sudo chmod 777 /var/run/docker.sock. Changing permissions on the host socket punches a major security hole into your host daemon.

Do: Add the dev container user to the group ID matching /var/run/docker.sock. In our .devcontainer/Dockerfile:

RUN usermod -aG root vscode
Enter fullscreen mode Exit fullscreen mode

2. Host Port vs. Container Port Confusion

In our docker-compose.yml, the backend configuration was:

ports:
  - "5002:5000"
Enter fullscreen mode Exit fullscreen mode

This syntax always means: <Host Port>:<Container Port>.

  • Flask listens on 0.0.0.0:5000 inside the container.
  • Docker forwards connections from port 5002 on the host machine to port 5000 in the container.

The Trap

Running curl http://localhost:5002/health worked directly from the host Mac terminal, but failed inside the DevPod dev container.

Why? localhost is scoped to your current network namespace:

  • On the host Mac: localhost:5002 reaches the published Docker port.
  • Inside DevPod: localhost is the DevPod container itself, which isn't listening on 5002!

Lesson: Always know which network namespace your command is executing in.


3. Frontend-to-Backend Port Mismatch

Our frontend static index.html originally had:

fetch("http://localhost:5000/health")
Enter fullscreen mode Exit fullscreen mode

When opened in the browser at http://localhost:8081, clicking the button failed with a connection error.

Why? The browser is running on the host machine. The backend isn't exposed on localhost:5000 on the host; it was published on port 5002.

The Fix

Update the browser fetch call:

const response = await fetch("http://localhost:5002/health");
Enter fullscreen mode Exit fullscreen mode

4. The CORS Paradox: When HTTP 200 Still Fails

Once the port was corrected, the network tab showed HTTP 200 OK, but the browser console threw this error:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:5002/health. 
(Reason: CORS header 'Access-Control-Allow-Origin' missing). Status code: 200.
Enter fullscreen mode Exit fullscreen mode

The Insight

An HTTP status of 200 OK means the backend successfully received and processed the request. However, because the frontend is served from http://localhost:8081 and the backend is at http://localhost:5002, they are different origins (different ports = different origins). The browser's Same-Origin Policy blocks client-side JavaScript from reading the response unless the backend explicitly provides CORS headers.

The Fix

Install flask-cors in backend/requirements.txt and wrap the Flask app:

from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app)
Enter fullscreen mode Exit fullscreen mode

5. The Phantom Disconnection: Environment Variable Misalignment

Testing the database health check endpoint (curl http://localhost:5002/db-health) returned:

{"database": "disconnected", "error": "fe_sendauth: no password supplied"}
Enter fullscreen mode Exit fullscreen mode

Yet running a manual test using docker exec worked seamlessly:

docker exec -it multi-container-backend python -c "
import psycopg2
conn = psycopg2.connect(host='database', dbname='appdb', user='appuser', password='apppassword')
print('Connected!')
"
Enter fullscreen mode Exit fullscreen mode

The Culprit

We inspected the environment using docker-compose config and found:

  • docker-compose.yml was injecting DB_NAME, DB_USER, DB_PASSWORD.
  • Flask's app.py was looking for POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD.

Only DB_HOST matched; the user and password fell back to non-matching defaults!

The Fix

Always ensure your variable names match seamlessly across .env, docker-compose.yml, and application source code:

def get_db_connection():
    return psycopg2.connect(
        host=os.getenv("DB_HOST", "database"),
        dbname=os.getenv("DB_NAME", "appdb"),
        user=os.getenv("DB_USER", "appuser"),
        password=os.getenv("DB_PASSWORD", "apppassword"),
    )
Enter fullscreen mode Exit fullscreen mode

6. Container-to-Container DNS vs. localhost

Inside a container, localhost refers strictly to that container.
If your Flask backend tries to connect to localhost:5432, it attempts to find PostgreSQL inside the Flask container and fails.

The Docker Solution

Docker Compose automatically creates an internal bridge network and sets up DNS records using the service names:

  • Backend connects to host="database", port 5432.
  • Docker resolves database directly to the PostgreSQL container IP.

7. Verifying True Data Persistence (and the Dangerous -v Flag)

A common mistake in Docker development is assuming your database data is safe without testing container destruction.

To verify persistence:

  1. Insert a user:
   curl -X POST http://localhost:5002/users -H "Content-Type: application/json" -d '{"name":"Alan Turing"}'
Enter fullscreen mode Exit fullscreen mode
  1. Stop and remove the database container:
   docker-compose stop database
   docker-compose rm -f database
Enter fullscreen mode Exit fullscreen mode
  1. Spin up a brand new container:
   docker-compose up -d database
Enter fullscreen mode Exit fullscreen mode
  1. Query /users: Alan Turing is still there!

Because we declared a named volume in docker-compose.yml:

volumes:
  - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

⚠️ Warning: Never use docker-compose down -v when testing persistence. The -v flag deletes all named volumes, wiping out your database!


8. Health Checks: Running vs. Ready

If service A depends on service B, using only depends_on: [database] is not enough. Docker starts the backend as soon as the PostgreSQL container process spawns—which is seconds before the database is actually initialized and accepting connections.

The Solution: Healthcheck + condition: service_healthy

In docker-compose.yml:

services:
  database:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5

  backend:
    build: ./backend
    depends_on:
      database:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
      interval: 10s
      timeout: 5s
      retries: 5
Enter fullscreen mode Exit fullscreen mode

Now, Docker delays starting Flask until pg_isready returns exit code 0.


9. Pytest in Dev Environments vs. Docker Integration Tests

When running pytest in your local or dev-container environment, you might be tempted to test database endpoints directly. However:

  • Your local Python interpreter isn't inside the Compose bridge network.
  • database:5432 won't resolve locally unless PostgreSQL's port is published to the host.

Strategy

Keep your test tiers clear:

  1. Unit/API Tests (Pytest): Test business logic and mock external network calls. Verify /health via Flask's test_client().
  2. Integration Tests (cURL/HTTP client): Execute queries against the live, running Compose stack.

10. The Systematic 6-Step Debugging Workflow

When multi-container stacks misbehave, resist the urge to randomly change settings. Follow this deterministic sequence:

  1. Check Status & Health:
   docker-compose ps
Enter fullscreen mode Exit fullscreen mode
  1. Check Logs:
   docker-compose logs backend
   docker-compose logs database
Enter fullscreen mode Exit fullscreen mode
  1. Check Resolved Configuration:
   docker-compose config
Enter fullscreen mode Exit fullscreen mode

(Catches 90% of missing .env variable substitutions!)

  1. Inspect Live Container Environment:
   docker exec -it multi-container-backend env | grep DB_
Enter fullscreen mode Exit fullscreen mode
  1. Direct Endpoint Testing:
   curl -i http://localhost:5002/health
   curl -i http://localhost:5002/db-health
Enter fullscreen mode Exit fullscreen mode
  1. Browser Developer Tools: Inspect the Network & Console tabs for CORS headers and origin mismatches.

🚀 Key Takeaways

  1. Containers are disposable; volumes are permanent. Treat containers as ephemeral compute instances.
  2. Service names are hostnames. Inside the Docker network, use database, backend, and frontend.
  3. HTTP 200 doesn't mean your frontend works. Always account for CORS when frontend and backend run on different ports.
  4. depends_on needs condition: service_healthy. Never assume a running container is a ready service.
  5. docker-compose config is your best friend. Run it whenever environment variables behave unpredictably.

💬 Discussion

Have you run into CORS surprises or database startup race conditions in Docker Compose? What is your favorite healthcheck pattern? Let me know in the comments below!

Top comments (0)