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.
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.
allkeys-lru and noeviction Can't Coexist in One Instance
These two workloads naturally need opposing policies.
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.
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.
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?"
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
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
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
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.
`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.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first