
Laravel Horizon on Ubuntu: Redis Queue Production Guide
Laravel queues keep slow work—email delivery, imports, reports, image processing, and webhooks—out of the request-response cycle. In production, however, running a single queue:work command in a terminal is not enough. The worker must survive disconnects and reboots, reload new code safely, expose useful metrics, and avoid processing the same job twice.
This guide builds that production setup with Laravel Horizon, Redis, and Supervisor on Ubuntu. Horizon adds a dashboard and code-driven worker configuration to Laravel's Redis queues, while Supervisor keeps the Horizon process alive. The examples target Laravel 13, but the operational principles also apply to recent Laravel releases.
What Laravel Horizon adds
Laravel already provides a capable queue system. Horizon does not replace it; Horizon manages Redis-backed queue workers and adds visibility into throughput, runtime, wait time, and failures. It also lets you define worker counts and balancing behavior in config/horizon.php, so production settings live in version control.
Use Horizon when your application depends on background jobs and Redis is an acceptable queue backend. If you use another driver such as Amazon SQS, run Laravel's regular queue workers with a process monitor instead. The official Horizon documentation states that Horizon requires Redis and is not currently compatible with Redis Cluster.
Prerequisites
Start with a deployed Laravel application, SSH access to an Ubuntu server, and a non-root deployment user. If the web application is not deployed yet, follow this Laravel deployment guide for Ubuntu first.
Install Redis, the PHP Redis extension, and Supervisor:
sudo apt update
sudo apt install redis-server php-redis supervisor
sudo systemctl enable --now redis-server
sudo systemctl enable --now supervisor
redis-cli ping
A healthy local Redis instance returns PONG. Do not expose port 6379 to the public internet. When Redis runs on another host, protect it with a private network, authentication, TLS where supported, and restrictive firewall rules.
Install Horizon and configure Redis
From the Laravel project directory, install Horizon and publish its files:
composer require laravel/horizon
php artisan horizon:install
Configure the application to use Redis for queues. A local Redis server typically needs these environment values:
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1
Using different logical databases for queues and cache makes inspection and maintenance easier. For a remote or managed Redis service, use the credentials and TLS settings supplied by the provider. Laravel uses PhpRedis by default, and the Laravel Redis documentation lists connection timeout, retry, and backoff options that can be tuned for networked deployments.
Clear cached configuration after changing environment variables, then confirm Laravel can connect:
php artisan optimize:clear
php artisan tinker
Redis::connection()->ping();
Design queues around workload priority
A single default queue is easy to start with, but it allows a large report or import to delay user-facing notifications. Separate work by latency and resource profile. For example:
criticalfor password resets and security notifications.defaultfor ordinary application jobs.reportsfor slower exports and document generation.
Dispatch a job to a specific queue from the application:
GenerateMonthlyReport::dispatch($accountId)
->onQueue('reports');
Queue names express operational priority, not just code organization. Keep the list small enough that it remains understandable during an incident.
Configure Horizon supervisors
Edit config/horizon.php and define separate supervisors for interactive and heavy work. This example reserves capacity for fast queues while limiting expensive report jobs:
'environments' => [
'production' => [
'supervisor-fast' => [
'connection' => 'redis',
'queue' => ['critical', 'default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 2,
'maxProcesses' => 8,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'tries' => 3,
'timeout' => 60,
'backoff' => [5, 30, 120],
'memory' => 256,
'maxJobs' => 1000,
'maxTime' => 3600,
],
'supervisor-reports' => [
'connection' => 'redis',
'queue' => ['reports'],
'balance' => 'simple',
'processes' => 2,
'tries' => 2,
'timeout' => 300,
'memory' => 512,
'maxJobs' => 100,
],
],
],
Do not copy these process counts blindly. Start conservatively and watch CPU, memory, Redis latency, job duration, and queue wait time. maxJobs and maxTime recycle long-lived workers periodically, which can limit the effect of gradual memory growth.
Align timeout and retry_after
This is the most important reliability detail. A Horizon worker timeout must be shorter than the Redis queue connection's retry_after. Otherwise, Redis may release a job while the original worker is still processing it, causing duplicate execution.
For a worker timeout of 300 seconds, use a comfortably larger retry window in config/queue.php:
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 330,
'block_for' => 5,
'after_commit' => true,
],
The Laravel queue documentation recommends keeping the worker timeout several seconds below retry_after. Also design jobs to be idempotent: store provider request IDs, use unique database constraints, and check whether an external side effect already completed before repeating it.
Protect the Horizon dashboard
The dashboard lives at /horizon. Never make it anonymously available in production because it reveals job names, timings, failures, and operational details. Define the viewHorizon authorization gate in app/Providers/HorizonServiceProvider.php:
use App\Models\User;
use Illuminate\Support\Facades\Gate;
Gate::define('viewHorizon', function (User $user): bool {
return $user->is_admin === true;
});
Combine application authorization with network controls for sensitive systems. A VPN, Cloudflare Access policy, or IP allowlist can reduce exposure further, but it should not replace the application gate.
Keep Horizon alive with Supervisor
Create /etc/supervisor/conf.d/horizon.conf. Replace the project path, PHP binary, and user with values from your server:
[program:horizon]
process_name=%(program_name)s
directory=/var/www/apps/example
command=/usr/bin/php artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/apps/example/storage/logs/horizon.log
stopwaitsecs=3600
stopasgroup=true
killasgroup=true
environment=APP_ENV="production"
The deployment user must be able to read the application and write to storage and bootstrap/cache. Set stopwaitsecs higher than the longest legitimate job so Supervisor does not kill it during shutdown.
Load the configuration and start Horizon:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start horizon
sudo supervisorctl status horizon
php artisan horizon:status
Collect metrics and schedule snapshots
Horizon needs periodic snapshots to populate its throughput and wait-time graphs. Add this to routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('horizon:snapshot')->everyFiveMinutes();
Your Laravel scheduler must already run every minute from cron:
* * * * * cd /var/www/apps/example && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
Then configure long-wait notifications and review failed jobs regularly. Horizon's dashboard is useful, but production monitoring should also alert on Redis availability, memory pressure, queue depth, oldest-job age, failure rate, and worker restarts.
Deploy new code without losing jobs
Queue workers are long-running processes; they do not automatically load new PHP code after every deployment. After dependencies, migrations, and caches are ready, terminate Horizon gracefully:
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan migrate --force
php artisan optimize
php artisan horizon:terminate
Horizon finishes its current jobs and exits. Supervisor detects the exit and starts a fresh process using the new release. Run horizon:terminate near the end of deployment, not before the new code and dependencies are available.
Production checklist
- Redis is private, authenticated when remote, monitored, and backed by an intentional persistence policy.
- Horizon has a matching production environment in
config/horizon.php. - Worker timeout is shorter than
retry_after. - Jobs are idempotent and safe to retry.
- The Horizon dashboard requires authorization.
- Supervisor starts Horizon on boot and restarts it after failure.
- The scheduler records Horizon snapshots every five minutes.
- Deployments run
horizon:terminateafter the release is ready. - Alerts cover queue delay, failures, Redis memory, and worker health.
Laravel Horizon turns Redis queues into an observable, version-controlled production system, but reliability still depends on careful timeouts, idempotent jobs, protected access, process supervision, and monitoring. Begin with a small worker pool, measure real workloads, and scale only when queue wait time and resource usage justify it.