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-fpmqueue:
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_HOSTmatches the service name, neverlocalhost. - [ ] The database healthcheck uses the same credentials as the service.
- [ ]
depends_onusescondition: 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.
Top comments (1)
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.