Dockerizing a Laravel + PostgreSQL App — and the Gotchas Nobody Warns You About

Why Bother

Every "works on my machine" bug traces back to the same thing: your machine isn't the server. Different PHP version, a missing extension, a Postgres minor that behaves slightly differently. Docker's real promise isn't "it runs in a container" — it's that the container on my laptop and the one in production are byte-for-byte the same image.

Here's the setup I use for Laravel + PostgreSQL, and — more usefully — the gotchas that cost me real time the first few times.

The Compose File

Four services: PHP-FPM (the app), nginx, Postgres, and Redis.

services:
  app:
    build: .
    volumes:
      - .:/var/www/html
    depends_on:
      postgres:
        condition: service_healthy   # ← waits for READY, not just "started"

  nginx:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - .:/var/www/html
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data   # ← persists across `down`
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:alpine

volumes:
  pgdata:

The Dockerfile

The whole game here is one line — installing the PostgreSQL driver, which the base PHP image does not ship with:

FROM php:8.3-fpm

# libpq-dev gives us the PostgreSQL client headers pdo_pgsql needs
RUN apt-get update && apt-get install -y \
        libpq-dev libzip-dev zip unzip git \
    && docker-php-ext-install pdo pdo_pgsql zip \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader

CMD ["php-fpm"]

And the .env the container uses — note DB_HOST:

DB_CONNECTION=pgsql
DB_HOST=postgres      # the Compose SERVICE NAME, not 127.0.0.1
DB_PORT=5432
DB_DATABASE=app
DB_USERNAME=app
DB_PASSWORD=secret

The Gotchas That Cost Me an Afternoon

1. pdo_pgsql isn't in the base image. The default php:8.3-fpm has no Postgres driver, so Laravel greets you with could not find driver. You need libpq-dev installed and docker-php-ext-install pdo_pgsql. This is the single most common Laravel-on-Docker trip-up.

2. DB_HOST is the service name, not localhost. Inside the Compose network the database lives at postgres (the service name). 127.0.0.1 from inside the app container points at the app container itself — so you get connection refused while a perfectly healthy Postgres sits one hop away.

3. depends_on does not wait for Postgres to be ready. By default it only waits for the container to start — but Postgres needs a second or two before it accepts connections. Without a healthcheck + condition: service_healthy, your boot-time migrate fires too early and dies with connection refused. The pg_isready healthcheck above is what makes the dependency actually mean "ready."

4. Persist the data volume. Map a named volume to /var/lib/postgresql/data. Forget it, and docker compose down cheerfully deletes your entire database — which feels a lot like the production scare, except it's silent.

5. storage/ permissions. php-fpm runs as www-data. Bind-mounting your host code can leave storage/ and bootstrap/cache unwritable, and Laravel throws a permission error on the first log write. Fix the ownership in the Dockerfile rather than chasing it at runtime.

6. Don't bake secrets into the image. Pass config at runtime via Compose environment / env_file. Never COPY .env into an image you might push to a registry.

Migrate on Boot, Safely

Because the healthcheck guarantees Postgres is ready before app starts, the entrypoint can stay dead simple:

#!/usr/bin/env bash
set -e

php artisan migrate --force
php artisan config:cache

exec php-fpm

--force is required to run migrations non-interactively in a container — which is exactly the flag I'm careful about in production, but here the database is unambiguous because it's defined right there in the Compose file.

Pin your versions, install pdo_pgsql, point DB_HOST at the service name, and give Postgres a real healthcheck. Get those four right and "works on my machine" finally means "works everywhere."