A fully containerised 3-tier web application built with Nginx · Python/Flask · MySQL 8.0.
Browser
│
│ HTTP :80
▼
┌─────────────────────────────┐
│ frontend (nginx:alpine) │ port 80 (host) → 8080 (container)
│ • serves index.html │
│ • /api/* → proxy backend │
└────────────┬────────────────┘
│ HTTP :5000 (Docker network: app_network)
▼
┌─────────────────────────────┐
│ backend (python:alpine) │ port 5000
│ • GET / → 200 JSON │
│ • GET /health → DB status │
└────────────┬────────────────┘
│ TCP :3306 (Docker network: app_network)
▼
┌─────────────────────────────┐
│ db (mysql:8.0) │ port 3306 (internal only)
│ • named volume: mysql_data │
└─────────────────────────────┘
All three containers share the custom bridge network app_network and communicate using Docker service names as hostnames (no hardcoded IPs).
- Docker Engine ≥ 24 and Docker Compose v2
git clone <your-repo-url>
cd docker-3tier-app
cp .env.example .env # fill in your passwordsdocker compose up --buildCompose starts the services in dependency order:
db → (healthy) → backend → (healthy) → frontend
Open http://localhost in your browser.
Two complementary mechanisms are in place so the backend never crashes permanently:
-
depends_on: condition: service_healthyindocker-compose.yml— Compose will not start thebackendcontainer at all until thedbhealthcheck (mysqladmin ping) passes.depends_onalone is not used. -
wait_for_db()retry loop inapp.py— when the Flask process starts it attempts a real MySQL connection up to 40 times (3 s apart). If it still can't connect it logs a warning and starts the HTTP server anyway, reportingunhealthyon/healthuntil the database becomes reachable again. The process never exits, so Docker never has to restart it due to a DB blip.
BACKEND_URL (e.g. backend:5000) is defined in .env. Compose injects it as
an environment variable into the frontend container. The container's start
command runs:
envsubst '${BACKEND_URL}' \
< /etc/nginx/templates/nginx.conf.template \
> /etc/nginx/conf.d/default.confenvsubst only replaces ${BACKEND_URL}; all other nginx variables
($host, $remote_addr, etc.) are left intact. No URL is hardcoded.
All containers are on the app_network bridge network. Docker's embedded DNS
resolves service names to container IPs automatically:
| From | To | Address used |
|---|---|---|
| browser | nginx | localhost:80 |
| nginx | backend | backend:5000 |
| backend | db | db:3306 |
open http://localhost # macOS
xdg-open http://localhost # LinuxThe page displays live JSON responses from both API endpoints.
# Basic OK response
curl http://localhost/api/
# Database health check
curl http://localhost/api/healthdocker compose logs -f
docker compose logs -f backend # backend onlydocker compose ps # shows health: healthy / starting / unhealthydocker restart docker-3tier-app-db-1| Time | What happens |
|---|---|
| 0 s | MySQL container stops. |
| 0–30 s | GET /api/health returns 500 unhealthy — backend is running but DB is unreachable. |
| ~30–60 s | MySQL restarts and initialises. |
| ~60 s | MySQL healthcheck passes again. |
| Next request | Backend reconnects transparently — wait_for_db is not re-run, but get_db_connection() creates a fresh connection on every /health call, so recovery is automatic. |
Typically 30–60 seconds (MySQL initialisation time).
- The backend never exits when the DB goes away — the Flask process stays
alive and continues serving
GET /with 200 OK. GET /healthreturns 500 while MySQL is down, giving an honest status signal.- Once MySQL is back, the very next call to
get_db_connection()succeeds and/healthreturns 200 healthy again — no restart of the backend is needed.
| Feature | Implementation |
|---|---|
| Multi-stage build | backend/Dockerfile — Stage 1 installs deps into a venv; Stage 2 copies only the venv + source. |
| Non-root user | Backend runs as appuser; Nginx runs as nginx (port 8080, no root required). |
.
├── frontend/
│ ├── Dockerfile # nginx:alpine, non-root, envsubst
│ ├── nginx.conf # main config — no 'user' directive, /tmp paths
│ ├── nginx.conf.template # server block with ${BACKEND_URL} placeholder
│ ├── index.html # static page with live API status cards
│ └── .dockerignore
├── backend/
│ ├── Dockerfile # multi-stage python:alpine, non-root appuser
│ ├── app.py # Flask app — GET /, GET /health, wait_for_db
│ ├── requirements.txt
│ └── .dockerignore
├── docker-compose.yml
├── .env.example # ← commit this
├── .env # ← DO NOT commit
└── README.md