The Local Stack
A Compose file with the app, Postgres and Redis, using healthchecks rather than depends_on alone, and staying close to production.
depends_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
<?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.
# 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.
# 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.
# 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