DEV Community

Cover image for Laravel with Docker Compose: Five Infrastructure Mistakes Worth Avoiding
Ivan Firestone
Ivan Firestone

Posted on Edited on Originally published at blog.ifirestone.dev

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.

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

php artisan schedule:work
Enter fullscreen mode Exit fullscreen mode

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. The queue worker needs two extra settings once it runs on its own (see below):

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

  queue:
    image: myapp:1.4.0
    command: php artisan queue:work --tries=3 --timeout=90
    restart: unless-stopped
    stop_grace_period: 120s

  scheduler:
    image: myapp:1.4.0
    command: php artisan schedule:work
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

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

Stop grace period: don't let Docker kill a job mid-flight

Once the worker is its own container, Docker's stop timeout matters. If a job runs longer than stop_grace_period (10 seconds by default), Compose sends SIGKILL in the middle of the job. The job retries from scratch or, worse, leaves a half-committed side effect.

The rule: keep the timeouts in this order so workers finish or fail cleanly.

--timeout (90s)  <  stop_grace_period (120s)
--timeout (90s)  <  retry_after in config/queue.php
Enter fullscreen mode Exit fullscreen mode
  • --timeout is the maximum time a single job may run before the worker kills it.
  • stop_grace_period must be larger, so on a deploy the worker has time to finish the current job or fail it cleanly.
  • retry_after must also be larger than --timeout, otherwise another worker can pick up the same job while the first one is still running it.

Restart workers on every deploy

Queue workers are long-lived PHP processes: they keep the old code in memory. After a deploy, run:

php artisan queue:restart
Enter fullscreen mode Exit fullscreen mode

It's cheap insurance against stale workers running old code. Workers finish their current job and exit, and restart: unless-stopped brings them back with the new code. If you replace the containers on every deploy (new image tag), this happens naturally, but it's still the safest option when you update code in place.

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. But the one-shot service is only half the fix: the other services must wait for it to finish.

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

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

  app:
    image: myapp:1.4.0
    command: php-fpm
    depends_on:
      migrate:
        condition: service_completed_successfully

  queue:
    image: myapp:1.4.0
    command: php artisan queue:work --tries=3 --timeout=90
    restart: unless-stopped
    stop_grace_period: 120s
    depends_on:
      migrate:
        condition: service_completed_successfully

  scheduler:
    image: myapp:1.4.0
    command: php artisan schedule:work
    restart: unless-stopped
    depends_on:
      migrate:
        condition: service_completed_successfully
Enter fullscreen mode Exit fullscreen mode

Two details that matter:

  • service_completed_successfully, not service_healthy. migrate is a one-shot service: it never becomes "healthy", it exits. Without this condition Compose starts everything in parallel, the app boots mid-migration and you get the same lock race the pattern was supposed to kill.
  • Fail fast for free. If migrate exits with a non-zero code, app, queue and scheduler never start, so a broken migration doesn't take a half-migrated application live.

Since migrate already waits for postgres to be healthy, the other services inherit that wait transitively.

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 for the database.
  • [ ] The cache table exists if you use CACHE_STORE=database.
  • [ ] One process per container: web, queue and scheduler separated.
  • [ ] Queue worker: --timeout < stop_grace_period and --timeout < retry_after.
  • [ ] queue:restart runs on every deploy.
  • [ ] Migrations run once per deployment, not on every startup.
  • [ ] app, queue and scheduler use depends_on with condition: service_completed_successfully on the migrate service.
  • [ ] 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, stop signals with a deadline). 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.

Update: thanks to Kamran Khan for the service_completed_successfully ordering in #5, and to Syed Mujtaba for the stop grace period and queue:restart points in #4.

Top comments (5)

Collapse
 
mujtbabbas profile image
Syed Mujtaba •

Number 4 is the one that bites quietly. One related thing worth adding: once the queue worker is its own container, Docker's stop grace period matters too. If a job runs longer than the container's stop timeout, Compose SIGKILLs it mid-flight and the job retries from scratch, or worse, leaves a half-committed side effect. We run queue:work with a --timeout of 90 and set stop_grace_period to 120 so workers finish or fail cleanly. Related to number 5, queue:restart on deploy is cheap insurance against stale workers running old code.

Collapse
 
ifirestone profile image
Ivan Firestone •

Excellent point, thanks! Pairing --timeout 90 with stop_grace_period: 120 is exactly the right way to avoid SIGKILLs mid-job and half-committed side effects. And queue:restart on deploy is cheap insurance against stale workers. Great addition to #4 and #5.

Collapse
 
mujtbabbas profile image
Syed Mujtaba •

Glad that landed, Ivan. The mid-job SIGKILL is the part people usually only discover in production, so getting the timeout and grace period paired up front saves a lot of painful retries. Appreciate you writing the piece, the Docker Compose pitfalls were a useful checklist.

Collapse
 
kamrankhandev profile image
Kamran Khan •

One thing I'd add to the checklist, from a deploy that bit me: the one-shot migrate service only works if app, queue and scheduler wait on it with depends_on condition: service_completed_successfully. Without that line Compose starts everything in parallel, the app boots mid-migration, and you get the same lock race the pattern was supposed to kill. The migrate service is half the fix; the ordering is the other half.

Collapse
 
ifirestone profile image
Ivan Firestone •

Good catch, Kamran, and fair point: the prose says the other services start after the migration, but the snippet doesn’t show how. service_completed_successfully is the missing piece, and it’s different from service_healthy, since a one-shot service never becomes healthy, it just exits. It also gives you fail-fast for free: if migrate exits non-zero, app, queue and scheduler never start. I’ll update the example and add it to the checklist. Thanks!

The docker file should be updated:
app:
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully