docker-compose.yml structure
version: '3.9'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./www:/var/www/html:ro
depends_on:
php:
condition: service_healthy
networks:
- app-net
php:
image: php:8.2-fpm-alpine
volumes:
- ./www:/var/www/html
healthcheck:
test: ["CMD", "php-fpm", "-t"]
interval: 10s
timeout: 5s
retries: 3
networks:
- app-net
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
- mysql-data:/var/lib/mysql
networks:
- app-net
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASS}
networks:
- app-net
volumes:
mysql-data:
networks:
app-net:
.env file for variables
DB_ROOT_PASS=supersecret
DB_NAME=myapp
DB_USER=appuser
DB_PASS=dbpassword
REDIS_PASS=redispass
Management commands
docker compose up -d # Start in background
docker compose down # Stop
docker compose logs -f # Logs
docker compose ps # Service status
docker compose exec php sh # Enter the container
Diagnosing common errors
The error Bind for 0.0.0.0:80 failed: port is already allocated means port 80 is already taken — either by another container, or by a system Nginx/Apache running directly on the host.
Error response from daemon: driver failed programming external connectivity on endpoint nginx:
Bind for 0.0.0.0:80 failed: port is already allocated
Find out who holds the port: ss -tlnp | grep :80. If it is the system Nginx, stop it and disable its autostart (systemctl disable --now nginx) before bringing up the container stack on the same port, otherwise the conflict returns after every server reboot.
The php container keeps restarting (Restarting (1) X seconds ago) — before guessing, check the container's own logs, not just its status:
docker compose logs php --tail=50
A common cause with PHP-FPM is a syntax error in the mounted code, or a missing extension not present in the base php:8.2-fpm-alpine image; Alpine images are minimal and do not include many extensions by default — they must be installed explicitly through a separate Dockerfile.
Comparing ways to pass secrets
The example above stores passwords in .env — acceptable for development, but in production that file must live outside git and have 600 permissions, otherwise any access to the server's disk is equivalent to leaking every password at once.
| Method | Where it lives | Risk |
|---|---|---|
| Passwords directly in docker-compose.yml | In the file itself, often in git | High — the secret leaks along with the repository |
| An .env file next to compose | A separate file, not baked into the image | Medium — needs .gitignore, but the secret is still plaintext on disk |
| Docker secrets (Swarm) | Encrypted in the cluster's Raft log | Low, but requires Swarm mode |
When Docker Compose does not fit
- You need to spread services across multiple physical servers — Compose manages containers on a single host only; a cluster needs Docker Swarm or Kubernetes.
- You need automatic scaling under load — Compose runs a fixed number of replicas that must be changed manually with
--scale; there is no auto-scaling here. - Production with strict resilience requirements — if the host goes down, every Compose container goes down with it; without a multi-node orchestrator, recovery is entirely manual.
- You need zero-downtime deployment of a new service version —
docker compose up -drecreates the container with a brief gap when the image changes; truly seamless updates need a separate mechanism such as rolling updates in Swarm or Kubernetes.
Verifying the result
- Confirm all services are running and healthy:
docker compose psmust showrunning, and php must additionally show(healthy). - Check that the site actually responds through Nginx:
curl -I http://localhostmust return200or the expected redirect code. - Check that MySQL data survives container recreation:
docker compose down && docker compose up -d, then confirm the tables are still there thanks to the namedmysql-datavolume. - Check the logs for errors right after startup:
docker compose logs --tail=100must not contain repeating exception traces.
For a deeper look at configuring Docker itself on a VDS, see the article on installing and configuring Docker. If you need your own registry for private images instead of Docker Hub, see the article on a private Docker registry.
Frequently asked questions
What do I do if port 80 is already taken by the system Nginx when bringing up a stack with Docker Compose?
You need to decide which service gets the port: either stop the system Nginx and disable its autostart before launching the containers, or change the port mapping in the compose file to a different external port. Restarting the stack without that decision brings the conflict back on every host reboot, because the system service starts again before the containers do.
Is storing passwords in an .env file good enough for production?
It technically works, but with caveats: the file must live outside git and have strict permissions, otherwise anyone who gets access to the server's disk gets every secret in plain text at once. For stronger protection there's Docker Swarm with encrypted secret storage — but that requires actually switching modes, not just dropping an .env file next to compose.
Can Docker Compose automatically add more containers for a service under load?
No, Compose starts a fixed number of replicas, set in the config or passed manually through a scaling flag, and it doesn't react to rising load on its own. Automatic scaling under real load needs an orchestrator like Kubernetes or Swarm mode with the matching rules — plain Compose isn't built for that.
How do I update a service's image in a running stack without downtime for site visitors?
The standard command for updating an image recreates the container with a brief service gap — that's not true zero-downtime deployment. Genuinely seamless updates need a separate mechanism such as rolling updates, available in Swarm mode or Kubernetes, where the old container only gets pulled from the load balancer once the new one is fully ready to accept traffic.