Swap on a Laravel VM: Survival Mechanism, Not a Fix
Swap on a production VM isn't shameful — it's a tuning decision. Here's how I configure it and why I treat it as a canary, not a crutch.
Every Laravel project I've deployed on a small VPS has eventually hit the same wall: the OOM killer shows up unannounced, takes out php-fpm or MySQL, and the site goes dark. Swap didn't cause that. But the absence of swap made it uglier than it had to be. Here's how I think about it after two-plus decades of keeping production boxes alive.
What Swap Actually Does (and Doesn't Do)
Swap is overflow space on disk that the kernel uses when RAM is exhausted. It is not fast. On a spinning disk it's catastrophic. On a modern NVMe SSD it's tolerable for brief spikes — we're talking 200–500 MB/s sequential versus 20–50 GB/s for RAM. You will feel it.
What it buys you is time. Instead of the OOM killer randomly murdering a process the moment you breach available RAM, the kernel can push cold pages — stuff that hasn't been touched recently — out to swap and keep your critical processes breathing. On a 2 GB DigitalOcean droplet running Laravel, Horizon, MySQL, and Redis, that buffer is the difference between a graceful degradation and a 3 AM phone call.
The mistake I see people make is treating swap as a substitute for proper memory allocation. It isn't. If your box is regularly hitting swap, you have a resizing problem, a memory leak problem, or a queue worker configuration problem. Swap just keeps the patient alive long enough to diagnose.
My Standard Configuration
For a Laravel production VM — typically Ubuntu 22.04 LTS, anywhere from 1–8 GB RAM — here's exactly what I run.
1. Create the swapfile
# Size: 2x RAM for boxes under 4 GB, 1x for larger ones
# I use fallocate because dd is slow on large files
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
Make it survive reboots:
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Verify:
sudo swapon --show
free -h
2. Tune swappiness
This is where most guides stop, and it's where the real tradeoffs live.
vm.swappiness controls how aggressively the kernel moves pages to swap. Default is 60. For a Laravel app on a VPS, I drop it to 10.
# Apply immediately
sudo sysctl vm.swappiness=10
# Persist across reboots
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.d/99-laravel.conf
A value of 10 tells the kernel: exhaust RAM before touching swap. The kernel will still use swap — the value isn't a hard threshold — but it'll be reluctant. On a dedicated app server where I want RAM used aggressively for opcache and MySQL buffers, I want that reluctance.
I've seen people set it to 0. That's almost never right. A value of 0 in Linux kernels before 3.5 meant "never swap"; in modern kernels it means "avoid swap unless absolutely necessary but still allow it." The semantic is close enough to be dangerous if you're cargo-culting old documentation.
3. Tune vfs_cache_pressure
Less talked about, but it matters. This controls how aggressively the kernel reclaims memory used for filesystem caches — the VFS cache, dentry cache, inode cache. Default is 100.
sudo sysctl vm.vfs_cache_pressure=50
echo 'vm.vfs_cache_pressure=50' | sudo tee -a /etc/sysctl.d/99-laravel.conf
Setting it to 50 tells the kernel to be half as aggressive about dumping filesystem caches compared to page cache. On a Laravel app serving cached Blade views, config files, and route caches from disk, keeping those dentries warm is a real win. I've measured this with perf stat on a print management app I run — the reduction in stat() syscalls is measurable when the dentry cache is healthy.
4. Monitor it — treat swap use as a signal
I add this to every VM I manage. A simple cron that logs swap usage so I can see trends:
# /etc/cron.d/swap-monitor
* * * * * root free -m | awk 'NR==3{print strftime("%Y-%m-%d %H:%M:%S"), $3}' >> /var/log/swap-usage.log
Or if you're running Laravel Forge or similar with server monitoring, set an alert threshold. I personally set mine at 512 MB on a 2 GB box. If I'm burning 512 MB of swap regularly, something needs to change — a worker count needs to come down, queue batches need to shrink, or the box needs more RAM.
The Gotchas That Bit Me
SSD lifespan on cheap VPS hosts. A lot of budget VPS providers run shared storage. Persistent swap writes on shared NVMe are not your problem alone — they're everyone's problem. I had a client on a $6/month Vultr box where the box itself was fine but the underlying storage was clearly contended. Swap under load felt like a spinning disk. Upgrade or move.
fallocate on some filesystems. fallocate doesn't work on all filesystems. On ext4 you're fine. On btrfs, fallocate creates a sparse file that mkswap will complain about. Use dd instead:
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 status=progress
Yes it's slower to create. Yes it matters for correctness.
PHP-FPM pool sizing. This is the real source of memory pressure on most Laravel boxes I've looked at. The default pm.max_children is often set too high for the available RAM. I calculate it: take available RAM (minus MySQL buffer pool, Redis memory, OS overhead — roughly 400–600 MB), divide by average PHP-FPM worker memory. Check worker memory with:
ps -o pid,rss,command ax | grep php-fpm | sort -b -k2 -rn | head -20
RSS is in kilobytes. If each worker is eating 60 MB and I have 1.2 GB left for PHP, max_children should be 20, not 50. Swap won't save you from 50 workers each trying to load a 15 MB dataset into memory.
Laravel Horizon queue memory limits. Horizon has a memory config option per queue. Default is 128 MB per worker. I've seen jobs that pull large result sets from a LIMS or a biotech instrument data feed balloon to 300+ MB per worker, and nobody noticed because swap was silently absorbing it. Set your Horizon memory limit low enough that it forces you to notice:
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'processes' => 3,
'tries' => 3,
'memory' => 128, // MB — Horizon will restart the worker if exceeded
],
],
],
Horizon's memory limit causes a graceful worker restart, not a hard kill. That's good. But if workers are restarting constantly, you've found a real problem swap was masking.
MySQL innodb_buffer_pool_size. MySQL is greedy. On a shared app+db server I set the buffer pool to 25–30% of total RAM. On a 4 GB box:
# /etc/mysql/mysql.conf.d/mysqld.cnf
innodb_buffer_pool_size = 1G
I've inherited boxes where someone left MySQL at its default and it was attempting to use 70% of RAM by itself. Then php-fpm couldn't get pages, swap thrashed, and the whole thing fell over.
When I'd Reach for This (and When I Wouldn't)
I always configure swap on every new VM, even ones with 8+ GB RAM. The overhead is nothing. A 2 GB swapfile on a 200 GB SSD is rounding error. The benefit — keeping the OOM killer in its cage during a traffic spike or a runaway migration — is real.
I lean on it most heavily for:
- Small to mid-size Laravel apps on shared-tenant VPS hosting where I can't instantly resize
- Apps with occasional batch jobs or imports that spike memory temporarily
- Staging servers that I want to survive developer mistakes
I'd still configure swap but treat it differently for:
- High-traffic apps that need consistent latency. Here swap is a last resort. The real fix is horizontal scaling or memory profiling.
- Apps running on RAM-constrained containers (Docker/K8s). Container swap behavior is controlled by the runtime, not sysctl. Different problem.
- Database servers. If MySQL is hitting swap, your buffer pool is misconfigured or the box is genuinely undersized. No amount of swappiness tuning saves you there.
Closing
Swap on a Laravel production VM is a cheap insurance policy with a known premium: latency when you actually use it. Configure it thoughtfully, tune swappiness down, watch the metrics, and treat any consistent swap usage as a bug to fix rather than a feature to rely on. The canary is singing — listen to it.
Need help shipping something like this? Get in touch.