Laravel on a VPS: Storage, Queues, Scheduler
Run Laravel on a VPS with a writable storage tree, a systemd queue worker, and cron for schedule:run so logs, jobs, and scheduled tasks survive every deploy.
Netbay Cloud Team
Netbay Engineering
On this page
Laravel on a VPS is not just nginx plus artisan serve. The framework assumes three persistent surfaces: a writable storage tree, a queue worker that outlives any HTTP request, and a scheduler that runs every minute. Miss any of those and you get 500s on log writes, jobs that never fire, and kernel:schedule entries that only run when someone hits a web route. This post is the production layout on Ubuntu 24.04 with PHP-FPM, not Sail, not a shared-hosting panel.
The app lives at /var/www/app. The public web root is /var/www/app/public. Releases can rotate, but storage and the environment file must not vanish when you check out a new tag. That is the whole operational model.
Storage is a directory contract, not a cloud product
Laravel writes logs, compiled views, cache files, and uploaded disks under storage/. It writes package manifests and compiled config under bootstrap/cache/. Both trees must be writable by the PHP-FPM user (www-data) and by queue workers running as the same user. They must survive deploys. If you rsync a release over /var/www/app and wipe storage/logs, you lose history and you race the next request.
On a single VPS the fix is simple: keep storage/ and .env outside the release, or never delete them during git checkout. A common layout is /var/www/app/releases/TIMESTAMP for code and /var/www/app/shared/storage plus /var/www/app/shared/.env, with symlinks from the current release. If you deploy in place with git pull, at least add storage/ and .env to a gitignore and never force-clean them.
Create the directories Laravel expects, then link the public disk:
cd /var/www/app
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache
chown -R deploy:www-data storage bootstrap/cache
find storage bootstrap/cache -type d -exec chmod 2775 {} ;
find storage bootstrap/cache -type f -exec chmod 664 {} ;
sudo -u www-data php artisan storage:link
sudo -u www-data php artisan config:cachestorage:link creates public/storage -> ../storage/app/public. Without it, uploaded files are invisible to nginx. Do not chmod 777. 2775 on directories with group www-data is enough when the deploy user and FPM share that group.
APP_DEBUG must be false. LOG_CHANNEL=stack and LOG_LEVEL=info are enough. Point FILESYSTEM_DISK at local unless you have a reason. There is no object storage product to wire here; a local disk on High-Speed SSD is the production store for a single VPS.
Queue workers are long-running PHP processes
dispatch() does not run the job. A worker does. php artisan queue:work is a daemon. Running it under nohup in a tmux session is how you lose jobs at the first reboot. systemd is the supervisor.
Use Redis or database as the queue connection. database works with no extra daemon and is fine on a 2 GB box if job volume is modest. Redis is better once you have many workers. Do not use the sync driver in production; it runs jobs inside the HTTP request and throws away the point of a queue.
[Unit]
Description=Laravel queue worker
After=network.target php8.3-fpm.service
[Service]
User=www-data
Group=www-data
Restart=always
RestartSec=3
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php /var/www/app/artisan queue:work database --sleep=1 --tries=3 --max-time=3600 --timeout=90
[Install]
WantedBy=multi-user.target--max-time=3600 recycles the worker once an hour so PHP memory leaks do not live forever. --timeout=90 must be less than retry_after in config/queue.php or you will double-run jobs. After a deploy, systemctl restart laravel-worker so the process reloads new code. An FPM reload does not restart queue workers. They are separate PHP processes with their own opcache.
Horizon is optional. On a 2 GB Lucknow VPS a single queue:work is usually enough. Horizon wants Redis and more RAM. Add it when you have named queues and metrics worth a dashboard.
Failed jobs land in failed_jobs. Run php artisan queue:failed on a schedule or alert on the table count. Do not ignore it.
The scheduler is cron, not a web ping
Laravel 11 still expects * * * * * php artisan schedule:run. That cron line is the only thing that fires kernel schedules, billing ticks, and telescope:prune. Do not rely on a URL hitting /schedule. Put this in /etc/cron.d/laravel as root, with the user field set to www-data:
* * * * * www-data /usr/bin/php /var/www/app/artisan schedule:run >> /var/log/laravel-schedule.log 2>&1Without the user field in a system crontab, the job runs as root and writes root-owned files into storage/logs. Use www-data. Keep the crontab pointed at the current release path or at a stable /var/www/app symlink.
Disable any web-based scheduler plugin. If you later add a second web node you would fire schedules twice; even on one node the HTTP hammer is a poor clock.
APP_KEY must be set, generated once, and stored in the shared .env. Rotate it only when you intend to invalidate encrypted cookies and queued payloads. php artisan key:generate --show can print a key you paste; do not run key:generate against a live .env by accident.
cache:config and route:cache belong in the deploy script, after composer install --no-dev. view:cache too. After those, restart the worker and reload FPM. migrate --force runs once per release, not from a Composer script, so a failed migration does not leave vendor half-installed.
Database, sessions, and mail
MySQL or MariaDB on the same VPS is normal at this size. Bind the database to localhost. SESSION_DRIVER=database or redis; file sessions on a rotating release directory will log users out on deploy. MAIL_MAILER=smtp with credentials in .env, never in git.
TrustProxies should include the nginx hop. If you terminate TLS on nginx, set URL generator APP_URL to https://app.example.com so queued notifications do not render http links.
Intel Xeon Platinum plus High-Speed SSD handles this stack on 2 GB if you keep MySQL innodb_buffer_pool modest (256M) and FPM max_children in the 8 to 12 range. That is a later post. Here, get storage writable, a worker supervised, and cron running.
Takeaway
Laravel in production is a web pool, a worker unit, and a cron clock sharing one storage tree and one .env. Treat those three as the app. Code is replaceable. storage/ is not.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and run this Laravel layout in Lucknow - netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance