Add a Docker HEALTHCHECK to a Laravel Queue Worker Container

Add a Docker healthcheck to a Laravel queue worker so a dead worker is flagged unhealthy. Uses horizon:status exit codes, a cache heartbeat and compose.

Steven Richardson
Steven Richardson
· 8 min read

A worker container that reports Up 3 days while the queue backs up is one of the more irritating production failures. Docker only watches PID 1, so if queue:work wedges on a half-dead Redis connection — or Horizon's master supervisor dies while a shell wrapper keeps breathing — the container stays green and nothing restarts it. A HEALTHCHECK gives Docker something real to test.

Pick a liveness signal for the queue worker#

Before writing any Dockerfile lines, decide what "alive" actually means for this container, because a queue worker has no port to curl. There are three signals worth using, in descending order of confidence: Horizon's own horizon:status command, a heartbeat cache key written by the worker loop, and a bare process check with pgrep. Horizon and the heartbeat both prove the worker loop is turning; pgrep only proves a process with the right name exists, which a wedged worker will happily satisfy.

Pick based on what you're running:

Worker Signal Proves
php artisan horizon horizon:status Master supervisor is registered and not paused
php artisan queue:work Cache heartbeat key The worker loop ran in the last N seconds
Either pgrep -f 'artisan queue:work' A matching process exists — nothing more

If you're moving off a VM where Supervisor keeps your queue workers alive, this is the container-native replacement for the autorestart=true behaviour you're giving up.

Add a Docker healthcheck for a Horizon worker container#

Horizon ships the check you need. php artisan horizon:status asks the MasterSupervisorRepository whether any master supervisor has registered itself in Redis, and returns a distinct exit code for each state — which makes it a genuine liveness probe rather than a string you have to grep.

The exit codes in current Horizon are:

0  Horizon is running
1  Horizon is paused
2  Horizon is inactive

That exit code 2 is a trap. Docker documents 0 as healthy, 1 as unhealthy, and 2 as reserved — do not use. Pipe it through || exit 1 so every failure state collapses to the one code Docker actually promises to honour:

# Absolute path to artisan: HEALTHCHECK does not inherit your CMD's working directory
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD php /var/www/html/artisan horizon:status || exit 1

Note the side effect: a paused Horizon exits 1 and will be reported unhealthy. That's usually what you want during an incident, but if you pause Horizon deliberately during deploys, your worker will flip unhealthy mid-deploy. The wider trade-offs of running Horizon under Redis are covered in scaling Laravel queues in production.

Write a Docker healthcheck for a plain queue:work worker#

Plain queue:work has no status command, so give it one. Laravel dispatches an Illuminate\Queue\Events\Looping event on every iteration of the worker loop — including empty polls while the queue is idle — which makes it the perfect place to stamp a heartbeat.

Register the listener in AppServiceProvider::boot():

use Illuminate\Queue\Events\Looping;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;

Event::listen(function (Looping $event): void {
    Cache::put(
        'queue-heartbeat:'.gethostname(),
        now()->timestamp,
        now()->addMinutes(5),
    );

    // Never return false here. Looping is dispatched with until(), so a
    // false return tells the worker to skip picking up the next job.
});

Then a command that reads it and exits non-zero when the stamp goes stale:

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

class QueueHeartbeatCommand extends Command
{
    protected $signature = 'queue:heartbeat {--max-age=90 : Seconds before the heartbeat is stale}';

    protected $description = 'Exit non-zero when the local queue worker has not looped recently';

    public function handle(): int
    {
        $beat = Cache::get('queue-heartbeat:'.gethostname());

        if ($beat === null || (time() - (int) $beat) > (int) $this->option('max-age')) {
            $this->components->error('Queue worker heartbeat is stale.');

            return self::FAILURE; // 1 - the code Docker reads as unhealthy
        }

        $this->components->info('Queue worker heartbeat is fresh.');

        return self::SUCCESS; // 0
    }
}
HEALTHCHECK --interval=30s --timeout=10s --start-period=45s --retries=3 \
    CMD php /var/www/html/artisan queue:heartbeat || exit 1

Keep --max-age comfortably above your --sleep value plus your longest single job, otherwise a worker chewing through a slow job looks dead. Pair this with the memory guards in --max-jobs and --max-time so a recycling worker doesn't trip the heartbeat during its restart window.

If you genuinely only want a process check, remember the official php images are built on debian:bookworm-slim and do not ship procps — so pgrep isn't there:

