The Local Stack

A Compose file with the app, Postgres and Redis, using healthchecks rather than depends_on alone, and staying close to production.

Syntaxdepends_on: { database: { condition: service_healthy } }

Compose runs several containers together from one file. Locally it gives you the application, its database and its cache with a single command. It is also where "works on my machine" either matches production or quietly stops matching it.

One file, several services

Every key under services becomes a container, and that key is its hostname on the shared network. The application reaches Postgres at database and Redis at redis, with the same DATABASE_URL shape as production and different credentials. A named volume keeps the database files across docker compose down and up; without one, every restart gives you an empty database.

depends_on is not readiness

The short form, depends_on: [database], waits only for the container to have started. Postgres needs a few more seconds before it accepts connections, so the application's first query fails. The long form is the same key with a condition attached:

depends_on:
  database:
    condition: service_healthy

That waits for the healthcheck defined on the database service to pass. These are not two features: service_started is exactly what the short form means. The other conditions are service_completed_successfully, for a one-shot container that must finish first, and the flag required: false, which downgrades a missing dependency to a warning.

Keep local close to production

Build the same Dockerfile, stopping at a dev stage that adds Xdebug, so CI and production use the later stages unchanged. Same PHP version, same extensions, same Postgres major version. A bind mount over /app is the one difference worth having.

Example

Example · php
<?php
// src/Command/WaitForDatabaseCommand.php

declare(strict_types=1);

namespace App\Command;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception as DbalException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:db:wait', description: 'Blocks until the database answers a query')]
final class WaitForDatabaseCommand extends Command
{
    // The connection is injected, not pulled out of the container.
    public function __construct(private readonly Connection $connection)
    {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        for ($attempt = 1; $attempt <= 30; ++$attempt) {
            try {
                $this->connection->fetchOne('SELECT 1');
                $io->success(sprintf('Database answered on attempt %d.', $attempt));

                return Command::SUCCESS;
            } catch (DbalException $e) {
                $io->writeln(sprintf('Attempt %d: %s', $attempt, $e->getMessage()));
                sleep(1);
            }
        }

        $io->error('Database did not become reachable.');

        return Command::FAILURE;
    }
}

// $ docker compose run --rm php bin/console app:db:wait
// Attempt 1: An exception occurred in the driver: connection refused
// Attempt 2: An exception occurred in the driver: connection refused
//
//  [OK] Database answered on attempt 3.
//

When to use it

  • Getting a new developer from clone to a running application with one command.
  • Fixing a flaky start-up where the application boots before Postgres accepts connections.
  • Running a Messenger consumer locally so async code is exercised before production.
  • Reproducing a production bug on the same PHP and Postgres versions the servers run.

More examples

compose.yaml with healthchecks

The baseline stack. start_period gives Postgres time to initialise before failing checks count towards retries, which otherwise marks a slow first boot as unhealthy. Pin the Postgres major version to whatever production runs, and keep serverVersion in the DSN in step with it: that value tells Doctrine which SQL dialect to generate, so a mismatch produces migrations that pass locally and fail on deploy.

Example · yaml
# compose.yaml
services:
  php:
    build:
      context: .
      target: dev
    environment:
      APP_ENV: dev
      DATABASE_URL: postgresql://app:app@database:5432/app?serverVersion=17&charset=utf8
      REDIS_URL: redis://redis:6379
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "8080:80"

  database:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 30s
    volumes:
      - database_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  database_data:

compose.override.yaml for development only

Keep every development-only difference here and the base file stays deployable. Running docker compose --file compose.yaml up --build, with the override left out, tests roughly what production runs - which is what CI should do, rather than inheriting the override by accident.

Example · yaml
# compose.override.yaml - Compose loads this on top of compose.yaml automatically
services:
  php:
    volumes:
      # Your working copy replaces /app, so edits appear without a rebuild.
      - ./:/app
      # An anonymous volume stops the host's var/ from shadowing the image's,
      # which is the usual cause of "my cache is from yesterday".
      - /app/var
    environment:
      XDEBUG_MODE: debug

  database:
    ports:
      # Reachable from a GUI client on the host. Not in the base file,
      # because production has no business exposing 5432.
      - "5432:5432"

A worker from the same image

Add this as soon as any work is handled asynchronously, so local behaviour matches a production deployment where the consumer is a separate process. The time and memory limits make the process exit on purpose and come back clean, which is how a long-running PHP process avoids accumulating memory; restart: unless-stopped is what brings it back.

Example · yaml
# compose.yaml (excerpt)
  worker:
    build:
      context: .
      target: dev
    # Same image, different command: a message consumer instead of a server.
    command:
      - php
      - bin/console
      - messenger:consume
      - async
      - --time-limit=3600
      - --memory-limit=128M
    environment:
      APP_ENV: dev
      DATABASE_URL: postgresql://app:app@database:5432/app?serverVersion=17&charset=utf8
      MESSENGER_TRANSPORT_DSN: redis://redis:6379/messages
    depends_on:
      database:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

Discussion

  • Be the first to comment on this lesson.