log in
consulting hosting industries the daily tools about contact

The I/O Signals That Tell You to Split the DB Off Your App Server

Running your database on the same VM as your app is fine until it isn't. Here's how I know when it's time to split them.

Single-VM deployments get a bad reputation they don't entirely deserve. For most of the apps I run for small and mid-size clients, one well-sized server with Nginx, PHP-FPM, and Postgres living together is the right call. Fewer moving parts, cheaper, easier to snapshot, easier to debug at 2am. But there's a point where that setup starts lying to you, and if you don't know what signals to watch for, you'll spend weeks chasing application bugs that are actually infrastructure problems.

This is the pattern I've learned to read after running managed hosting for NWOS clients for years.

Why Single-VM Works at All

The reason co-locating the database and app server works well early on is that you're trading network latency for simplicity. A query from PHP-FPM to Postgres over localhost takes fractions of a millisecond. You have full control of the machine. Backups are straightforward. There's no VPC peering to misconfigure, no managed database bill, no replication lag to explain to a client.

I run probably a dozen production apps this way right now. E-commerce stores, a print management platform, a couple of real estate tools. They hum along fine. The key is knowing when "fine" is about to become "not fine".

The Signals I Watch

There are three categories of signal I look at: CPU contention, memory pressure, and I/O wait. The last one is the one that gets people.

1. I/O Wait Creeping Above 5%

This is the number I watch more than anything else. Run iostat -x 2 on your server and look at the %iowait column. On a healthy single-VM app server, this should be near zero most of the time with occasional spikes during bulk writes or backups. When I start seeing sustained iowait above 5% during normal traffic hours, that's the first flag.

What's happening is that the database and the application are fighting over the same disk. Postgres is flushing WAL, checkpointing, or doing a sequential scan, and at the same time your app is writing logs, PHP's opcache is doing its thing, and your web server is serving cached assets. They share one I/O queue.

Here's the command I have in a small monitoring script I run via cron on every server:

#!/bin/bash
# iowait-check.sh - flag if avg iowait exceeds threshold
THRESHOLD=5
SAMPLES=6
INTERVAL=10

AVG=$(iostat -x ${INTERVAL} ${SAMPLES} | awk '/^avg-cpu/{found=1; next} found{print $4; found=0}' | \
  awk '{sum += $1; count++} END {if (count > 0) printf "%.2f", sum/count}')

if (( $(echo "$AVG > $THRESHOLD" | bc -l) )); then
  echo "WARN: avg iowait ${AVG}% over last $((SAMPLES * INTERVAL))s on $(hostname)" \
    | mail -s "iowait alert" ops@nwos.com
fi

Not fancy. But I've caught two separate situations where a client's app was getting sluggish and the culprit was iowait spiking during Postgres autovacuum. The app logs showed nothing. The error logs showed nothing. It was the disk.

2. Memory Contention Between Postgres and PHP-FPM

Postgres wants shared_buffers. PHP-FPM wants per-process memory for each worker. On a VM with 8GB RAM, you can tune these to coexist. On a VM with 4GB, you're making painful trade-offs.

The specific signal here is watching for the OOM killer or swap usage climbing. Run vmstat 2 10 and watch the si and so columns (swap in, swap out). Any sustained swap activity on a database server is bad news. Postgres doing I/O to swap instead of RAM kills query performance in a way that looks, from the application side, like slow queries. Which people then try to fix with indexes when the real fix is memory.

I had a biotech client last year running a LIMS integration that was doing bulk inserts every hour. The inserts kicked off right after we increased the PHP-FPM worker count to handle a new reporting feature. Suddenly Postgres started thrashing swap during those windows. The fix short-term was tuning work_mem and shared_buffers down. The real fix was splitting the database to its own VM so each service could have what it needed.

3. CPU Steal or Noisy Neighbors (on VMs)

