Author: Lychee Team Last Updated: 2025-12-28 Feature: 002-worker-mode Related: Feature 002 Spec
Lychee supports running separate worker containers for background job processing, enabling horizontal scaling of queue workers independently from web servers. This guide covers deploying and configuring worker mode for production use.
Worker mode addresses background processing bottlenecks without impacting web server capacity:
- Horizontal Scaling: Run multiple worker containers processing jobs in parallel
- Resource Isolation: Dedicate resources to background tasks (photo processing, notifications, etc.)
- Memory Leak Mitigation: Workers automatically restart after configured intervals
- Failure Recovery: Auto-restart loop ensures continuous operation after crashes
- Queue Priority: Process high-priority jobs before low-priority ones
- Docker and Docker Compose v2 installed
- Existing Lychee deployment with database and (optionally) Redis
- Lychee Docker image with worker mode support (version >= TBD)
Controls container startup mode:
LYCHEE_MODE=web # Default: Run FrankenPHP/Octane web server
LYCHEE_MODE=worker # Run Laravel queue workerWeb mode (default) is unchanged from previous Lychee versions. Omitting LYCHEE_MODE defaults to web mode for backward compatibility.
Worker mode starts php artisan queue:work in an auto-restart loop, processing background jobs continuously.
Specifies the queue backend driver:
QUEUE_CONNECTION=database # Use database for queue storage (no Redis dependency)
QUEUE_CONNECTION=redis # Use Redis for queue storage (recommended for production)
QUEUE_CONNECTION=sync # Synchronous processing (NOT recommended for worker mode)Recommendation: Use redis for production (faster, better concurrency). Use database if Redis is unavailable. Never use sync in worker mode - jobs will run synchronously, defeating the purpose of a queue worker.
Comma-separated queue names for priority processing:
QUEUE_NAMES=default # Default: process only 'default' queue
QUEUE_NAMES=high,default,low # Process high-priority jobs first, then default, then lowExample use cases:
high: User-initiated photo uploads/processingdefault: General background taskslow: Cleanup, maintenance, non-urgent operations
Worker restart interval in seconds for memory leak mitigation:
WORKER_MAX_TIME=3600 # Default: restart after 1 hour (3600 seconds)
WORKER_MAX_TIME=7200 # Restart after 2 hoursWorkers automatically exit and restart after this interval to prevent memory leaks from accumulating. The auto-restart loop ensures continuous operation.
Edit your existing docker-compose.yaml to uncomment the lychee_worker service:
services:
# Existing lychee_api service...
lychee_worker:
image: lychee-frankenphp:latest
container_name: lychee-worker
restart: unless-stopped
environment:
LYCHEE_MODE: worker # Enable worker mode
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}"
QUEUE_NAMES: "${QUEUE_NAMES:-default}"
WORKER_MAX_TIME: "${WORKER_MAX_TIME:-3600}"
# Database (same as lychee_api)
DB_CONNECTION: "${DB_CONNECTION:-mysql}"
DB_HOST: "${DB_HOST:-lychee_db}"
DB_PORT: "${DB_PORT:-3306}"
DB_DATABASE: "${DB_DATABASE:-lychee}"
DB_USERNAME: "${DB_USERNAME:-lychee}"
# Redis (if using QUEUE_CONNECTION=redis)
REDIS_HOST: "lychee_cache"
REDIS_PASSWORD: "null"
REDIS_PORT: "6379"
volumes:
# CRITICAL: Share storage with web service
- ./lychee/uploads:/app/public/uploads
- ./lychee/storage/app:/app/storage/app
- ./lychee/logs:/app/storage/logs
- ./lychee/tmp:/app/storage/tmp
- .env:/app/.env:ro
depends_on:
lychee_db:
condition: service_healthy
lychee_cache:
condition: service_started # If using Redis
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- lycheeStart the worker:
docker compose up -d lychee_worker
docker compose logs -f lychee_worker # Monitor logsTo run multiple workers processing jobs in parallel:
docker compose up -d --scale lychee_worker=3This creates 3 worker containers. Each processes jobs independently, improving throughput for background tasks.
Note: When scaling, remove container_name: lychee-worker from the compose file (Docker Compose generates unique names when scaling).
Advantages:
- Faster job processing
- Better concurrency handling
- Lower database load
Setup:
- Ensure
lychee_cache(Redis) service is running:
lychee_cache:
image: redis:alpine
# ... (existing configuration)- Set
QUEUE_CONNECTION=redisin bothlychee_apiandlychee_worker:
QUEUE_CONNECTION=redis
REDIS_HOST=lychee_cache
REDIS_PORT=6379- Restart containers:
docker compose restart lychee_api lychee_workerAdvantages:
- No Redis dependency
- Simpler setup for small deployments
Setup:
- Set
QUEUE_CONNECTION=database:
QUEUE_CONNECTION=database- Ensure the
jobstable exists (Laravel migration):
docker compose exec lychee_api php artisan queue:table
docker compose exec lychee_api php artisan migrate- Restart worker:
docker compose restart lychee_worker# View worker logs
docker compose logs -f lychee_worker
# Check if queue:work process is running
docker compose exec lychee_worker pgrep -f 'queue:work'
# View container health status
docker compose ps lychee_workerSuccessful startup:
🚀 Starting Lychee entrypoint...
✅ Application ready!
⚙️ Starting Lychee in worker mode...
🔄 Auto-restart enabled: worker will restart if it exits
📋 Queue names: high,default,low
⏱️ Max time: 3600s
📡 Queue connection: redis
🚀 Starting queue worker (2025-12-28 10:00:00)
Job processing:
[2025-12-28 10:01:00] Processing: App\Jobs\ExtractColoursJob
[2025-12-28 10:01:05] Processed: App\Jobs\ExtractColoursJob
Auto-restart (after WORKER_MAX_TIME):
✅ Queue worker exited cleanly (exit code 0)
⏳ Waiting 5 seconds before restart...
🚀 Starting queue worker (2025-12-28 11:00:00)
Check pending jobs in the queue:
Redis:
docker compose exec lychee_cache redis-cli
> LLEN queues:defaultDatabase:
docker compose exec lychee_db mysql -u lychee -p lychee -e "SELECT COUNT(*) FROM jobs;"To prevent duplicate jobs from being queued (e.g., multiple concurrent photo uploads triggering the same background task), use Laravel's WithoutOverlapping middleware:
use Illuminate\Queue\Middleware\WithoutOverlapping;
class RecomputeAlbumStatsJob implements ShouldQueue
{
public function middleware(): array
{
return [
(new WithoutOverlapping($this->albumId))
->releaseAfter(60) // Release lock after 60 seconds
->expireAfter(120), // Expire lock after 120 seconds
];
}
}Requirements:
- Redis cache driver (
CACHE_STORE=redis) for distributed locking - Or database cache driver for single-server deployments
Workers handle SIGTERM gracefully, completing in-flight jobs before exiting:
# Send SIGTERM (stop command)
docker compose stop lychee_worker
# Worker completes current job (up to --timeout=3600), then exitsRolling updates:
- Start new worker containers
- Stop old workers (graceful SIGTERM)
- Old workers finish current jobs, new workers take over
Symptom: Worker container exits with "Queue connection failed"
Solution: Verify queue backend is reachable:
# Test Redis connection
docker compose exec lychee_worker nc -z lychee_cache 6379
# Test database connection
docker compose exec lychee_worker nc -z lychee_db 3306Symptom: Jobs queued but not processed
Checklist:
- Worker is running:
docker compose ps lychee_workershows "Up" - Queue connection matches web service:
QUEUE_CONNECTIONsame in both - Jobs table exists (if using database driver):
php artisan queue:table && php artisan migrate - Logs show "Starting queue worker":
docker compose logs lychee_worker
Symptom: Worker repeatedly crashes and restarts
Solution: Check healthcheck threshold (default: 3 retries in 30 seconds)
healthcheck:
test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
interval: 30s
retries: 3Increase retries or interval if workers need more startup time.
Symptom: Worker memory usage grows over time
Solution: Reduce WORKER_MAX_TIME to restart workers more frequently:
WORKER_MAX_TIME=1800 # Restart every 30 minutesOr allocate more memory in docker compose:
deploy:
resources:
limits:
memory: 4G # Increase from default 2G- Use secrets for credentials: Store
DB_PASSWORD,REDIS_PASSWORDin.envor Docker secrets - Restrict worker capabilities: Workers don't need network binding, reduce attack surface:
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE- Read-only volumes: Mount
.envas read-only:
volumes:
- .env:/app/.env:roTune worker resource limits based on workload:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512MGuidance:
- Photo processing jobs: Higher memory (2-4GB per worker)
- Notification jobs: Lower memory (512MB-1GB per worker)
- CPU: 1-2 cores per worker typically sufficient
- Start with 1 worker, monitor queue depth
- Scale up if queue depth consistently > 100 jobs:
docker compose up -d --scale lychee_worker=2- Monitor metrics: Use Prometheus/Grafana to track queue depth, job processing time
- Scale down if queue depth stays near 0
Dispatch jobs to specific queues:
// High-priority (user-initiated)
ExtractColoursJob::dispatch($photoId)->onQueue('high');
// Default priority
ProcessImageJob::dispatch($photoId); // Defaults to 'default' queue
// Low-priority (cleanup)
PruneOldLogsJob::dispatch()->onQueue('low');Configure worker to process high-priority first:
QUEUE_NAMES=high,default,lowWorker processes all high jobs before moving to default, then low.