EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Laravel Queues on Shared Hosting — Don't Share Redis with Cache
Linux

Laravel Queues on Shared Hosting — Don't Share Redis with Cache

by Ton-Or ·17 Aug 2026·10 min read
Laravel Queues on Shared Hosting — Don't Share Redis with Cache

A queue job that disappears silently hurts more than one that fails, because a failed job still leaves a row to retry, while an evicted job leaves no trace at all — and that's what happens when you put jobs on a Redis instance configured for cache.

It was time to enable queues for a Laravel app running on shared hosting. The queued work was nothing heavy — email verification messages, processing payment slips after customers upload them, just a few dozen jobs a day. The server already had a separate Redis instance for each user (I covered that setup in my article on Redis per user). The app was already using that Redis instance for cache and sessions. So the first proposal on the table was the one that looked cleanest: move the queue onto Redis too, completing the stack, and separate them with different REDIS_DB numbers.

I rejected that proposal, and this article explains all the reasons why, along with things you can put straight to use — a systemd unit to manage the worker instead of Supervisor, and the steps to take during deployment.

The usernames, paths, and database names in this article are examples. The structure and config are real.

Separate REDIS_DB Values Don't Separate Memory

The most common misunderstanding is thinking that REDIS_DB=0/1/2 separates things like actual databases. It doesn't — these are just key namespaces. All the important things are not separated by DB.

How jobs disappearCache in DB 1 growsNormal daily activity→Hits maxmemoryInstance-wide limit→allkeys-lru evictsSelects from all DBs→Jobs in DB 2 vanishNo log, no error

maxmemory and maxmemory-policy are both instance-level settings. There is no command to set a separate limit for each DB number. Two lines are enough to prove it.

redis-cli -s /home/appuser/.redis/redis.sock CONFIG GET maxmemory maxmemory-policy
# 1) "maxmemory"
# 2) "125829120"
# 3) "maxmemory-policy"
# 4) "allkeys-lru"

redis-cli -s /home/appuser/.redis/redis.sock INFO keyspace
# db0:keys=812,expires=812,avg_ttl=0
# db1:keys=5140,expires=5140,avg_ttl=0

That means whenever cache in DB 1 grows enough to hit the instance limit, eviction candidates aren't selected only from DB 1 — they're selected from the entire instance. Jobs waiting in DB 2 are just as eligible to be discarded as ordinary cache keys.

REDIS_DB only separates key namespaces1 Redis instance per usermaxmemory 125829120maxmemory-policy allkeys-lruDB 0812 keyDB 1cache grows: 5,140 keyDB 2Planned queue jobsevictLimit and eviction apply to the whole instance, not each DBWhen DB 1 cache hits the limit, eviction selects from all DBs
REDIS_DB 0/1/2 are just key namespaces, while maxmemory and allkeys-lru apply to the whole instance. If cache in DB 1 grows to the limit, eviction selects from all DBs, including jobs in DB 2.

allkeys-lru and noeviction Can't Coexist in One Instance

These two workloads naturally need opposing policies.

Cache needs allkeys-lru

Old entries can always be discarded

Losing cache isn't a big deal. At worst, I query again and things slow down a little. What's worse is Redis filling up and refusing writes, so go ahead and discard entries that aren't used much.

Queues need noeviction

Nothing can be discarded

One lost job means one real task lost. A customer doesn't get a verification email, a payment slip goes unchecked, and nobody knows it happened. When memory is full, rejecting writes is better than silently losing jobs.

Only one value can be chosen per instance. There's no compromise. If I set noeviction to preserve jobs, once cache fills the available memory, the entire app starts getting OOM command not allowed when used memory > 'maxmemory' every time it writes cache or sessions — the whole site goes down. If I set allkeys-lru to keep cache working normally, I'm accepting that jobs can disappear.

What happens when a job is evicted

No exception, no line in laravel.log, no row in failed_jobs — because eviction isn't a failure as far as the queue system is concerned. It's just a key that disappeared. The worker never saw it, and retries never kick in. The result is a customer complaining about a missing verification email while I spend all day searching logs and finding nothing.

Comparing Risks Before Deciding

I weighed the two options by one criterion: "How much will it hurt if this goes wrong?" rather than "Which one looks more modern?"

Redis with allkeys-lru

Fast, but jobs can vanish silently

Jobs are picked up immediately, with no query · But jobs can disappear under memory pressure without a trace · Memory needs constant monitoring · More customers mean more cache and a greater chance of losing jobs

database on MariaDB

A little slower, but jobs stay put

Jobs live in a table, protected by transactions · A delay of no more than one second per poll · Query cost is practically zero at this volume · Pending work can be inspected with plain SQL

50/dayActual job volume
1 secDelay before pickup
0Acceptable lost jobs
10.6Minimum MariaDB version

At just a few dozen jobs a day, Redis's speed advantage barely matters. The difference is milliseconds for work the user isn't sitting there waiting for anyway. The downside of losing work, though, matters a great deal. My conclusion was that at this scale, I use only the database driver. A much heavier workload would be a different discussion.

QUEUE_CONNECTION=database

# cache/session ยังอยู่ Redis ตามเดิม — สองตัวนี้มี TTL อยู่แล้ว โดน evict ก็ไม่เจ็บ
CACHE_STORE=redis
SESSION_DRIVER=redis
REDIS_CLIENT=phpredis
REDIS_HOST=/home/appuser/.redis/redis.sock
REDIS_PORT=0

Why the database Driver Holds Up with Multiple Workers

The classic concern with database queues is multiple workers competing for the same job and colliding. This really was a problem in the past, because workers had to lock rows and wait on one another. But MariaDB has supported FOR UPDATE SKIP LOCKED since 10.6, and Laravel uses it automatically.

-- สาระสำคัญของสิ่งที่ Laravel ยิงตอน worker หยิบงาน
SELECT * FROM jobs
 WHERE queue = 'default'
   AND (reserved_at IS NULL AND available_at <= 1786000000)
 ORDER BY id ASC
 LIMIT 1
 FOR UPDATE SKIP LOCKED;

SKIP LOCKED means "If a row is already reserved, skip it; don't wait" — the second worker skips the job reserved by the first and immediately picks up the next one. No waiting, no deadlock. Check the version before enabling it.

mysql -e 'SELECT VERSION();'
# 10.6.25-MariaDB

If the server is still on 10.3 or 10.4, don't run multiple workers simultaneously yet. Stick to one worker for now; it's safer.

Running the Worker with systemd Instead of Supervisor

Laravel's documentation recommends Supervisor, but on a server that already has systemd, I see no reason to install another process manager and add another layer to maintain. Everything Supervisor does (starting at boot, restarting after a crash, collecting logs), systemd can do too, and I can read the logs directly through the journal.

# /etc/systemd/system/app-worker.service
[Unit]
Description=Laravel queue worker (app.example.com)
After=network.target mysqld.service
Requires=mysqld.service

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/home/appuser/app-backend
# ใช้พาธเต็มของ php เสมอ — PATH ของ service ไม่เหมือน shell ของ user
ExecStart=/usr/local/bin/php /home/appuser/app-backend/artisan queue:work database \
          --queue=default --sleep=3 --tries=3 --backoff=30 --max-time=3600
Restart=always
RestartSec=5
StandardOutput=append:/home/appuser/app-backend/storage/logs/worker.log
StandardError=inherit
# กันงานหลุดมือกลางคัน: ให้เวลา job ปัจจุบันทำจนจบก่อนค่อยดับ
KillSignal=SIGTERM
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now app-worker
systemctl status app-worker --no-pager
journalctl -u app-worker -n 50 --no-pager

The settings I spent the most time considering were --max-time=3600 and TimeoutStopSec=60. The first tells the worker to exit every hour so systemd can restart it. This achieves two things: memory leaks accumulated in PHP are cleared periodically, and new code after deployment is guaranteed to load within an hour, even if I forget to run any commands. The second gives an in-progress job time to finish. Without it, systemd kills the worker too soon, leaving the job stuck in the reserved state.

