Run Laravel Horizon on your own server with systemd

Horizon runs your Laravel queue workers for you and gives you a page to watch them: what's waiting, what failed, how long jobs take. On your own server, you need to keep it running, restart it after every deploy, and make sure strangers can't open that page.

We tested every step in this guide on Ubuntu 24.04 (PHP 8.3, Redis 7.0) and Debian 13 (PHP 8.4, Redis 8.0) with a new Laravel 13 app and Horizon 5.51, each in a fresh container with systemd. The outputs below are copied from those tests.

Before you start

1. Give your app its own name first

Look at APP_NAME in .env. A new Laravel app says APP_NAME=Laravel. Laravel uses this name to label everything it puts in Redis: the queues and all of Horizon's data. In our test, the queue was stored as laravel-database-queues:default and Horizon's data as laravel_horizon:….

That causes two problems:

  • Two apps on one server, both called "Laravel", share the same queue in Redis. One app's Horizon can pick up the other app's jobs.
  • Changing the name later breaks the queue. In our test, we changed APP_NAME to Shop while Horizon ran. New jobs went to a new queue that nobody watched, so they just waited. horizon:status said "Horizon is inactive" and horizon:terminate said "No processes to terminate", even though Horizon was running. Only a full restart (step 4) fixed it.

So pick a short, unique name now, before you install Horizon, and don't change it afterwards:

APP_NAME=Shop

And make sure the queue uses Redis:

QUEUE_CONNECTION=redis

2. Install Horizon

Run these as the site's user, in the app's folder. On a real project, do it on your computer and deploy the change like any other code:

cd /var/www/shop
sudo -u shop composer require laravel/horizon
sudo -u shop php artisan horizon:install
INFO  Horizon scaffolding installed successfully.

This adds config/horizon.php and app/Providers/HorizonServiceProvider.php. Horizon needs the PHP pcntl and posix parts. They come with PHP on Ubuntu and Debian; check with php -m | grep -E 'pcntl|posix'.

Open config/horizon.php and look at the production part:

'production' => [
    'supervisor-1' => [
        'maxProcesses' => 10,
        ...

Horizon starts more workers when the queue gets busy, up to maxProcesses. Each worker is a PHP process that holds memory. On a small server with 1 or 2 GB, lower it to 2 or 3. In our test, Horizon with one idle worker used about 110 MB.

3. Keep Horizon running with systemd

Horizon is one program that starts and watches all the workers. systemd starts it when the server boots and again whenever it exits. Create /etc/systemd/system/shop-horizon.service:

[Unit]
Description=Laravel Horizon for shop
After=network.target redis-server.service

[Service]
User=shop
Group=shop
WorkingDirectory=/var/www/shop
ExecStart=/usr/bin/php artisan horizon
Restart=always
RestartSec=5
TimeoutStopSec=300
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
  • User=shop: never run Horizon as root. Files it writes, such as logs, must belong to the site's user.
  • Restart=always: after a deploy, Horizon exits on purpose (step 4). always brings it back; on-failure would leave it stopped.
  • TimeoutStopSec=300: on stop, Horizon lets running jobs finish first. Make this longer than your longest job.

Start it, and make it start at boot:

sudo systemctl daemon-reload
sudo systemctl enable --now shop-horizon
sudo -u shop php artisan horizon:status
INFO  Horizon is running.

To see the workers it started:

sudo -u shop php artisan horizon:supervisors

In our test, a job sent with SayHello::dispatch() was done within a second, and the journal showed it:

sudo journalctl -u shop-horizon -f
App\Jobs\SayHello .............................. RUNNING
App\Jobs\SayHello ......................... 10.14ms DONE

We also stopped Redis for 15 seconds. Horizon logged "Lost connection", systemd restarted it, and once Redis was back, it ran jobs again without any help.

4. Restart Horizon after every deploy

Horizon's workers load your code and your .env once, when they start. They don't notice changes. In our test, we changed APP_URL in .env: all 8 jobs after that still used the old address. After a restart, they used the new one.

Add this line to the end of your deploy steps:

sudo -u shop php artisan horizon:terminate

Horizon lets running jobs finish, then exits. systemd starts it again 5 seconds later with the new code. In our test, the main process number changed (609 → 677) and the journal showed:

shop-horizon.service: Scheduled restart job, restart counter is at 1.
Started shop-horizon.service - Laravel Horizon for shop.
INFO  Horizon started successfully.

If horizon:terminate says "No processes to terminate" while systemd says Horizon is running, the app's name changed (step 1). Restart it fully instead:

sudo systemctl restart shop-horizon

To stop new jobs for a moment, for example during a database change, use horizon:pause and later horizon:continue. Jobs wait in Redis and nothing is lost.

5. Lock the dashboard

The dashboard lives at https://your-site/horizon. It shows job data, which can include e-mail addresses and other details from your app. With APP_ENV=production, nobody can open it by default. In our test, both /horizon and /horizon/api/stats gave 403.

Never leave APP_ENV=local on a live server. In our test, the dashboard opened for anyone, with no login, as soon as we set APP_ENV=local.

To let yourself in, log in to your app with your normal account and put your e-mail address in app/Providers/HorizonServiceProvider.php:

Gate::define('viewHorizon', function ($user = null) {
    return in_array(optional($user)->email, [
        'you@example.com',
    ]);
});

In our test, the user you@example.com was let in; another user and a visitor who wasn't logged in were not.

Optional: allow only your own IP address. This adds a second lock in Nginx, in front of Laravel. Add this to your site's server block, next to location /, with your own address:

location ^~ /horizon {
    allow 203.0.113.10;
    deny all;
    try_files $uri $uri/ /index.php?$query_string;
}

Then sudo nginx -t && sudo systemctl reload nginx. In our test, other addresses got Nginx's own 403 page, and the rest of the site wasn't affected. The dashboard's styles and scripts are built into the page, so nothing else needs to be allowed. Skip this if your own IP address changes often, or you'll lock yourself out. Behind Cloudflare, Nginx sees Cloudflare's address unless you set up the real visitor IP first (step 7 of our rate limiting guide).

6. Turn on the graphs

The "Metrics" page stays empty until Horizon saves a snapshot regularly. Add this to routes/console.php (the use line goes at the top, with the others):

use Illuminate\Support\Facades\Schedule;

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

This needs the Laravel scheduler to run every minute (see the scheduler guide). Check it's listed:

sudo -u shop php artisan schedule:list
*/5 * * * *  php artisan horizon:snapshot ...... Next Due: 1 minute from now

If something goes wrong

  • "Horizon is inactive" but systemd says running: the app's name changed, or the command ran with a different .env. Run sudo systemctl restart shop-horizon.
  • Jobs stay in "Pending": check QUEUE_CONNECTION=redis, and that the job goes to a queue Horizon watches (the queue list in config/horizon.php, default unless you changed it).
  • Horizon keeps restarting: read the reason with sudo journalctl -u shop-horizon -n 50. Often Redis isn't running, or a file in storage/ belongs to root (see file owners and permissions).
  • Old code still runs: you forgot horizon:terminate in the deploy.
  • Dashboard gives 403 for you too: you're not logged in to the app, or your e-mail isn't in the list exactly as it's stored.

Official sources

Next steps: make deploys safe with zero-downtime release folders, and keep an eye on the server with uptime monitoring.

ServerNest is being built to set up and watch queue workers for each Laravel site, including Horizon. This is planned, not ready yet. Join early access to try it first.

← Back to the security blog