When is your server too small? Five upgrade signals
Five measurable signals tell you when a VPS is too small – and when cleaning up is cheaper than upgrading. With real numbers from 80 days of uptime.
Table of contents
“The server is too small” is rarely a measurement and usually a feeling. This article replaces the feeling with five numbers you can read off yourself in two minutes.
The short answer
A bigger server pays off in three cases: when the machine itself runs out of memory
(available persistently below roughly 10 percent, CONSTRAINT_NONE in the kernel log), when
the 15-minute load stays above the core count, and when the disk is tight even though
docker system df shows nothing left to clean up. If the disk is the brake instead (wa high,
us low) or a container hits its own mem_limit, an upgrade achieves nothing. Sustained
double-digit steal time is a case for a support ticket. Four of the five signals appear in a
single vmstat output.
What this is based on
Every value below comes from a netcup VPS 1000 G12 with 4 vCPU and 7.8 GiB of RAM that had been running for 80 days at the time. It was collected under controlled load.
The result is a decision routine. By the end you will know five signals, the command that shows each one, and the threshold at which a bigger server actually solves the problem. The reverse case matters just as much: for two of the five signals an upgrade is money thrown away, because the cause lies somewhere else.
The nice part: four of the five signals appear in a single output, and the tool for it is already installed on every Debian system.
To read them off you need:
- A Linux server with root access that already runs something – an empty server tells you nothing.
vmstat(packageprocps, preinstalled on Debian 13). That is all you need.- Useful but not required: a running Grafana and Prometheus setup. The snapshot below answers “is something tight right now?”; for “since when has it been getting tighter?” you need a time series.
VPS 1000 G12.5
4 vCores · 8 GB RAM · 128 GB SSD
from €14.50/month
The server every number in this article was measured on.
💶 1 month free:
Single use, valid for VPS 1000 G12.5. Redeem in the cart →
💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite
One output, four signals
vmstat shows what the server is stuck on, once per second. The first block is always an
average since boot – ignore it and read from the second line onwards:
vmstat 1 5procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
r b swpd free buff cache si so bi bo in cs us sy id wa st gu
1 0 0 3475108 271400 3831564 0 0 36 68 151 0 0 0 100 0 0 0
0 0 0 3474844 271400 3831576 0 0 0 0 165 158 0 0 100 0 0 0
0 0 0 3474844 271400 3831576 0 0 0 4 116 103 0 0 100 0 0 0That is what a bored server looks like. Six columns carry the message:
| Column | Meaning | Signal |
|---|---|---|
r | processes that want to compute | queue in front of the cores |
b | processes waiting on disk | disk maxed out |
free / cache | free memory, file cache | memory pressure |
si / so | swapping in and out | always 0 on this VPS – see signal 1 |
wa | CPU waiting on I/O | disk, not CPU |
st | stolen CPU time | oversubscribed host |
The fifth number, disk usage, comes with signal 5. df is enough for that.
Signal 1: memory runs out without warning
The best-known rule of thumb says: once the server starts swapping, RAM is too small. On a netcup VPS that rule does not apply, because there is no swap:
free -h total used free shared buff/cache available
Mem: 7.8Gi 721Mi 7.0Gi 664Ki 261Mi 7.1Gi
Swap: 0B 0B 0BSwap: 0B – and /proc/swaps contains nothing but its header line. That has an awkward
consequence: the si and so columns in vmstat stay at 0 forever. The early-warning
signal half the advice on the internet points at cannot fire on this machine at all.
We measured what happens instead. A test process allocated memory in 200 MiB steps and
actually touched it, while vmstat ran alongside:
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 2749328 199352 4218256 0 0 492 0 296 171 0 11 88 0 0
0 0 0 1513180 199352 4218256 0 0 0 0 222 86 0 13 87 0 0
0 0 0 684544 199352 4218256 0 0 0 140 190 125 1 6 94 0 0
0 0 0 200836 199352 4218256 0 0 0 0 175 81 1 6 93 0 0
0 0 0 149284 152392 3782580 0 0 0 0 324 207 1 13 86 0 0
0 0 0 141984 152376 3371156 0 0 0 0 309 209 1 11 88 0 0
0 0 0 140092 49936 2163436 0 0 0 0 511 243 1 18 82 0 0Read the lines top to bottom: free drops from 2.7 GB to 140 MB. Then the kernel starts
sacrificing the file cache – cache falls from 4.2 GB to 2.1 GB. si and so sit at 0 the
whole time. And then it is over:
journalctl -k --since "-5min" --grep "oom-kill|Out of memory"python3 invoked oom-killer: gfp_mask=0x140dca(GFP_HIGHUSER_MOVABLE|__GFP_COMP|__GFP_ZERO), order=0, oom_score_adj=1000
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=user.slice,mems_allowed=0,global_oom,task_memcg=/user.slice/user-0.slice/session-6076.scope,task=python3,pid=3065035,uid=0
Out of memory: Killed process 3065035 (python3) total-vm:6571320kB, anon-rss:6459404kB, file-rss:116kB, shmem-rss:0kB, UID:0 pgtables:12712kB oom_score_adj:1000Seven seconds passed between “2.7 GB still free” and a killed process. Without swap there is no sluggish phase in which the server tells you things are getting tight. It runs normally – and then a service is missing.
The number to watch is called available
free. The kernel uses every unused block as file cache, which is why free is always
small on a server that has been up for a while and tells you nothing. available estimates
how much can still be obtained without swapping. If that value stays below roughly 10 percent
of total memory, you are one unlucky request away from the next kill.Who got killed decides the bill
The same kernel line contains the field your purchase decision hangs on. This server has
both variants in its log. Above, just now: constraint=CONSTRAINT_NONE together with
global_oom. And on September 9:
oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=docker-c7122b11…scope,oom_memcg=/system.slice/docker-c7122b11…scope,task=tail,pid=589430,uid=0
Memory cgroup out of memory: Killed process 589430 (tail) total-vm:132096kB,anon-rss:130432kB,file-rss:772kB,shmem-rss:0kB,UID:0 pgtables:304kB oom_score_adj:0The difference is the entire diagnosis:
CONSTRAINT_MEMCGmeans a container hit its own limit – 130 MB against amem_limitof 128 MB. The server had gigabytes free at that moment. A bigger server changes nothing here; the limit needs fixing, see Harden Docker containers.CONSTRAINT_NONEwithglobal_oommeans the machine itself ran out of memory. Only that is an upgrade signal.
Skip this distinction and you will order twice the server, then watch the container die
again the next day. An exit 137 alone does not tell you which of the two cases you are
looking at – both end the process the same way. Only the kernel log has the answer.
Signal 2: the queue in front of the cores
Load average on its own says little, because nobody writes down how many cores the machine
has. It becomes interesting only when divided by nproc:
nproc; cat /proc/loadavgTo measure it we built a reference task that always costs the same: the SHA-256 hash of a one-gigabyte file that is already cached – pure compute, no disk. Idle, three times in a row:
0.88 s
0.88 s
0.89 sThen eight busy loops on four vCPU, so more than double subscription:
r b swpd free buff cache si so bi bo in cs us sy id wa st
8 0 0 2406468 271400 4882092 0 0 0 0 980 905 98 1 0 0 1
8 0 0 2406468 271400 4882092 0 0 0 0 985 920 99 0 0 0 1Load average 6.15 on four cores, r sits at 8, the CPU is 99 percent in application code.
The same task now takes:
2.16 s
2.20 sTwo and a half times as long. With the load switched off it is immediately back at 0.91 s – no after-effects, no broken machine. That is exactly how a server short on compute behaves: everything gets slower, nothing breaks.
That yields a usable rule of thumb. Divide r by the number of cores and you get roughly
the factor by which everything is currently slower. Nine runnable processes on four cores
predict a factor of 2.25; we measured 2.45.
The threshold: if the 15-minute value stays above the core count, the server is too small
for what you are asking of it. Short spikes during the nightly backup or a docker compose pull are perfectly normal and no reason for anything.
Signal 3: when the disk is the brake, a bigger server will not help
Now the case that gets misread most often. We turned four parallel writers loose on the system disk, bypassing the file cache:
for i in 1 2 3 4; do dd if=/dev/zero of=/var/tmp/p$i.bin bs=1M count=8192 oflag=direct & doneafter 15 s: 2.07 1.95 1.27 | r=0 b=4 us=0% wa=72%
after 30 s: 2.50 2.05 1.31 | r=0 b=4 us=0% wa=97%
after 45 s: 2.83 2.15 1.35 | r=0 b=4 us=0% wa=97%A load average of 2.8 – at zero percent CPU usage. That is not a measurement error: Linux counts processes in uninterruptible disk wait towards the load average. Anyone looking only at load will order more cores here and get exactly nothing.
The numbers behind it: a single stream writes 678 MB/s and reads 686 MB/s. Four parallel
streams together also write only about 700 MB/s – the NVMe is at its ceiling, and the three
extra processes are merely parked in column b.
What this signal means is uncomfortable: a bigger VPS package gives you more vCPU and more
RAM, but not more disk throughput. If wa is your bottleneck, what helps is reshaping the
work – database indexes, fewer concurrent jobs, a separate volume for the write-heavy
application – or a product with different storage, not the next size of the same one.
Tip
wa with low throughput (bi/bo small) is a different problem from wa at
full throughput. Full bo means “device at its limit”. Low bo with high wa usually means
many tiny writes, typically a database without suitable indexes or a logger flushing every
line synchronously.Signal 4: steal time, and why it is mostly a legend
Every forum will tell you shared vCPU are useless because the neighbours take the compute
time away. The st column measures exactly that. For this server across its full 80 days of
uptime:
awk '/^cpu /{t=$2+$3+$4+$5+$6+$7+$8+$9; printf "%.3f %%\n", 100*$9/t}' /proc/stat0.001 %One thousandth of a percent. Even during our full-load measurement st showed 1. The legend
does not hold up on this machine, and the
VPS 1000 benchmark found the same picture.
The column still belongs in the routine – because when it does spike, it is the one signal you cannot fix yourself. Sustained double-digit values mean the host is oversubscribed. No amount of cleaning up and no bigger package on the same host will help; a support ticket with your measurements will.
Signal 5: disk usage lies the loudest
The last signal is the only one where cleaning up is almost always cheaper than upgrading. On our server it looks relaxed at first:
df -h /Filesystem Size Used Avail Use% Mounted on
/dev/vda4 251G 73G 169G 31% /31 percent, nothing to worry about. Until you ask who is holding those 73 GB:
docker system dfTYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 107 1 74.58GB 68.43GB (91%)
Containers 1 1 8.192kB 0B (0%)
Local Volumes 0 0 0B 0B
Build Cache 33 0 209.5MB 4.096kB107 images, exactly one of them in use. 68 GB are leftovers from tests that were torn
down long ago – the containers were, but not the images. That is not an upgrade case, that
is a docker image prune -a preceded by a look at whether any of it is genuinely needed.
On a current VPS 1000 of the G12.5 generation, the same heap would already be more than half the disk: it has 128 GB instead of the 251 GB of our G12. Why the generation matters here is covered in the 100-day report.
Under Docker 29 the images live elsewhere
du -sh /var/lib/docker reports 188 MB on this server. The
70 GB sit in /var/lib/containerd, because since version 29 Docker uses the containerd image
store by default (docker info shows driver-type: io.containerd.snapshotter.v1). Follow
the old recipe and you find nothing, then go looking for the problem in the wrong place.Two more candidates that pile up quietly: the journal (journalctl --disk-usage, 279 MB
here – cap it with SystemMaxUse in /etc/systemd/journald.conf) and the old images your
update run leaves behind; how to automate that is in
Keep your Docker stack updated.
On Debian 13, /tmp is a RAM disk
findmnt /tmp shows tmpfs 3.9G. Whatever you put there consumes memory, not disk – it
appears as shared in free -h and is gone after a reboot. A 4 GiB file written to /tmp
therefore aborts halfway while df -h / still reports an unchanged 31 percent – the disk was
never the problem. Large scratch files belong in /var/tmp.The decision
| Signal | How you see it | And then? |
|---|---|---|
| Memory | available persistently < 10 %, CONSTRAINT_NONE in the kernel log | upgrade |
| Container OOM | CONSTRAINT_MEMCG in the kernel log | fix mem_limit |
| Compute | 15-minute load persistently > core count, us high | upgrade |
| I/O | wa high, us low, b > 0 | reshape the work or change storage |
| Steal | st persistently double-digit | support ticket |
| Disk | df tight and docker system df clean | upgrade – otherwise clean up |
Three of the six rows do not end at “bigger server”. That is the real value of the routine: it prevents an upgrade that achieves nothing just as reliably as it justifies one that does.
And if the decision does land on “bigger” after all: the plans with prices are in the netcup overview, and a snapshot before the move costs nothing but a few minutes.
When things go wrong
vmstat shows a steady 100 percent id even though the site feels slow. Then the
bottleneck is not on this server. Check the response time at the service directly instead of
through the domain – if the container answers fast and the browser still waits, it is the
path there: DNS, the TLS handshake or a congested network, not CPU or RAM.
Nothing at all in the kernel log, even though a container disappeared. journalctl -k
only shows genuine OOM kills. If a service exits on its own, the reason is in its own log:
docker compose logs --tail 50 YOUR_SERVICE. An exit 137 without a matching kernel line is
usually a docker stop whose grace period ran out, not a memory problem.
Load average is high but r and b are both 0. You are reading the first vmstat line.
That one is an average since boot and practically meaningless on a server with 80 days of
uptime. Use vmstat 1 5 and evaluate from the second line onwards.
free -h reports almost no free memory, yet the server runs perfectly. That is the
normal state. The kernel fills unused memory with file cache and hands it straight back when
somebody needs it. The column that matters is available; free may happily sit near zero.
Lots of reclaimable space, but docker image prune -a frees almost nothing. Then the
images are still attached to stopped containers. Run docker container prune first, then the
images – or do both at once with docker system prune -a, after checking that every stack
you want is actually running.
Your problem is not listed? Search all error messages →
Limits of this analysis
Every number comes from a single netcup VPS 1000 of generation G12 with 4 vCPU, 7.8 GiB of RAM and Debian 13.6, measured on 2026-10-04 under controlled load. That steal time stayed at one thousandth of a percent over 80 days holds for this machine; on another host it can look different. The memory behaviour in signal 1 applies to a server without swap, which is how netcup ships its VPS. A current VPS 1000 of generation G12.5 has 128 GB of disk instead of 251 GB, so the same image heap would already take up more than half of it.
The thresholds are rules of thumb: r divided by the core count predicted a factor of 2.25,
we measured 2.45. And the routine is a snapshot. Whether things have been getting tighter for
weeks only shows up in a time series from your monitoring.
Maintenance & backups
This routine is not something you run once. Two habits are enough:
Once a month, two minutes: vmstat 1 5, free -h, df -h / and docker system df.
Write the four values down – the trend across half a year says more than any snapshot, and
it shows you an upgrade weeks in advance.
Set up once, running permanently: an alert on available and on disk usage. Both fit
into the existing
Grafana and Prometheus setup; if you prefer
something leaner, use a push service as in
ntfy push notifications. Set the memory threshold
deliberately low: without swap there is no window between “getting tight” and “service gone”
in which an alert firing at 2 percent would still reach you in time.
A backup belongs before any consequence you draw from this measurement – whether you clean up or move. The routine in Backups with restic covers both; before a product change, add a snapshot in the netcup customer area.
Send feedback: feedback@serverkueche.de
You might also like

netcup VPS 1000 G12 benchmarked: how fast is it really?

100 days of netcup: what nobody tells you beforehand