# ถ้าอยากรันหลายตัว ใช้ template unit แทน แล้ว enable ทีละเบอร์
# /etc/systemd/system/[email protected]  (ExecStart เหมือนเดิม)
# systemctl enable --now app-worker@1 app-worker@2

One cron Entry for the Scheduler

Scheduled tasks (schedule:run) are separate from queues, but both often get forgotten together. A single line in the user's crontab takes care of it.

* * * * * cd /home/appuser/app-backend && /usr/local/bin/php artisan schedule:run >> /dev/null 2>&1
The full php path matters more than it seems

On DirectAdmin with multiple PHP versions, cron's PATH and the login shell's PATH are different. Writing just `php artisan` can invoke a different php version from the one the website uses, leading to strange errors about missing extensions. Always specify `/usr/local/bin/php` explicitly in both cron and the unit.

Run queue:restart After Every Deployment

This is the step people miss most often. queue:work is a long-running process. It loads code into memory at startup, then keeps picking up jobs without reloading files. That means after new code is deployed to the website, the worker is still running old code in RAM. The symptom is that the website works correctly, but background jobs still use the previous version's logic. This is very hard to diagnose because every file on disk is already correct.

#!/bin/bash
# scripts/deploy.sh (ลำดับสำคัญ — queue:restart ต้องอยู่หลัง cache ทุกตัว)
set -e
cd /home/appuser/app-backend

composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart          # <- บอก worker ให้จบงานปัจจุบันแล้วดับตัวเอง
chmod 600 .env

queue:restart doesn't kill the process immediately. It simply places a signal in cache for the worker to see and exit gracefully after finishing its current job — then systemd, configured with Restart=always, starts a replacement that loads the new code. These two must go together. Without Restart=always, this command simply shuts the worker down.

The signal lives in cache — and cache lives on Redis

`queue:restart` writes a timestamp to cache, so if the worker's cache and the website's cache point to different places (different users, different sockets), the signal won't reach the worker. It will quietly keep running the old code. A simple check is to see whether the worker actually restarted: `systemctl show app-worker -p ActiveEnterTimestamp`

Verify the Whole Path Works

Before wrapping up, I dispatch one test job and check all three stages — it enters the table, gets picked up, and leaves the table empty.

php artisan tinker --execute="\App\Jobs\PingJob::dispatch();"
mysql appuser_shop -e 'SELECT id, queue, attempts, created_at FROM jobs;'
sleep 5
mysql appuser_shop -e 'SELECT COUNT(*) AS pending FROM jobs; SELECT COUNT(*) AS failed FROM failed_jobs;'
journalctl -u app-worker -n 20 --no-pager

To test the retry path too, write a temporary job that throws an exception, dispatch it, and check that it goes through all --tries=3 attempts and actually ends up in failed_jobs. That's the path I'll need when something breaks in production.

When Would I Move Back to Redis?

I'm not saying Redis queues are bad. They're very good in the right setting. I've set two criteria for myself — volume rises beyond a thousand jobs a day to the point where polling costs start to matter, or I need to spread workers across multiple machines. When I reach that point, the right approach is to request another, separate Redis instance configured with maxmemory-policy noeviction, with its own socket and config file. It isn't to go back to sharing the existing instance and hope a different DB number will help, because it won't.

# หน้าตาของ instance ที่จะเอาไว้ทำคิวโดยเฉพาะ (ตัวอย่าง)
port 0
unixsocket /home/appuser/.redis/redis-queue.sock
unixsocketperm 700
maxmemory 64mb
maxmemory-policy noeviction
appendonly yes

Anyone adding another instance needs to talk to the server administrator first, because it means another thing to maintain for every user, not just mine. For anyone still at a few dozen jobs a day like me, a database table and a single worker are enough. I'd rather spend the time saved on something else. If I've missed something in the explanation or made a typo, I'll come back and fix it later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first