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.
--triesand--max-timeare set, andfailed_jobsis 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.
More articles
Hosting a Laravel app on one AWS EC2 instance with Docker, Caddy and Cloudflare
The exact setup behind this portfolio: Docker Compose on a single EC2 instance, Caddy for HTTPS, Cloudflare in front, and a security group that only lets Cloudflare in.
· 4 min read
Technical SEO for a Laravel and Livewire portfolio: what I actually implemented
A practical checklist from building this site: per-page meta, canonical URLs, Open Graph, JSON-LD, a dynamic sitemap and Search Console — with the code and the things I deliberately skipped.
· 4 min read
Laravel CI/CD with GitHub Actions and a self-hosted runner on EC2
Run tests on GitHub, then deploy from a runner that lives on the server itself — no SSH keys in secrets, no inbound deploy port, and a deploy that never gets cancelled halfway.
· 4 min read