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)
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
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
2. Host Port vs. Container Port Confusion
In our docker-compose.yml, the backend configuration was:
ports:
- "5002:5000"
This syntax always means: <Host Port>:<Container Port>.
- Flask listens on
0.0.0.0:5000inside the container. - Docker forwards connections from port
5002on the host machine to port5000in 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:5002reaches the published Docker port. - Inside DevPod:
localhostis 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")
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");
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.
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)
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"}
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!')
"
The Culprit
We inspected the environment using docker-compose config and found:
-
docker-compose.ymlwas injectingDB_NAME,DB_USER,DB_PASSWORD. - Flask's
app.pywas looking forPOSTGRES_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"),
)
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", port5432. - Docker resolves
databasedirectly 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:
- Insert a user:
curl -X POST http://localhost:5002/users -H "Content-Type: application/json" -d '{"name":"Alan Turing"}'
- Stop and remove the database container:
docker-compose stop database
docker-compose rm -f database
- Spin up a brand new container:
docker-compose up -d database
- 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:
⚠️ 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
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:5432won't resolve locally unless PostgreSQL's port is published to the host.
Strategy
Keep your test tiers clear:
-
Unit/API Tests (Pytest): Test business logic and mock external network calls. Verify
/healthvia Flask'stest_client(). - 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:
- Check Status & Health:
docker-compose ps
- Check Logs:
docker-compose logs backend
docker-compose logs database
- Check Resolved Configuration:
docker-compose config
(Catches 90% of missing .env variable substitutions!)
- Inspect Live Container Environment:
docker exec -it multi-container-backend env | grep DB_
- Direct Endpoint Testing:
curl -i http://localhost:5002/health
curl -i http://localhost:5002/db-health
- Browser Developer Tools: Inspect the Network & Console tabs for CORS headers and origin mismatches.
🚀 Key Takeaways
- Containers are disposable; volumes are permanent. Treat containers as ephemeral compute instances.
-
Service names are hostnames. Inside the Docker network, use
database,backend, andfrontend. -
HTTP 200doesn't mean your frontend works. Always account for CORS when frontend and backend run on different ports. -
depends_onneedscondition: service_healthy. Never assume a running container is a ready service. -
docker-compose configis 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)