RUN apt-get update \
    && apt-get install -y --no-install-recommends procps \
    && rm -rf /var/lib/apt/lists/*

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
    CMD pgrep -f 'artisan queue:work' > /dev/null || exit 1

Tune the interval, timeout, retries and start period#

The defaults will bite you, so set all four flags explicitly. Docker defaults to --interval=30s --timeout=30s --retries=3 --start-period=0s, and a zero start period is the one that causes trouble: a container that takes 40 seconds to warm caches will fail three checks and be declared unhealthy before it has ever been healthy.

  • --interval — 30s is a sensible floor for a PHP check. Every run boots the full framework and does a Redis round trip, so a 5s interval across twenty worker containers is real, pointless CPU.
  • --timeout — must exceed a cold framework boot. 10s is generous; 1s will fail every time.
  • --retries — 3 means roughly interval × retries before the container flips. With 30s/3 you get a ~90 second detection window.
  • --start-period — longer than your slowest boot. Failures inside this window don't count toward retries, and the first success ends the period early.
  • --start-interval — probes more aggressively during the start period. Requires Docker Engine 25.0 or later, so guard it if you support older hosts.

Detection time is start_period + (interval × retries) in the worst case. Tune retries down before you tune interval down — it's cheaper.

Declare the healthcheck in docker compose#

A healthcheck: block in Compose overrides whatever HEALTHCHECK is baked into the image, which is exactly what you want when the same image runs as both a web container and a worker. Same flags, snake_case keys, and the test array needs CMD-SHELL for the || exit 1 to be interpreted by a shell:

services:
  worker:
    build:
      context: .
      target: worker
    command: php artisan horizon
    restart: unless-stopped
    depends_on:
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "php /var/www/html/artisan horizon:status || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s

["CMD", "php", "artisan", "horizon:status"] skips the shell entirely, so || would be passed to Artisan as an argument. Use CMD-SHELL whenever there's a pipe, a redirect, or an ||. The scheduler container needs the same treatment for the same reason — see running the Laravel scheduler in Docker.

Verify the worker container flips to unhealthy#

Don't trust a healthcheck you haven't seen fail. Bring the stack up, confirm it reports healthy, then deliberately break the liveness signal and watch the state change — Horizon's pause command is ideal because it makes horizon:status exit 1 while leaving the container process running, which is exactly the silent-death scenario you're defending against.

docker compose up -d worker

# Wait out the start period, then read the state
docker inspect --format '{{ .State.Health.Status }}' "$(docker compose ps -q worker)"
# starting -> healthy

# Break it without killing PID 1
docker compose exec worker php artisan horizon:pause

# After interval x retries (~90s) it should flip
docker compose ps
# worker   ...   Up 4 minutes (unhealthy)

# Read why - Docker keeps the last five probe results
docker inspect --format '{{ json .State.Health }}' "$(docker compose ps -q worker)" | jq

docker compose exec worker php artisan horizon:continue

Docker truncates each probe's stored output at 4096 bytes, so keep your check quiet. A healthcheck that prints a stack trace on every failure fills .State.Health.Log with noise.

Restart unhealthy worker containers automatically#

Here's the part most write-ups get wrong: Docker Engine and Docker Compose never restart an unhealthy container. restart: unless-stopped reacts to the process exiting, not to the health state. Left alone, Compose will happily run an unhealthy worker forever, and all you've gained is a nicer docker compose ps. Acting on the signal takes one of three things.

Docker Swarm does it natively — a task that goes unhealthy is killed and replaced by the scheduler, no extra components.

Kubernetes ignores the Dockerfile HEALTHCHECK completely and wants its own probe, so reuse the same command as an exec liveness probe:

livenessProbe:
  exec:
    command: ["php", "/var/www/html/artisan", "queue:heartbeat"]
  initialDelaySeconds: 45
  periodSeconds: 30
  failureThreshold: 3

Plain Compose needs a sidecar that watches the Docker API and restarts unhealthy containers:

  autoheal:
    image: willfarrell/autoheal
    restart: always
    environment:
      AUTOHEAL_CONTAINER_LABEL: autoheal
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

I use autoheal on single-box Compose deployments and nothing else — mounting /var/run/docker.sock hands that container root-equivalent control of the host, which is a real trade-off, not a footnote. On anything with more than one node I'd rather run the orchestrator that handles it properly. If that's where you're heading, the probe design carries straight over to Laravel health checks for Kubernetes readiness and liveness probes, and it's worth trimming the worker image first with multi-stage builds so the healthcheck isn't booting a 900MB image every thirty seconds.

FAQ#

How do I health check a Laravel queue worker in Docker?

Add a HEALTHCHECK instruction to the worker Dockerfile (or a healthcheck: block in Compose) that runs an Artisan command exiting non-zero when the worker is dead. For Horizon that's php artisan horizon:status || exit 1. For plain queue:work you need to build the signal yourself, usually by writing a timestamp to the cache from the Looping event and checking its age with a small custom command.

Why does my queue worker container stay running when jobs stop?

Docker only tracks whether PID 1 has exited. A worker that is blocked on a dead Redis connection, stuck in an infinite loop, or paused is still a live process, so the container remains running and no restart policy fires. Without a HEALTHCHECK there is no other signal for Docker to act on — the container is healthy by default because nothing ever said otherwise.

How do I use horizon:status as a Docker healthcheck?

Run it as CMD php /var/www/html/artisan horizon:status || exit 1. Use the absolute path because HEALTHCHECK does not inherit the working directory from your CMD, and append || exit 1 because horizon:status exits 2 when Horizon is inactive — a code Docker documents as reserved. Be aware it also exits 1 when Horizon is merely paused, so a deliberate pause will mark the container unhealthy.

What HEALTHCHECK interval should I use for a worker?

Start at --interval=30s --timeout=10s --retries=3 and set --start-period to comfortably exceed your container's boot time, typically 45–60 seconds. That gives a worst-case detection window of about 90 seconds after startup. Going below 30 seconds rarely helps for a background worker and multiplies the cost of booting the framework on every probe.

How do I health check a plain queue:work process?

The reliable method is a heartbeat: listen for Illuminate\Queue\Events\Looping and write now()->timestamp to a cache key on each loop, then have a small Artisan command return Command::FAILURE when that key is missing or older than your threshold. A pgrep -f 'artisan queue:work' check is simpler but far weaker — it proves a process exists, not that it is still consuming jobs — and it needs procps installed, which the official PHP images do not include.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.