Running Laravel queue workers and Horizon under systemd

Run queue:work and Horizon as systemd services instead of Supervisor: template units, restarts on deploy, journalctl logs and memory limits.

Jacob Søndergaard ·

Laravel's documentation runs queue workers under Supervisor. Every Ubuntu server already runs systemd, which does the same job: it starts the worker at boot, restarts it when it exits, stops it gracefully, and keeps its output. This post runs queue:work and Horizon as systemd services, restarts them on every deploy, and sets the limits that keep a stuck job or a leaking worker from taking the server with it.

The server is the one from the zero-downtime deploys post: the app lives in /var/www/example.com with a current symlink to the live release, and runs as its own account, example. Everything was tested on Ubuntu 26.04 with PHP 8.5 and 24.04 with PHP 8.3. On 24.04, write 8.3 where the files say 8.5.

Why systemd instead of Supervisor

Supervisor works, and if you already run it there is no emergency. On a new server it is one more daemon with its own config format and its own log files, running under systemd anyway. systemd covers the same ground directly:

  • Logs in the journal. Worker output lands in journald next to everything else on the server, with timestamps and rotation already handled.
  • Resource limits. MemoryMax= is a cgroup limit the kernel enforces, including on child processes. Supervisor has no memory limit of its own.
  • Template units. One file describes a worker, and @1 to @N run as many as you want.

The settings map across directly. Supervisor's stopwaitsecs is systemd's TimeoutStopSec, numprocs is the number of template instances you enable, and autorestart is Restart=always.

A systemd unit for queue:work

This is a template unit, so the same file runs one worker or ten. Save it as /etc/systemd/system/example-queue@.service:

/etc/systemd/system/example-queue@.service
[Unit]
Description=example.com queue worker %i
After=network.target
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
User=example
Group=example
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php8.5 artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600 --memory=256
Restart=always
RestartSec=3
TimeoutStopSec=90
MemoryMax=512M

[Install]
WantedBy=multi-user.target

What each line is for:

  • User and Group. The site's own account, so the worker can read shared/.env and writes logs the web process can append to. More on this at the end.
  • WorkingDirectory. The current symlink. systemd resolves it once, when the worker starts, so each worker is pinned to the release that was live at that moment. That is what makes the restart on deploy work.
  • ExecStart. The versioned PHP binary, not plain php, so installing a second PHP version later does not quietly move the workers onto it.
  • --tries=3 and --sleep=3. Three attempts per job, and a three-second pause when the queue is empty.
  • --timeout=60. Kills a job that runs longer than 60 seconds. Keep it below retry_after in config/queue.php (90 by default), or a slow job is handed to a second worker while the first is still running it.
  • --max-time=3600 and --memory=256. The worker exits cleanly after an hour, or after a job that leaves it above 256 MB, and systemd starts a fresh one. This is the cheap fix for slow memory leaks.
  • Restart=always. Not on-failure. queue:restart and --max-time both make the worker exit with status 0, and on-failure does not restart a clean exit. In a test with Restart=on-failure, one queue:restart left all three workers stopped for good.
  • StartLimitIntervalSec and StartLimitBurst. A ceiling on crash loops. systemd's default of five starts in ten seconds can never trigger with a three-second RestartSec, so a worker that cannot boot (a missing extension, a syntax error) would restart forever. Ten in five minutes stops it after about half a minute.
  • TimeoutStopSec=90. How long systemctl stop waits for the current job before it kills the worker. Keep it above --timeout.
  • MemoryMax=512M. A hard ceiling. A worker that crosses it is killed mid-job by the kernel and the job is retried later. Set it above --memory so the graceful limit is reached first.

Template units for several workers

Write the file, check it, and start three workers:

sudo tee /etc/systemd/system/example-queue@.service > /dev/null <<'EOF'
[Unit]
Description=example.com queue worker %i
After=network.target
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
User=example
Group=example
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php8.5 artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600 --memory=256
Restart=always
RestartSec=3
TimeoutStopSec=90
MemoryMax=512M

[Install]
WantedBy=multi-user.target
EOF
sudo systemd-analyze verify /etc/systemd/system/example-queue@.service
sudo systemctl daemon-reload
sudo systemctl enable --now example-queue@{1..3}
systemctl status 'example-queue@*'

On 26.04, systemd-analyze verify also prints warnings about CPUAccounting= in Ubuntu's own xfs_scrub units. They are unrelated to yours. The three workers run as the site account, each in the live release:

$ ps -o user=,pid=,args= -C php8.5 | grep queue:work
example    58258 /usr/bin/php8.5 artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600 --memory=256
example    58259 /usr/bin/php8.5 artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600 --memory=256
example    58260 /usr/bin/php8.5 artisan queue:work --sleep=3 --tries=3 --timeout=60 --max-time=3600 --memory=256

$ sudo readlink /proc/58258/cwd
/var/www/example.com/releases/20261021114636

To run fewer, disable an instance. To run more, enable @4. A separate pool for one queue is a second template file with --queue=emails on its ExecStart.

sudo systemctl disable --now example-queue@3

Laravel Horizon with systemd

Horizon needs Redis and the pcntl and posix extensions. Ubuntu's PHP CLI has both built in, and php-redis adds the Redis client:

sudo apt install redis-server php-redis
php -m | grep -E 'pcntl|posix|redis'

Add Horizon to the app with composer require laravel/horizon and php artisan horizon:install, set QUEUE_CONNECTION=redis in shared/.env, and deploy. Horizon replaces the queue:work units, so disable those first. It starts and balances its own workers, which means systemd supervises a single process:

