Skip to content
article

Laravel queues that don't fall over: Horizon + Supervisor on a cheap VPS

the exact config we run for about 30 client apps, and the one setting that made our jobs run twice

Ayesha Siddiqui@laravel_ayesha

May 19, 2026 · 4 min read

Every few weeks a junior in my team (or a freelancer in my DMs) shows me a production server where the queue worker is running inside screen. It works until the server reboots, or the worker eats a bad job and dies at 3am, and then the client calls me asking why no invoices went out since Tuesday. Uff.

So here is the setup we actually use. Nothing fancy. A 2 vCPU / 4 GB VPS for about 12 dollars a month runs Redis, Horizon and three small client apps without breaking a sweat.

1. Horizon needs Redis

Horizon only works with the Redis queue driver. Install it, publish the config, point the queue at Redis:

composer require laravel/horizon
php artisan horizon:install

# .env
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis

One thing people skip: if Redis is on the same box, check maxmemory-policy. For a queue you want noeviction. With allkeys-lru Redis will happily delete your pending jobs when memory gets tight and nobody gets an error. Silent data loss, my favourite.

2. The Horizon config we start every project with

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['high', 'default', 'emails'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 1,
            'maxProcesses' => 8,
            'balanceMaxShift' => 1,
            'balanceCooldown' => 3,
            'tries' => 3,
            'timeout' => 90,
            'memory' => 128,
        ],
    ],
],

Queue order matters: high is drained before default. We put password resets and OTP emails in high, PDF reports and exports in default. Nobody should wait for a login code because finance is exporting 40,000 rows.

3. The setting that made our jobs run twice

This one cost us an angry client and about 300 duplicate emails. In config/queue.php the Redis connection has retry_after, default 90 seconds. When a worker picks a job, Laravel reserves it for that many seconds. If the job is still running after retry_after, Laravel assumes the worker died and puts the job back on the queue. Another worker picks it up. Now it runs twice, and there is no exception anywhere, so you will not find it in the logs.

// config/queue.php
'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 180,
    'block_for' => null,
],

The rule from the docs, which I now repeat in every code review: the worker timeout must always be a few seconds shorter than retry_after. Our Horizon timeout is 90, retry_after is 180. If a job genuinely needs 5 minutes, it goes on its own queue with its own supervisor and bigger numbers, or better, it gets split into smaller jobs.

4. Supervisor keeps Horizon alive

; /etc/supervisor/conf.d/horizon.conf
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/current/artisan horizon
autostart=true
autorestart=true
user=deploy
redirect_stderr=true
stdout_logfile=/var/www/app/shared/storage/logs/horizon.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start horizon

stopwaitsecs should be longer than your longest job, otherwise Supervisor kills a worker in the middle of a job during a restart.

5. Deploys: terminate, don't forget

Workers are long running processes. They load your code once and keep it in memory. If you deploy and do not restart them, they will keep running yesterday's code, and you will spend an evening wondering why your fix "is not working". Last line of every deploy script:

php artisan horizon:terminate

Horizon finishes the current jobs, exits, Supervisor starts it again with the new code. Also schedule the snapshot so the metrics graphs in the dashboard actually have data (Laravel 11, in routes/console.php):

Schedule::command('horizon:snapshot')->everyFiveMinutes();

6. Lock the dashboard

In production, /horizon is protected by the viewHorizon gate in HorizonServiceProvider. Put real emails or a role check there. Do NOT "fix" the 403 by making the gate return true. I have seen job payloads with customer phone numbers sitting on an open Horizon page. Yaar.

What it costs

For reference, one of our client boxes: 2 vCPU, 4 GB, Redis + Horizon + 3 apps, about 40k jobs a day, load average under 0.6. Failed jobs we check every morning with php artisan queue:failed before chai. That is the whole system. Boring, cheap, and it has not paged me since January.

If you are still on queue:work inside screen, do one thing this weekend and move it to Supervisor. Thank me later 🐘

Written by

Ayesha Siddiqui

Laravel dev turned agency owner, Karachi. 11 people, mostly PHP, all of them better than me now. PHP 8 is good and I will die on this hill. Founder of PHP Still Pays 🐘

Get the best of #php_still_pays weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

11

Sign in to join the conversation.

Sign in to comment
Nadia Benali@nullbyte_nadia2 rep in PHP Still Pays
please, please do not "fix" the 403 on /horizon by making the gate return true. i have found open horizon dashboards on three bug bounty programs, one with full job payloads. bref, gate on email or on a role, voilà
8 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
three!! I knew it was common but not that common. thank you for confirming my paranoia Nadia
1 net votes
Nguyễn Minh Hiếu@homelab_hieu3 rep in PHP Still Pays
the noeviction part is very very important. I lost queue on managed redis because provider set allkeys-lru as default, jobs just disappear, no error. hope this help someone :)
6 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
exactly this. silent data loss is the worst kind, at least an exception has the decency to shout
6 net votes
Caio Nascimento@caio_backend#9 in PHP Still Pays
Good post. In Java world we have same problem with visibility timeout on SQS, message come back while first consumer is still processing. Different language, same bug kkkk
9 net votes
Rodel Bautista@rodel_java4 rep in PHP Still Pays
retry_after shorter than the job. Been there, with a different queue. Ay naku, the duplicate emails to customers.
6 net votes
Bayu Setiawan@bayu_laravel#2 in PHP Still Pays
Thank you mbak. For client with shared hosting, no Supervisor, I run queue:work --stop-when-empty from cron every minute. Not as good as Horizon but works for small koperasi.
6 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
that is the poor man's version and honestly for 50 jobs a day it is fine. run it from the scheduler with ->withoutOverlapping() so cron does not start a second worker while the first one is still busy, and add --max-time=50
5 net votes
Oluwatobi Adeyemi@tobi_freelance#6 in PHP Still Pays
Bookmarking. I have been running queue:work inside screen on one client server since 2021 like an animal. This weekend I go fix am
4 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
tobi 😐 screen. in 2026. bas karo
4 net votes
Faizan Qureshi@faizan_php#4 in PHP Still Pays
Ayesha baji ne mere sawaal pe is post ka link diya, sab clear ho gaya. retry_after 90 tha aur job 4 minute ki thi. maazrat, purani post pe comment kar raha hoon, behad shukriya 🙏
7 net votes