Skip to content

Running a Laravel queue worker in Docker Compose without migration races

Put your queue worker in its own container, make it wait for a healthy database, stop it from re-running migrations, and restart it cleanly on every deploy.

Gohar Nawaz 4 min read
Docker and Laravel icons with the title Running a Laravel queue worker in Docker Compose

Sending an email, calling a slow API or processing a file should not make a visitor wait. That work belongs on a queue, handled by a worker process. In Docker, the clean way to run it is a separate container built from the same image as your app.

This article shows the Compose setup I use, and the one bug it prevents: two containers both trying to run migrations at startup.

One image, two jobs

The web container and the worker run the same code, so they should use the same image. Build it once and give the worker a different command:

services:
  app:
    build:
      context: .
    image: portfolio-app:latest
    restart: unless-stopped
    env_file: .env.docker
    depends_on:
      mysql:
        condition: service_healthy

  queue:
    image: portfolio-app:latest
    restart: unless-stopped
    env_file: .env.docker
    command: ["php", "/var/www/html/artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]

There is no second build on the worker — it reuses the image tagged by the app service, so both are always on the same version of the code.

The migration race

Many Laravel Docker images can run startup tasks for you: wait for the database, run php artisan migrate --force, link storage and cache the config. That is wonderful for the web container. But if the worker uses the same image, it runs those tasks too, and now two containers migrate at the same time on a fresh database.

Turn the automation off for the worker:

  queue:
    environment:
      AUTORUN_ENABLED: "false"

Only the web container migrates. The worker simply starts working.

Make the worker wait until the app is ready

A worker that boots before the database tables exist will crash and restart in a loop. Wait for the app to be healthy — which means its migrations finished:

  queue:
    depends_on:
      app:
        condition: service_healthy
      mysql:
        condition: service_healthy

service_healthy only works if the dependency defines a healthcheck. For MySQL:

  mysql:
    image: mysql:8.4
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p$$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s

The doubled $$ stops Compose from substituting the variable itself, so the container's own environment value is used.

Disable the inherited healthcheck

If the base image defines a healthcheck that pings the web server, the worker container inherits it — and the worker has no web server, so it reports unhealthy forever. Disable it on that service:

  queue:
    healthcheck:
      disable: true

The worker flags that matter

Flag Why
--tries=3 A failing job is retried, then moved to failed_jobs instead of looping forever
--sleep=3 Poll every three seconds when the queue is empty, instead of hammering the database
--max-time=3600 Exit after an hour; restart: unless-stopped starts a fresh process, which keeps memory from creeping up

Pair this with a failed_jobs table and check it periodically. A silent queue that is failing is worse than a loud one.

Workers keep old code in memory

A long-running worker loads your application once. After a deploy, it would keep running the old code until it restarts. With Compose this takes care of itself: rebuilding with docker compose up -d --build recreates the containers whose image changed, so the worker restarts on the new version. In the CI/CD pipeline that happens on every deploy.

Outside Docker you would run php artisan queue:restart to get the same effect.

Share the right state

The web container and the worker must agree on anything that is stored on disk. Mount the same named volume into both:

    volumes:
      - app-storage:/var/www/html/storage/app
      - app-logs:/var/www/html/storage/logs

Otherwise a file uploaded through the web container is invisible to the worker that is supposed to process it.

Which queue driver?

I use the database driver for this project: no extra service to run, and plenty for a low-volume site. Switch to Redis when you need higher throughput, delayed jobs at scale or a dashboard such as Horizon. Changing driver is a configuration change, not a code change — which is the point of Laravel's queue abstraction.

Checklist

  • Worker and web share one image.
  • Worker has AUTORUN_ENABLED=false.
  • Worker waits for a healthy app and database.
  • The inherited web healthcheck is disabled on the worker.
  • --tries and --max-time are set, and failed_jobs is monitored.
  • Shared volumes are mounted into both containers.

For how this fits into a full single-server deployment, see hosting a Laravel app on one EC2 instance.