log in
consulting hosting industries the daily tools about contact

The Signals That Say Your Single VM Is About to Buckle

By the time your server tells you it's out of headroom, you're already on fire. Here's what to watch before that moment.

I've run more single-server stacks than I can count, and the failure mode is always the same: everything looks fine until it isn't, and by the time a metric pegs 100% you're already explaining an outage to a client. The trick is reading the signals two or three weeks before that moment.

This isn't a post about microservices or Kubernetes. Most of what NWOS ships runs on a single well-tuned VM — Laravel app, Nginx, PHP-FPM, Postgres, Redis, queue workers, cron, the whole stack. For small-to-mid businesses that's appropriate. It's cheaper, simpler to reason about, and easier to back up. But every single-VM deployment has a ceiling, and that ceiling tends to arrive quietly.

Why Vertical Scaling Feels Safe (Until It Doesn't)

Adding CPU and RAM to a box is fast. DigitalOcean, Linode, Hetzner — you can resize upward in a few minutes. So the instinct is to keep doing that. Double the RAM, double the vCPUs, repeat. The problem is that the relationship between hardware and capacity isn't linear. You get diminishing returns, and more importantly, some bottlenecks don't respond to bigger hardware at all.

I had a client in healthcare — running a Laravel app that managed patient intake forms and appointment scheduling — where we'd scaled the Droplet to 16 GB RAM and 8 vCPUs. Response times were still spiking at 10 AM and 2 PM every day. The box had headroom on CPU. The issue was PHP-FPM pool exhaustion combined with slow Postgres queries that held connections open too long. More RAM wasn't going to fix that. The shape of the problem had changed, and a second box — specifically, moving the database off to its own VM — was the right move.

That's the core lesson: vertical scaling solves resource starvation. It does not solve architectural contention.

The Signals I Actually Watch

1. PHP-FPM Pool Saturation (Not CPU Load)

This is the one that bites PHP shops the most. You can have 20% CPU utilization and still be dropping requests because your FPM pool ran out of workers.

# Check active vs idle workers right now
sudo -u www-data php-fpm-status-check 2>/dev/null || \
  curl -s http://127.0.0.1/fpm-status?full | grep -E 'active|idle|max children'

Better yet, expose the FPM status endpoint and scrape it with something like Telegraf or a simple cron that writes to a log:

// artisan command: app/Console/Commands/FpmStatusSnapshot.php
public function handle()
{
    $raw = file_get_contents('http://127.0.0.1/fpm-status?json');
    $data = json_decode($raw, true);

    Log::channel('fpm_metrics')->info('fpm_snapshot', [
        'active_processes'    => $data['active processes'],
        'idle_processes'      => $data['idle processes'],
        'max_children_reached'=> $data['max children reached'],
        'listen_queue'        => $data['listen queue'],
    ]);
}

The field that matters is max children reached. If that counter is incrementing daily, you are already shedding requests. Users are seeing timeouts or 502s at peak. Adding vCPUs will not fix this if the bottleneck is DB query latency keeping workers occupied.

When listen queue starts climbing above zero regularly, that's the loudest signal short of an actual outage.

2. Postgres Connection Count Creeping Toward the Limit

-- run this on your DB regularly
SELECT count(*), state
FROM pg_stat_activity
GROUP BY state
ORDER BY count DESC;

Postgres has a max_connections setting. Default is often 100. Every PHP-FPM worker that hits the DB holds a connection for the duration of the request. If you're at 70-80% of max_connections at peak, you're close. Hitting the limit causes FATAL: sorry, too many clients already — which cascades into Laravel throwing a 500 with a DB exception.

The short-term fix is PgBouncer in transaction mode. The medium-term signal is that you've installed PgBouncer and you're still watching connection counts climb. That's when I start talking to the client about a dedicated DB VM.

3. I/O Wait Above ~15% During Business Hours

iostat -x 1 10
# or just
top  # look at the 'wa' column in the CPU line

I/O wait means CPUs are sitting idle waiting on disk. On a single VM, your web server, app, database, and queue workers are all competing for the same disk I/O. Postgres is especially hungry — it does a lot of sequential and random reads depending on your query patterns.

If I see wa consistently above 15% during peak hours, the first thing I check is whether Postgres is the culprit:

sudo iotop -o -P

If postgres processes are at the top of that list by a wide margin, the database needs its own spindle — and eventually its own VM. You can throw NVMe at it, but if the app and DB are fighting for the same NVMe, you're just delaying the conversation.

4. Redis Memory Approaching Allocated Limit

Redis is fast until it isn't, and "isn't" happens abruptly when it starts evicting keys or, worse, when you haven't set a maxmemory policy and it starts using swap.

redis-cli info memory | grep -E 'used_memory_human|maxmemory_human|mem_fragmentation_ratio'

If used_memory is above 75% of your configured maxmemory, you're close. If mem_fragmentation_ratio is above 1.5, Redis is fighting with itself over fragmented memory. On a single VM, Redis competes for RAM with PHP-FPM, Postgres shared_buffers, and the OS page cache. Something loses.

For a biotech client running a Laravel app with heavy caching and queue-backed background jobs, Redis was the quiet culprit. The queue workers were fine. The cache hit rate was fine. But Redis was slowly eating memory that Postgres needed for its buffer pool, and query times were gradually degrading. Took me longer than I'd like to admit to trace it because no single metric was screaming.

5. Load Average Trending — Not Spiking

A spike is obvious. A trend is what kills you.

sar -q 1 3600 > /tmp/load_hour.txt  # record for an hour
awk '{print $4}' /tmp/load_hour.txt | sort -n | tail -20  # look at the top values

If your 15-minute load average at 10 AM last Monday was 2.1, last Tuesday 2.4, last Wednesday 2.6 — that's a trend. Extrapolate it. If the ceiling is your vCPU count (a 4-core box starts having problems when load average consistently exceeds ~4), you can calculate roughly when you're going to have a bad week. Do it before that week arrives.

I keep a simple Grafana dashboard for every managed hosting client with a 30-day sparkline for load average at peak hour. It's not fancy. It tells me everything.

Signals That Are NOT Reliable Leading Indicators

  • Average CPU utilization. Averages hide spikes. 20% average with regular 95% spikes is a problem. Average doesn't tell you that.
  • Disk space. Disk fills slowly and linearly. It'll send its own alert. It's not a sign of the scaling ceiling; it's a separate housekeeping issue.
  • Memory utilization at 70-80%. Linux uses RAM for page cache aggressively. 80% memory utilization on a server where Postgres and Redis are both configured properly is actually healthy. Swap usage is the signal, not memory utilization.

When I'd Reach for a Second Box

Specifically, I start planning a second VM when I see two or more of the above signals simultaneously, or when any single one is consistently bad and isn't fixed by tuning.

The typical first split I make is the database off to its own VM. The app server and DB server are almost always the right first decomposition. It immediately:

  • Gives Postgres its own RAM budget (bigger shared_buffers, work_mem)
  • Eliminates I/O contention between app and DB
  • Lets you scale each independently
  • Reduces FPM worker latency if slow queries were the root cause

The second split, if needed, is usually Redis — or a read replica if the read:write ratio is lopsided.

When I Wouldn't Bother

If you're hitting these signals because of a single bad query or a missing index, adding a server is expensive malpractice. Profile first.

# find slow queries in Postgres
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

I've "saved" clients from a server upgrade by adding one index. Don't let infrastructure spending substitute for engineering.

Also: if your traffic is genuinely low and the signals are showing up because of a cron job or a runaway queue job, fix the job. I've seen a single misconfigured artisan command scheduled every minute bring a healthy box to its knees. That's not a scaling problem.

The Bigger Point

Single-VM is a legitimate architecture for most of what NWOS builds. It's not a compromise or a starter kit — it's appropriate for a wide range of production workloads. But it has a shape, and that shape has edges. The job is to find the edges before your client does.

Set up monitoring that tracks the metrics above — not just disk and CPU ping, but FPM pool depth, Postgres connection counts, Redis memory, and load average trends. Check them weekly. When two signals start moving in the wrong direction at the same time, start the conversation about a second box. You want to migrate on a Tuesday afternoon, not at 11 PM on a Friday.

The server won't ask for help until it's too late. That's your job.

Related

Need help shipping something like this? Get in touch.