A VPS running out of RAM doesn't send a warning. There's no gradual slowdown you can screenshot and forward to yourself for later, no dashboard that turns amber before it turns red. One moment the box is fine. The next, the kernel has picked a process and shot it, mid-request, mid-transaction, mid-whatever it happened to be doing at the time. What follows is usually a scramble through logs trying to reconstruct what just happened — which is a bad time to be learning the mechanism for the first time.
What does memory pressure actually look like before anything dies?
Before the kernel kills anything, it tries hard not to. Linux overcommits memory by design: processes routinely ask for more address space than they'll ever actually touch, and the kernel hands it out optimistically instead of reserving physical pages the moment they're requested. That works fine until enough of those promises get called in at once. When free memory gets tight, the kernel's reclaim path — kswapd running quietly in the background, then direct reclaim on the allocating process itself if kswapd can't keep pace — starts evicting clean page-cache pages and, if you've configured any, pushing dirty anonymous pages out to swap. Pair this with XenGrowth's growth engineering practice if what happens when your VPS runs out of RAM sits inside a wider growth programme.
This is the part that looks like a slowdown rather than a crash. CPU cycles go toward reclaiming pages instead of running your app. Disk I/O climbs as swap fills. Request latency creeps up for no reason your application metrics can explain on their own, because the thing eating the time isn't in your code. That's memory pressure, and it's survivable — the kernel is still choosing what to sacrifice on its own terms, mostly recovering pages nobody urgently needed. The OOM killer is a later, harder stage: what happens once reclaim genuinely can't free enough to satisfy an allocation, and something has to give immediately.
What is the OOM killer, and when does the kernel actually reach for it?
The OOM killer isn't a monitoring service polling memory in the background. It's a code path inside the kernel's own page allocator, invoked directly, in the exact moment reclaim has failed and an allocation cannot be satisfied any other way. There's no grace period and no notification first. One instruction the kernel is trying to run needs pages, reclaim comes back empty-handed, and the kernel picks a victim and kills it right there, in the same breath.
That's worth sitting with, because it's why the kill always feels so abrupt from the outside. It isn't a scheduled policy decision — it's the last thing standing between the box and a total lockup. The kernel doesn't get to pause and check what a process was actually doing at the time. It needs pages back now, and it already has a scoring system ready to tell it exactly who loses them.
How does the kernel decide who dies?
Every process on the box carries an oom_score, recalculated at kill time and normalized to a 0–1000 scale against total system RAM. The core input is how much memory the process is using right now — resident set size, swapped-out pages, page-table memory — not how much it burned getting there, and not whether it was the process that just allocated a huge chunk a second earlier. A process that has quietly held a large, long-lived allocation for hours scores worse under this algorithm than one that spiked hard for two seconds and has already moved on. The XenGrowth resource library approaches this from the the operations side of this side.
That's the mechanism behind an outcome people find counterintuitive: the process that caused the spike is often not the one that dies. A short-lived build step or a cron job can be exactly what tips the box over the edge — and then exit cleanly on its own a moment later — while Postgres, sitting there with shared_buffers and a large steady-state footprint, gets killed instead, because at the exact millisecond the kernel needed a victim, Postgres was the bigger number on the page. You can shift this with oom_score_adj, a per-process adjustment from -1000 to +1000 the kernel factors in before ranking candidates. Set it to -1000 and a process is effectively exempt. Set it positive and you've volunteered it as a preferred casualty. Almost nothing sets this by default, which is exactly why the database is so often the one that goes.
Factor | Effect on oom_score | What it means in practice |
|---|---|---|
Resident set size (RSS) | The single biggest input — directly raises the score | A process holding a lot of live memory, like a database's cache, is a bigger target than one that briefly spiked and released it |
Swapped-out pages | Counted the same as RSS in the badness calculation | Swap doesn't hide a process from the OOM killer — it still counts against it |
oom_score_adj = -1000 | Score forced to 0 — effectively immune | Reserved for processes you've deliberately protected; the kernel doesn't set this for you by default |
oom_score_adj > 0 | Score inflated regardless of actual usage | A way to nominate a specific process as the preferred victim ahead of time |
Root-owned process | A small historical bias in some kernel heuristics | Not something to rely on for protection — it's minor and version-dependent |
What does swap actually change, and what does it just postpone?
Swap buys time, not capacity. When RAM fills, the kernel can push cold anonymous pages out to a swap file or partition and hand that physical RAM to whoever's asking for it. That turns a hard failure into a soft one — instead of an instant kill, you get elevated latency while pages shuffle to and from disk. For a workload with an occasional burst above its steady-state footprint, that trade is completely reasonable. A few seconds of degraded response time beats a killed process every time.
What swap doesn't do is shrink your working set. If the memory your processes genuinely need, moment to moment, permanently exceeds RAM plus swap combined, you haven't avoided the OOM killer — you've booked an appointment with it later, with worse symptoms leading up to it. Heavy, sustained swapping can make a box feel functionally dead well before the kernel actually kills anything, because every page fault has become a disk round-trip instead of a memory access. On the SSD-backed VPS most of this cluster runs on, that's also real wear you're paying for, to delay an outcome swap was never going to prevent.
Why is `next build` the classic way a small box gets killed?
If you've self-hosted a Next.js app on a small VPS and watched it die during a deploy rather than under traffic, this is almost always why. `next build` isn't a lightweight step — it type-checks, bundles, minifies, and statically generates pages, often across several worker processes running in parallel to use the box's full vCPU count. Each worker holds its own chunk of the module graph in memory at once, and on a box sized for the app's steady-state runtime rather than for compiling it, that's frequently the single highest memory-pressure moment the server will ever see — well past anything real traffic does to it. XenGrowth on governed AI marketing workflows works through AI agents and marketing automation in more operational detail.
That's a build-time spike, not a runtime one, which is exactly why sizing a VPS off steady-state traffic alone can quietly undersell what you actually need if you're also building on that same box. The fix that actually holds is separating the two problems entirely: build somewhere with room to spare, and ship the box only a finished image or a finished `.next` output to run. My own deployment workflow builds off the VPS for exactly this reason — the app server never runs `next build` at all.
How does this change under Docker and cgroup v2 memory limits?
Everything above describes the host's own memory. Docker adds a second, independent layer on top of it. A `--memory` limit on a container is implemented as a cgroup v2 `memory.max` value scoped to that container's own control group — a hard ceiling with no relationship to how much RAM the host has free. When a container's cgroup hits its own `memory.max` and reclaim inside that cgroup can't free enough, the kernel invokes the OOM killer scoped to that cgroup specifically, and it kills a process inside the container, not necessarily anything the host cares about.
That's the detail people miss: a container can get OOM-killed while `free -h` on the host shows plenty of headroom, because the limit that actually mattered was the container's own, not the box's total RAM. cgroup v2 also exposes `memory.high`, a softer threshold below `memory.max` that throttles a cgroup by forcing aggressive reclaim, without killing anything — an early squeeze rather than a cliff edge. Docker's resource-constraint flags map onto this directly: `--memory` sets the hard `memory.max`, `--memory-reservation` behaves like a soft target under contention, and `--memory-swap` controls how much swap that specific cgroup can use on top of its RAM limit — set it equal to `--memory` and the container gets none at all.
Practically, this means one overloaded container can die in isolation while every sibling container on the same Coolify box keeps running untouched — and the host-level graphs you'd normally check first look completely fine while it happens. The evidence has moved from the host's dmesg to the container's own cgroup accounting, which changes where you look next. XenGrowth on building one SEO and GEO content system goes further into AI search, GEO and discovery.
How do you read the evidence afterward, instead of guessing?
The first instinct after a mystery restart is to check `free -h` and see a healthy-looking number, and conclude memory wasn't the problem. That instinct is usually wrong, for a specific reason: the 'free' column in `free -h` only counts memory nothing is using at all, and excludes page cache that's sitting there being useful but fully reclaimable in an emergency. `MemAvailable`, in `/proc/meminfo`, is the kernel's own estimate of what's actually allocatable without swapping — the same figure `free`'s 'available' column reports — and it's the number that would have told you the box was in trouble before the kill, not after.
`dmesg` — the kernel ring buffer, holding the literal 'Out of memory: Killed process <pid> (<name>)' line with the process name and score at kill time
`journalctl -k --since "..."` — the same kernel messages as dmesg, but persisted across reboots and searchable by time range, useful once the ring buffer has wrapped
`docker inspect <container> --format '{{.State.OOMKilled}} {{.State.ExitCode}}'` — whether Docker's own cgroup limit specifically triggered the kill, versus some other SIGKILL entirely
Exit code 137 on its own only means SIGKILL was received (128 + signal 9) — consistent with an OOM kill, but also with a `docker stop` that timed out and escalated, so it's a clue, not a verdict
The 'free' column in `free -h` is close to the least useful number on that whole page. Most of what it excludes is page cache doing genuinely useful work that the kernel will hand back the instant something else actually needs it. `MemAvailable` is the number that answers 'how much room do I actually have' — and it's the one almost nobody checks until after the kernel has already picked a winner.
Symptom | Where to look | What it tells you |
|---|---|---|
A container vanished and restarted itself | docker inspect <name> --format '{{.State.ExitCode}}' | 137 is SIGKILL. Almost always the OOM killer, not a bug in your code |
The host froze, then came back | dmesg -T | grep -i 'killed process' | A host-level kill. The line names the victim, its RSS, and the score it was chosen on |
Container died but dmesg is silent | memory.events in the container's cgroup | A rising oom_kill counter means a per-container limit was hit with no host-wide pressure |
A build failed with no error message | journalctl -k --since '10 min ago' | A compiler killed mid-run leaves nothing at the application layer to find |
free -h says there is plenty free | MemAvailable in /proc/meminfo | free counts reclaimable page cache as used. MemAvailable is the number that predicts a kill |
Preventing the next one
None of the above is really about avoiding memory pressure — a small VPS will hit it eventually, and that's fine. The goal is making sure the pressure lands somewhere you chose, at a size you accounted for, instead of somewhere the kernel had to decide for you at 3am.
Size swap deliberately, for a known worst-case spike — a build, a batch job — rather than by whatever your distro's installer defaulted to. Too little and you've bought no time at all; too much and thrashing can hide a real capacity problem for hours before anyone notices.
Give any build step its own memory ceiling, separate from the running app's — or better, don't run `next build` on the box that's serving traffic at all.
Set `--memory` on every container that runs anything bursty or untrusted, so a single bad deploy dies inside its own cgroup instead of pressuring its neighbors.
Track swap usage and `MemAvailable` over time, not just a CPU graph. A box that looks completely fine on CPU can still be minutes from an OOM kill.
For anything you've decided must never be the victim, set its `oom_score_adj` explicitly instead of hoping it stays small enough by luck.
The box this cluster runs on is deliberately small, and it will hit memory pressure again — that was never the part worth engineering around. What's worth engineering is knowing in advance which spike it can absorb through swap, which one needs its own cgroup ceiling, and which one has no business happening on that box at all. That's a much smaller list of decisions than it sounds like, and almost all of them get made once, correctly, instead of relearned after a kernel log line explains what already happened.
Further reading from XenGrowth
The XenGrowth resource library — what you'll learn: how the commercial side of this work is run, across search, automation and revenue operations.
XenGrowth on governed AI marketing workflows — what you'll learn: how the teams running AI marketing agents keep them governed and measurable.
XenGrowth on building one SEO and GEO content system — what you'll learn: how search and AI-answer visibility get run as a single content system.
Where this work meets go-to-market
the team at XenGrowth writes for the teams who have to run what happens when your VPS runs out of RAM day to day.
Five questions on how the OOM killer really decides, and what the evidence looks like afterward. Answers and reasoning at the end.