/etc/systemd/system/example-horizon.service
[Unit]
Description=example.com Horizon
After=network.target redis-server.service
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
User=example
Group=example
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php8.5 artisan horizon
Restart=always
RestartSec=3
TimeoutStopSec=90

[Install]
WantedBy=multi-user.target
sudo tee /etc/systemd/system/example-horizon.service > /dev/null <<'EOF'
[Unit]
Description=example.com Horizon
After=network.target redis-server.service
StartLimitIntervalSec=300
StartLimitBurst=10

[Service]
User=example
Group=example
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php8.5 artisan horizon
Restart=always
RestartSec=3
TimeoutStopSec=90

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now example-horizon
sudo -u example php8.5 /var/www/example.com/current/artisan horizon:status

Right after a start, horizon:status can still say Horizon is inactive for a few seconds while the master registers itself. ps shows the tree Horizon builds under the unit: the master, one supervisor per entry in config/horizon.php, and the workers.

example  59597      1 /usr/bin/php8.5 artisan horizon
example  59601  59597 /usr/bin/php8.5 artisan horizon:supervisor ...:supervisor-1 redis ...
example  59607  59601 /usr/bin/php8.5 artisan horizon:work redis --name=default ...
example  59608  59601 /usr/bin/php8.5 artisan horizon:work redis --name=default ...

TimeoutStopSec plays the same role as above. Set it higher than the largest timeout in config/horizon.php. With a 15-second job running, systemctl stop example-horizon took 13 seconds: Horizon let the job finish, then exited.

Restarting workers after a deploy

A worker loads your code once, at boot. After a deploy it keeps running the previous release until it restarts, which for a busy worker can be an hour. The deploy has to tell it. For queue:work that is:

php artisan queue:restart

It writes a timestamp to the cache. Each worker checks it between jobs, finishes the one it is on, and exits. systemd starts it again in current, now the new release. In a test with a 10-second job running during a deploy, the job finished in the old release, and three seconds later all three workers were running from the new one:

Oct 21 13:47:53 systemd[1]: example-queue@1.service: Deactivated successfully.
Oct 21 13:47:56 systemd[1]: example-queue@1.service: Scheduled restart job, restart counter is at 1.
Oct 21 13:47:56 systemd[1]: Started example-queue@1.service - example.com queue worker 1.

For Horizon, replace that line with php artisan horizon:terminate. Horizon waits for running jobs, exits, and systemd starts it on the new release. In the zero-downtime deploy script this is the line after the health check passes, so workers never start on a release that is about to be rolled back.

Two things this depends on. The cache store has to be shared between the deploy and the workers: database, redis, or the file store in the shared storage/, but not array. And the units need Restart=always, as above.

sudo systemctl restart 'example-queue@*' also works and is just as graceful, but it needs root, and it blocks the deploy until the longest running job is done. queue:restart returns at once and needs no privileges.

Reading logs with journalctl

Whatever queue:work prints goes to the journal: one line when a job starts and one when it finishes or fails.

# Follow every worker at once
journalctl -u 'example-queue@*' -f

# One worker, the last hour
journalctl -u example-queue@2 --since '1 hour ago'

# Horizon
journalctl -u example-horizon -f

# Starts, stops and crashes, without the job lines
journalctl -u 'example-queue@*' -t systemd
2026-10-21 11:47:30 App\Jobs\SlowJob ............................... RUNNING
2026-10-21 11:47:30 App\Jobs\SlowJob ............................... RUNNING
2026-10-21 11:47:34 App\Jobs\SlowJob ............................... 4s DONE
2026-10-21 11:47:34 App\Jobs\SlowJob ............................... 4s DONE

Exceptions from failed jobs go to Laravel's own log channel, so the stack trace is in shared/storage/logs, not the journal. The queue documentation lists the queue:work options this post does not use.

Memory and timeout limits

Four numbers, and each has to stay below the next:

  • --timeout below retry_after, so a job is never run twice at once.
  • --timeout below TimeoutStopSec, so stopping a worker lets its job finish.
  • --memory below MemoryMax, so a worker recycles itself before the kernel kills it.
  • MemoryMax times the number of workers within what the server can spare after PHP-FPM and the database.

If you test graceful stops yourself, do not use a job that calls sleep(). The stop signal cuts PHP's sleep() short, so the job appears to finish instantly and tells you nothing. A loop that checks the clock behaves like real work.

Running workers as the site's own user

The environment file is mode 600 and owned by example. A worker running as www-data or any other account cannot read it. A worker running as root can, which is the worse outcome: the first time it writes a log line it creates laravel.log owned by root, and from then on everything running as the site account fails to log. A queued job on the test server failed like this:

In StreamHandler.php line 168:
  The stream or file "/var/www/example.com/releases/20261021114934/storage/logs/laravel.log"
  could not be opened in append mode: Failed to open stream: Permission denied

So never start a worker with sudo php artisan queue:work, not even to test something. If it has already happened, give the files back:

sudo chown -R example:example /var/www/example.com/shared/storage

The same applies to the scheduler's crontab entry, which has to belong to the site account for the same reason.

What Provisore does here

In Provisore a queue worker or Horizon is a daemon: a command, a working directory and a number of processes. Provisore writes the systemd unit, a plain unit for one process or a template unit run as @1 to @N for more, with Restart=always and a start limit. A daemon tied to a site runs as the site's account with the site's PHP first on its path, and a deploy restarts it on the new release before the health check, so a worker that cannot start fails the deploy and rolls it back. Horizon has no special integration; it is supervised like any other daemon. More on the Laravel page.

Run it on a server you own

Provisore provisions the server, deploys on push and keeps watch, on your own provider account.