0Pricing
PHP Academy · Lesson

Docker Compose for Local Stacks

Run PHP, a database and cache together locally.

Docker Compose for Local Stacks is a free PHP Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Local Stacks with Compose

Real PHP apps are never just PHP — they need a database, a cache, sometimes a queue and a mail catcher. Docker Compose declares all of these as services in one file and wires them onto a shared network so they reach each other by name.

This lesson builds a full local stack: PHP-FPM + nginx + MySQL + Redis, with volumes, healthchecks, and dependency ordering.

Service Skeleton

A Compose file lists services under services:. Each can build from a Dockerfile or pull an image. Compose creates a default network where every service is reachable by its key — your PHP app connects to MySQL at host db, not localhost.

services:
  app:
    build:
      context: .
      target: dev          # multi-stage dev target
    volumes:
      - ./:/app            # live code mount
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql:8.4
  redis:
    image: redis:7-alpine

nginx in Front of FPM

FPM speaks FastCGI on port 9000, not HTTP. nginx terminates HTTP and proxies .php requests to app:9000. The nginx config references the PHP service by its Compose name.

# docker/nginx/default.conf
server {
  listen 80;
  root /app/public;
  index index.php;

  location / {
    try_files $uri /index.php?$query_string;
  }

  location ~ \.php$ {
    fastcgi_pass app:9000;       # service name from compose
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
  }
}

Wiring the Web Service

The nginx service mounts the same code (to resolve SCRIPT_FILENAME) and the config, publishes port 80, and depends on the app. Both share /app so paths match on each side of FastCGI.

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - ./:/app:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

Persistent Volumes

Container filesystems are ephemeral. Named volumes persist database files across docker compose down. Bind mounts (./:/app) sync host code live for development. Use named volumes for stateful data, bind mounts for source.

services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: rootsecret
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  dbdata:        # survives `down`, removed only by `down -v`

Healthchecks and depends_on

depends_on alone only waits for the container to start, not for MySQL to accept connections. Add a healthcheck and use condition: service_healthy so your app waits until the DB is actually ready.

services:
  db:
    image: mysql:8.4
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-prootsecret"]
      interval: 5s
      timeout: 3s
      retries: 10
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy   # waits for healthcheck pass

Environment & .env

Compose auto-loads a sibling .env for variable interpolation, and you pass app config via environment or env_file. Keep secrets out of the committed Compose file; reference variables instead.

services:
  app:
    build: .
    env_file:
      - .env
    environment:
      DATABASE_URL: "mysql://app:secret@db:3306/app"
      REDIS_URL: "redis://redis:6379"
# .env (gitignored) provides ${...} substitutions
#   MYSQL_PASSWORD=secret

Connecting from PHP

Inside the network, hostnames are service names. Your PHP code connects to db and redis directly. This snippet parses a DSN the way a config layer would, showing how the Compose service name flows into your connection string.

<?php
$dsn = 'mysql://app:secret@db:3306/app';
$p = parse_url($dsn);

printf("driver: %s\n", $p['scheme']);
printf("host:   %s\n", $p['host']);   // 'db' resolves via compose DNS
printf("port:   %d\n", $p['port']);
printf("db:     %s\n", ltrim($p['path'], '/'));

$pdoDsn = sprintf('mysql:host=%s;port=%d;dbname=%s', $p['host'], $p['port'], ltrim($p['path'], '/'));
echo $pdoDsn . PHP_EOL;
?>

Xdebug for Local Dev

Mount Xdebug config only locally (via the dev build target). Point client_host at host.docker.internal so the debugger reaches your IDE on the host. Never ship Xdebug to prod — it kills performance.

; docker/php/xdebug.ini  (mounted only in dev)
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9003

Running One-Off Commands

docker compose run spins an ephemeral container for migrations, tests, or Composer; exec runs inside an already-running service. Use run --rm for tasks so the throwaway container is cleaned up.

# Run migrations against the running db
docker compose exec app php bin/console doctrine:migrations:migrate

# One-off: install deps without a long-lived container
docker compose run --rm app composer install

# Tail logs of just the web service
docker compose logs -f web

Override Files

Compose merges docker-compose.yml with docker-compose.override.yml automatically. Keep production-shaped defaults in the base file and put dev-only volumes, ports, and Xdebug in the override — so CI uses the base and devs get extras for free.

# docker-compose.override.yml (auto-merged, dev only)
services:
  app:
    volumes:
      - ./docker/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini:ro
    environment:
      APP_ENV: dev
# CI runs: docker compose -f docker-compose.yml up  (no override)

Quick Check

Why is plain depends_on: [db] insufficient before running migrations?

Recap

You assembled a local stack with Compose: PHP-FPM behind nginx (FastCGI to app:9000), MySQL and Redis reachable by service name, named volumes for persistence, healthchecks gating depends_on, env-driven config, Xdebug in a dev-only override, and one-off commands via run/exec.

Remember: service names are DNS hostnames, override files keep dev extras out of CI, and healthchecks beat naive startup ordering.

Frequently asked questions

Is the “Docker Compose for Local Stacks” lesson free?

Yes — the full text of “Docker Compose for Local Stacks” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.

What will I learn in “Docker Compose for Local Stacks”?

Run PHP, a database and cache together locally. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start PHP Academy?

No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Docker Compose for Local Stacks” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this PHP Academy lesson?

Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Containerizing a PHP Application
  2. Multi-Stage Builds and Optimization
  3. Docker Compose for Local Stacks
  4. CI/CD with GitHub Actions
← Back to PHP Academy