If you're on a cloud VM, watch %steal in top or vmstat. Steal is CPU time your VM wanted but didn't get because the hypervisor gave it to another tenant. This isn't directly a split-DB signal, but when you see steal creeping up during your busiest hours and your load average is high, adding the database's CPU pressure to the mix makes a bad situation worse. Splitting the database to a separate VM at least lets you size and place each workload independently.

4. Slow Query Logs That Don't Match Explain Plans

This one is subtle. If Postgres's log_min_duration_statement is showing queries taking 200ms that EXPLAIN (ANALYZE, BUFFERS) says should take 10ms, the gap is usually I/O. The query plan is fine. The disk just wasn't available when the query ran because something else had it.

In postgresql.conf I always have:

log_min_duration_statement = 100
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
shared_preload_libraries = 'pg_stat_statements'

And I check pg_stat_statements regularly:

SELECT
  query,
  calls,
  round((total_exec_time / calls)::numeric, 2) AS avg_ms,
  round((mean_exec_time)::numeric, 2) AS mean_ms,
  round(stddev_exec_time::numeric, 2) AS stddev_ms,
  rows
FROM pg_stat_statements
WHERE calls > 50
  AND mean_exec_time > 20
ORDER BY mean_exec_time DESC
LIMIT 20;

High standard deviation on a query that runs thousands of times is a tell. The query isn't slow by nature - it's slow intermittently. That intermittency is almost always resource contention, not a bad query plan.

The Actual Decision Point

Here's my rough rule: when I'm spending time tuning OS and Postgres parameters to keep two workloads from stepping on each other, instead of building features or solving actual application problems, it's time to split.

More concretely, I split when two or more of these are true:

  • Sustained iowait above 5% during normal traffic (not backup windows)
  • Swap in use at all during peak hours
  • Postgres's shared_buffers is below what I'd set if it owned the machine
  • Application latency p95 is noticeably higher than p50 and I can't explain it in the app code
  • I'm considering vertical scaling purely because of memory pressure between the two services

Vertical scaling to get more headroom for a co-located setup is fine up to a point. But there's a crossover where two reasonably-sized VMs cost about the same as one big one, give you clean resource isolation, and let you scale each side independently. For most of my clients that crossover happens somewhere around 8-16 vCPU with heavy Postgres workloads.

What the Split Actually Looks Like

When I do split, the application config change in Laravel is minimal:

// config/database.php
'pgsql' => [
    'driver'   => 'pgsql',
    'host'     => env('DB_HOST', '10.0.1.50'), // internal IP of the DB VM
    'port'     => env('DB_PORT', '5432'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'sslmode'  => env('DB_SSLMODE', 'require'),
],

The real work is the infrastructure side: private networking between the VMs, Postgres pg_hba.conf updated to accept connections from the app server's internal IP, firewall rules so port 5432 is not exposed publicly, and connection pooling if you're running enough PHP-FPM workers to hammer max_connections.

On the Postgres side:

# postgresql.conf on the dedicated DB VM (16GB RAM example)
shared_buffers = 4GB
work_mem = 64MB
maintenance_work_mem = 1GB
effective_cache_size = 12GB
listen_addresses = '10.0.1.50'  # internal IP only

You can actually set shared_buffers to 25% of RAM now instead of the weird conservative value you needed when the app server was sharing the machine. That alone often gets you better query performance than the split itself.

When I Would Not Split

If your app is read-heavy with good caching and your queries are fast, a single VM with an NVMe disk will take you a long way. Don't add network hops and operational complexity for traffic levels that don't justify it. I've seen people split too early and then spend time debugging replication or connection issues that they would never have had otherwise.

Also, if your hosting budget is tight and you're on a managed Postgres service anyway (RDS, Supabase, whatever), you've already split - the question doesn't apply. The co-location conversation is specifically about self-managed VMs.

The Bottom Line

The signal to split is almost always I/O wait and memory pressure, not CPU and not query count. Watch iostat, watch vmstat, and watch stddev in pg_stat_statements. When those numbers stop making sense relative to your traffic, the disk is the problem and the fix is giving Postgres a machine it doesn't have to share. That's the whole lesson.

Related

Need help shipping something like this? Get in touch.