Skip to main content

Docker Compose: launching an application stack

VDS / VPS · 11.02.2025 · 6 min read
Illustration for “Docker Compose: launching an application stack”

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
💡 Use named volumes for persistent data — they are preserved when you run docker compose down.

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.

MethodWhere it livesRisk
Passwords directly in docker-compose.ymlIn the file itself, often in gitHigh — the secret leaks along with the repository
An .env file next to composeA separate file, not baked into the imageMedium — needs .gitignore, but the secret is still plaintext on disk
Docker secrets (Swarm)Encrypted in the cluster's Raft logLow, 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 -d recreates 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

  1. Confirm all services are running and healthy: docker compose ps must show running, and php must additionally show (healthy).
  2. Check that the site actually responds through Nginx: curl -I http://localhost must return 200 or the expected redirect code.
  3. Check that MySQL data survives container recreation: docker compose down && docker compose up -d, then confirm the tables are still there thanks to the named mysql-data volume.
  4. Check the logs for errors right after startup: docker compose logs --tail=100 must 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.

Was this article helpful?
← Back to Knowledge Base Ask Support