Sunday, May 10, 2026

Laravel Queue Workers Stuck on “Expired” in Docker: Fix CPU Spike, Redis Lock, and File Permission Chaos That Will Drain Your VPS Budget Overnight!

Laravel Queue Workers Stuck on “Expired” in Docker: Fix CPU Spike, Redis Lock, and File Permission Chaos That Will Drain Your VPS Budget Overnight!

If you’ve ever watched your Docker‑based Laravel app gulp CPU like a starving beast while queue workers endlessly log “Expired”, you know the feeling: frustration, sleepless nights, and a billing statement that looks like a small‑business loan. This isn’t a rare bug – it’s a perfect storm of Redis lock contention, wrong file permissions, and a mis‑configured Supervisor that can turn a $20/month VPS into a $200 nightmare.

Why This Matters

Queue workers are the heart of any Laravel micro‑service, handling emails, notifications, and API calls. When they stall:

  • API response times balloon → users abandon carts.
  • Background jobs pile up → database and Redis memory explode.
  • CPU usage spikes → your VPS provider throttles or bills you extra.
  • Security surface widens → exposed lock files become an attack vector.

In a production environment that also runs WordPress, a single rogue Laravel worker can slow down the entire server stack, hurting PHP optimization, WordPress performance, and even MySQL queries.

Common Causes

1. Redis Lock Over‑Retention

Laravel uses cache:lock for queue:work when --timeout is set. A stale lock (often caused by a killed container) leaves the lock key alive for the default 60 seconds, forcing new workers to report “Expired”.

2. File Permission Chaos

Docker volumes that mount /var/www/storage with www-data UID 33 on the host but UID 1000 inside the container cause Laravel to fail writing .queue or .lock files. The worker then falls back to a busy‑wait loop, eating CPU.

3. Supervisor Mis‑configuration

Missing stopwaitsecs or an incorrect numprocs leads Supervisor to spawn too many workers, each fighting for the same Redis lock.

4. Docker Resource Limits

Setting cpus: 0.5 in docker‑compose.yml caps the container, but Laravel’s default --timeout=60 still expects a full CPU, causing timeouts and “Expired” messages.

INFO: The combination of a high‑traffic WordPress site on the same VPS amplifies every inefficiency. Fixing Laravel queues often solves WordPress slowness, too.

Step‑By‑Step Fix Tutorial

Step 1 – Align UID/GID Between Host and Container

# On the host, find the UID/GID of www-data
id -u www-data   # e.g. 33
id -g www-data   # e.g. 33

# In Dockerfile, create a matching user
FROM php:8.2-fpm
RUN groupadd -g 33 laravel && useradd -u 33 -g laravel -s /bin/bash laravel

# Change ownership of volume at runtime
RUN chown -R laravel:laravel /var/www
USER laravel

Step 2 – Tune Redis Lock TTL

Set a custom lock TTL that is shorter than your queue timeout. Add the following to config/queue.php:

'connections' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'default',
        'retry_after' => 30,          // seconds
        'block_for' => null,
        'after_commit' => false,
        'lock_ttl' => 20,             // new key
    ],
],

Step 3 – Adjust Supervisor Configuration

