Laravel with Docker Compose: Five Infrastructure Mistakes Worth Avoiding

Service names, Postgres healthchecks, a scheduler without a cache table, queues and migrations on deploy: the mistakes that show up when taking Laravel to Docker Compose and how to avoid them.

Laravel with Docker Compose: Five Infrastructure Mistakes Worth Avoiding
Photo by Mohammad Rahmani / Unsplash

Service names, Postgres healthchecks, a scheduler without a cache table, queues and migrations on deploy: the mistakes that show up when taking Laravel to Docker Compose and how to avoid them.

Taking a Laravel application to Docker Compose seems trivial until the first deployment fails for reasons that aren't in your code. These are five infrastructure mistakes I found while debugging real projects, with the symptom, the cause and the fix.

1. The Service Name Doesn't Match DB_HOST

Symptom: the application can't connect to the database: getaddrinfo failed, Connection refused or Name or service not known.

Cause: on a Compose network, containers are resolved by service name. If your service is called postgres in docker-compose.yml but the .env says DB_HOST=db, there's nothing to resolve. It happens a lot when renaming services or copying an .env from another project.

Fix: make the value of DB_HOST exactly the service name.

services:
  postgres:
    image: postgres:16
  app:
    environment:
      DB_HOST: postgres # the service name, not "localhost"

A related classic: using localhost inside the container. Inside a container, localhost is that same container.

2. Postgres Healthcheck with Credentials Different from the Real Ones

Symptom: the database container ends up unhealthy and the services that depend on it never start. In the Postgres logs: FATAL: role "postgres" does not exist.

Cause: the healthcheck uses a user or database different from those configured in the environment variables. If you define POSTGRES_USER=laravel and your healthcheck runs pg_isready -U postgres, it checks against a role that doesn't exist.

Fix: have the healthcheck use the same variables, and escape the $ so it's resolved by the container and not by Compose:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_DATABASE}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    depends_on:
      postgres:
        condition: service_healthy

With depends_on and service_healthy, the app waits until the database actually accepts connections, not just until the container exists.

3. The Scheduler Fails Because the Cache Table Doesn't Exist

Symptom: the scheduler throws errors related to the cache or cache_locks table, especially when you use tasks with withoutOverlapping() or onOneServer().

Cause: with CACHE_STORE=database, Laravel stores the cache and locks in tables. If those tables don't exist (migration not run or a fresh database), the scheduler fails when trying to acquire a lock.

Fix: make sure the cache table is migrated. In Laravel 11 or higher:

php artisan make:cache-table
php artisan migrate

(In earlier versions the command was cache:table.) And run the scheduler as its own process:

php artisan schedule:work

4. All Processes in a Single Container

Symptom: in development everything works, and in production queues silently stop, restarts are unpredictable and you can't scale just the workers.

Cause: in development it's normal to use concurrently to bring up the web server, the queue and the scheduler in a single terminal. Carrying that pattern to production packs several processes into one container, and Docker only watches the main process: if the queue dies, nobody notices.

Fix: one process per container, with the same image and a different command:

services:
  app:
    image: myapp:latest
    command: php-fpm

  queue:
    image: myapp:latest
    command: php artisan queue:work --tries=3 --timeout=90
    restart: unless-stopped

  scheduler:
    image: myapp:latest
    command: php artisan schedule:work
    restart: unless-stopped

Advantages: independent restarts, separate worker scaling, separate logs and stop signals handled by Docker.

5. Migrations Run on Every Container Start

Symptom: when deploying with several replicas, intermittent migration errors, database locks or half-created tables appear.

Cause: putting php artisan migrate --force in the entrypoint makes every container try to migrate on startup. With several replicas, they compete with each other.

Fix: migrations are a deployment step, not an application startup step. Run them only once, as a prior task in the CI/CD pipeline or as a one-shot service:

services:
  migrate:
    image: myapp:latest
    command: php artisan migrate --force
    restart: "no"
    depends_on:
      postgres:
        condition: service_healthy

And have app, queue and scheduler start after the migration finishes successfully.

Quick Checklist

  • [ ] DB_HOST matches the service name, never localhost.
  • [ ] The database healthcheck uses the same credentials as the service.
  • [ ] depends_on uses condition: service_healthy.
  • [ ] The cache table exists if you use CACHE_STORE=database.
  • [ ] One process per container: web, queue and scheduler separated.
  • [ ] Migrations run once per deployment, not on every startup.
  • [ ] Images have a pinned version, not latest.

Conclusion

Almost all of these mistakes have the same origin: assuming the infrastructure behaves like your local machine. Compose has clear rules (resolution by name, startup order, one process per container). Knowing them saves hours of debugging.

If you have a recurring error in your own deployment, share it in the comments: surely someone else has run into it too.