DEV Community

Ivan Firestone
Ivan Firestone

Posted on Originally published at blog.ifirestone.dev

Laravel with Docker Compose: Five Infrastructure Mistakes Worth Avoiding

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"

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.

Top comments (1)

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.