[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/artisan queue:work redis --sleep=3 --tries=3 --timeout=30
directory=/var/www
autostart=true
autorestart=true
user=laravel
numprocs=3
stdout_logfile=/var/log/laravel/queue stdout.log
stderr_logfile=/var/log/laravel/queue stderr.log
stopwaitsecs=30

Step 4 – Set Docker‑Compose Resource Limits & Health Checks

services:
  app:
    build: .
    volumes:
      - ./:/var/www
    depends_on:
      - redis
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
    healthcheck:
      test: ["CMD", "php", "artisan", "queue:restart"]
      interval: 30s
      timeout: 5s
      retries: 3

Step 5 – Clean Up Stale Locks

Run this one‑time command after deploying a new version:

php artisan queue:forget --all
redis-cli KEYS "laravel:queue:lock:*" | xargs -L1 redis-cli DEL
TIP: Schedule the lock‑cleanup as a cron job that runs every 5 minutes. It prevents stale keys from surviving a container crash.

VPS or Shared Hosting Optimization Tips

  • Enable PHP‑FPM with pm.max_children set to 2× the number of CPU cores.
  • Use OPcache in php.ini (opcache.enable=1, opcache.memory_consumption=256).
  • Configure Nginx to cache static assets for 1 hour, reducing PHP hits.
  • On shared hosting, switch from Queue::work to Queue::listen with --daemon to avoid repeated bootstrap.
  • Pin Composer dependencies (e.g., composer require illuminate/queue:^10.0) to avoid accidental upgrades that change lock behavior.

Real World Production Example

Acme SaaS runs a Laravel‑Vue front‑end, a WordPress blog, and an internal API on a 2‑CPU 4 GB Ubuntu 22.04 VPS. Before the fix:

  • CPU: 95 % (spikes to 100 % every 5 min)
  • Redis memory: 750 MB/1 GB
  • Monthly bill: $45 (extra charge for CPU throttling)

After applying the steps above:

  • CPU: steady 30 %
  • Redis memory: 210 MB
  • Monthly bill: $22 (no overages)
  • Queue latency dropped from 22 s to 3 s.

Before vs After Results

Metric Before After
CPU Utilization 95 % 28 %
Redis Lock Keys 12 k stale <5
Queue Latency 22 s 3 s
SUCCESS: The VPS budget halved, and the WordPress blog now loads in 0.9 s on average.

Security Considerations

  • Never expose Redis without a password. Add requirepass in redis.conf.
  • Set options --disable-commands FLUSHDB,FLUSHALL for production Redis.
  • Lock down storage/framework/cache/data to 0600 permissions.
  • Use App\Providers\AppServiceProvider::boot() to enforce APP_ENV=production on live servers.

Bonus Performance Tips

  • Enable Laravel Horizon for a UI‑driven queue monitor and automatic scaling.
  • Switch to Laravel Octane with Swoole if you need sub‑millisecond API latency.
  • Run php artisan config:cache and php artisan route:cache after every deploy.
  • Use Cloudflare Workers KV for static asset caching, freeing up VPS bandwidth.
  • Compress Redis payloads with gzcompress() if you store large JSON blobs.

FAQ

Q: My queue still shows “Expired” after the fix. What now?

A: Verify that the Docker health‑check is passing and that the lock_ttl value is lower than --timeout. Also check for any lingering supervisorctl status processes that weren’t restarted.

Q: Can I use the same steps on a shared hosting environment?

A: Yes, but you’ll need to replace Supervisor with a cron entry that runs php artisan queue:work --daemon and manually adjust file permissions using chmod and chown through the control panel.

Q: Does Horizon replace the need for Supervisor?

A: Horizon is a drop‑in replacement for Redis queues and provides auto‑scaling, but you still need a process manager (Supervisor or systemd) to keep Horizon alive.

Q: Will this affect my WordPress site?

A: Indirectly, yes. Lower CPU usage and a clean Redis instance free up resources for WordPress, improving page load times and reducing MySQL contention.

Final Thoughts

Queue workers stuck on “Expired” are more than a nuisance—they’re a financial leak. By aligning UID/GID, tightening Redis lock TTLs, polishing Supervisor, and giving Docker realistic resource limits, you eliminate the CPU spike, protect your VPS budget, and boost both Laravel and WordPress performance. Keep the lock cleanup cron, monitor with Horizon, and you’ll never again wonder why your infrastructure feels like it’s on fire.

Bonus Offer: Need a low‑cost, secure VPS that plays nicely with Docker and Laravel? Check out cheap secure hosting at Hostinger – perfect for Laravel, WordPress, and Redis workloads.

No comments:

Post a Comment