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. 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
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
-
--timeoutis the maximum time a single job may run before the worker kills it. -
stop_grace_periodmust be larger, so on a deploy the worker has time to finish the current job or fail it cleanly. -
retry_aftermust 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
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
Two details that matter:
-
service_completed_successfully, notservice_healthy.migrateis 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
migrateexits with a non-zero code,app,queueandschedulernever 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_HOSTmatches the service name, neverlocalhost. - [ ] The database healthcheck uses the same credentials as the service.
- [ ]
depends_onusescondition: service_healthyfor 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_periodand--timeout<retry_after. - [ ]
queue:restartruns on every deploy. - [ ] Migrations run once per deployment, not on every startup.
- [ ]
app,queueandschedulerusedepends_onwithcondition: service_completed_successfullyon 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)
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.
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.
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.
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.
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