Docker Memory Limits and OOM Kills on a Small VPS: Sizing, Diagnosis and Fixes

The OOM killer is not a bug in Docker. It is the kernel enforcing a limit you set — or forgot to set. On a 1–2 GB budget VPS, the difference between a stable container host and one that dies every few days is almost always three settings and a reliable way to read the exit code. This guide covers how to size memory limits from measured usage, how to spot the container that is quietly stealing pages from its neighbours, and what to change before you simply buy more RAM.

What “OOM Killed” Actually Means

When a container exits with code 137, you know two things: it received SIGKILL, and something decided it had to die. That something can be either of two different mechanisms, and the distinction matters because the fixes are not the same.

  • cgroup OOM kill: the container exceeded its own --memory limit. The kernel kills a process inside that cgroup, and only that cgroup is affected. docker inspect shows OOMKilled: true.
  • Global OOM kill: the host ran out of memory overall and the kernel picked a victim, which may be any process. The killed process might be in a container that was behaving perfectly.

Code 137 also appears for a plain docker stop that times out and escalates to SIGKILL, so always check OOMKilled and the kernel log before you start tuning. On the host: dmesg -T | grep -i "killed process" gives you the process name, the OOM score and, critically, whether it says cgroup out of memory or Out of memory: Killed process with no cgroup qualifier.

Sizing Limits From Measured Usage

The common mistake is setting --memory to a round number that feels safe, such as 512 MB for a PHP app or 1 GB for Postgres. Limits should be derived from a real measurement of resident set size (RSS) under your own peak load, with a known margin. Run the workload for a day without limits, then read the numbers:

  • docker stats --no-stream — the MEM USAGE / LIMIT column is instantaneous; watch it rather than snapshotting once.
  • docker stats --format '{{.Name}} {{.MemPerc}}' in a loop, or a short while loop appending to a file every 10 seconds for 24 hours.
  • cat /sys/fs/cgroup/system.slice/docker-<id>.scope/memory.peak on cgroup v2 — the true high-water mark, which docker stats will not show you.

Set the limit at roughly 1.3–1.5× the observed peak, and never below the application’s startup footprint, because a container that cannot allocate during boot will crash-loop with no useful log. JVM and Node runtimes often peak at start before settling.

WorkloadObserved peak RSSSuggested --memorySuggested --memory-swap
Static nginx container25 MB64m128m
Small Node.js API180 MB256m384m
PHP-FPM + nginx (3 workers)420 MB640m768m
PostgreSQL, small dataset380 MB512m1g
Redis, cache only90 MB160m192m

Those figures assume a total of 2 GB on the host. On a 1 GB VPS, cut worker counts rather than limits: a container that is genuinely allowed to use 640 MB out of 1 GB leaves nothing for the operating system, Docker daemon and page cache.

The Settings That Matter More Than the Limit

A memory limit without swap control produces a hard kill at exactly the limit. That is intentional, but it converts a slow workload into a dead container. Adding a swap allowance changes the behaviour from “killed” to “slow”, which is a much better failure mode for anything user-facing.

  • --memory-swap set higher than --memory gives the container swap. Setting it equal disables swap, which is the default-in-effect behaviour people do not expect.
  • --memory-reservation is a soft limit applied before any kill happens.
  • --oom-kill-disable should essentially never be used on a small VPS. It moves the kill from a contained process to a random host process, sometimes sshd.
  • --kernel-memory is removed in modern Docker and should not appear in current configurations.

On the host side, two kernel settings make the situation visible rather than fatal. vm.overcommit_memory=0 (the default) is the correct choice for Docker; setting it to 1 disables the accounting that lets the kernel refuse obviously impossible allocations. vm.swappiness=10 reduces pushing application pages to disk; avoid 0, which can trigger the OOM killer early.

Finding the Container That Caused It

With five containers on a 2 GB host, a global OOM kill is a whodunit. The kernel log gives you the victim, not the culprit. The repeatable method is:

  1. Read dmesg -T | grep -A5 "oom-kill" and note the victim’s RSS and OOM score at the time of death.
  2. Compare that RSS with each container’s normal baseline from docker stats. A container using three times its usual memory is the culprit, even if another one was killed.
  3. Check which container has no --memory limit — in most incidents, that is the one.
  4. Look for timing patterns: kills clustered after a cron job, backup or crawler visit point at the culprit.

Then set the limit on the culprit. A container with a limit kills itself instead of the host, which means the rest of your stack stays up and your monitoring shows the true failure instead of a server that vanished.

When Swap Is the Wrong Answer

Adding a 2 GB swap file to a 1 GB VPS is the most common first response to an OOM kill, and it usually makes things worse in a specific way: the container no longer dies, so you never learn that the workload is four times too large for the machine. Response times go from inconsistent to consistently terrible, because every request now touches disk.

Swap is appropriate for cold pages and for smoothing brief spikes. It is not appropriate for a working set that exceeds RAM, and databases are the clearest case: InnoDB buffer pool pages swapped out under memory pressure produce latency in the hundreds of milliseconds per query. If vmstat 5 shows sustained si/so activity while your application is idle, you are out of RAM, not out of tuning. Our breakdown of swap as a symptom of memory shortage walks through the numbers behind that threshold.

A Reference Compose Configuration

The shape that works on a 2 GB host: limits on every service, reservations on the two that matter, log rotation, and a health check so Docker restarts a hung container rather than leaving it in place.

  • Every service gets an explicit mem_limit and memswap_limit.
  • Database and PHP get mem_reservation; low-value sidecars (cron, log shippers) do not.
  • Every service sets logging.options.max-size and max-file, otherwise the log volume becomes the next disk-full incident.
  • Total of all limits stays at or below 75% of host RAM, leaving room for the kernel and page cache.
  • Ports and health checks are defined so a restart loop is visible in docker ps rather than silent.

If you do all of that and the host is still tight at your normal load, the constraint is real and the answer is a bigger plan, not a smaller limit. Check the current price of the next tier up in our budget VPS comparison table — the jump from 1 GB to 2 GB is usually the cheapest upgrade in the entire ladder, and it buys more than any amount of tuning.

Affordable-Vps-Server-Author
Affordable-Vps-Server-Author
Articles: 354

Leave a Reply