Docker memory limits: set the cap, then prove the OOM

By Sudipta Dhara / September 30, 2026

By ProDevs

Published 2026-09-30

Last updated 2026-09-30

AI disclosure: drafted with AI assistance.

Set --memory and --memory-swap to the same value, at about 1.5× the steady memory usage docker stats prints during ordinary work. If a real job climbs higher, set the cap above that job. Then run the job once. If the container dies with OOMKilled true, exit 137 and an oom event, raise both numbers together or hold less.

How we test: On 29 September 2026 we ran two throwaway alpine containers on a Linux host that already had Docker Engine 28.5.1 (linux/amd64). docker info printed MemoryLimit=true, SwapLimit=true, MemTotal=50514534400, and Total Memory: 47.05GiB. Warnings were null, and there was no WARNING: No swap limit support line. The image was already on the host. We did not change the daemon. A first attempt stored dd if=/dev/zero in a shell variable. Both containers printed bytes:0 and exited 0, because the shell drops NUL bytes. The table later in this guide is the rerun, which pipes dd through tr. That log has no docker stats line and no dmesg line.

Who this is for: developers and DevOps engineers · Intermediate · You’ll need: Docker on a Linux host, and a container whose memory you can cap

The cap to set

Set --memory and --memory-swap to the same value, at about 1.5× the steady memory usage docker stats prints while the app does its ordinary work. If a real job (import, reindex, test suite) climbs higher than that, set the cap above the job. Use the same pair in Compose as mem_limit and memswap_limit. Then run that job once. If the container dies with OOMKilled true, the spike is still above the cap: raise both numbers together, or hold less in the process.

The 1.5× figure is a starting cap so a slow leak dies inside the container. It is a planning start. The lab further down is a concrete pair: an 8 MiB payload lived under a 64 MiB cap, and a request to hold 48 MiB died under a 32 MiB cap, with swap turned off in both runs.

By default a container has no resource constraints and can use as much of a given resource as the host’s kernel scheduler allows. (Docker docs: resource constraints) One uncapped process is enough to make a host look too small. Put the cap on before you order more RAM.

The machine under the container is a separate choice: Docker or a VPS.

Who it’s for

You run docker run or Compose on Linux, something has been killed or the box has stalled, and you want a ceiling you can explain from docker inspect. A Kubernetes LimitRange, a language-runtime heap, and a vendor shortlist are other jobs.

Not for

Kubernetes pod limits and LimitRange objects, JVM or Node heap tuning, and choosing a hosting vendor are separate jobs. This guide covers the memory flags on a Docker container running on one Linux host.

Find the number with docker stats

docker stats streams usage for running containers. Stopped containers return no data. --no-stream takes one sample. The MEM USAGE / LIMIT column is the memory the container is using and the memory it is allowed to use. On Linux the CLI subtracts cache before it prints usage. On cgroup v2 that cache field is inactive_file. (Docker docs: container stats)

docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}'

Read it while the app is serving ordinary traffic, and again during the job that hurts. Multiply the ordinary figure by 1.5. If the heavy job is higher, the cap has to clear the heavy job or that job is what the kernel kills.

--memory / -m is the maximum memory the container can use. The smallest value Docker accepts is 6m. A value is a positive integer plus b, k, m, or g. (Docker docs: resource constraints) Six megabytes is the smallest legal flag. Size the service from docker stats.

Copy-paste: docker run

This is the shape that survived in the lab. Count 8 allocates 8 × 1,048,576 bytes. Leave the container after it exits so you can inspect it.

docker run --name mem-ok \
  --memory 64m --memory-swap 64m --cpus 0.25 \
  alpine \
  sh -c 'x=$(dd if=/dev/zero bs=1048576 count=8 2>/dev/null | tr "\0" "A"); echo "bytes:${#x}"'

The failing shape uses a tighter cap and a larger count. Count 48 asks for 48 × 1,048,576 bytes.

docker run --name mem-cap \
  --memory 32m --memory-swap 32m --cpus 0.25 \
  alpine \
  sh -c 'x=$(dd if=/dev/zero bs=1048576 count=48 2>/dev/null | tr "\0" "A"); echo "bytes:${#x}"'

Equal --memory and --memory-swap turn swap off for the container. If you set --memory and leave --memory-swap unset, the container can use as much swap as the --memory setting, when the host has swap. Docker’s example is --memory="300m" with --memory-swap unset, which can use 600m in total of memory and swap. --memory-swap=-1 allows unlimited swap, up to the host. A value of 0 is ignored and treated as unset. (Docker docs: resource constraints)

The tr step is the whole trick. A shell drops NUL bytes from a command substitution, so dd if=/dev/zero straight into x=$(...) prints bytes:0 and exits 0. That was our first attempt, on both containers. Rewriting each NUL to A makes the variable hold the bytes, and the echo prints the length.

For a service that stays up, point the same flags at your image:

docker run -d --name app \
  --memory 64m --memory-swap 64m --cpus 0.25 \
  your-image:tag

Copy-paste: Compose

mem_limit is the service field for the ceiling, as a string such as 64m. If you also set deploy.resources.limits.memory, the two match. cpus on the service follows the same rule against deploy.resources.limits.cpus. memswap_limit is the total of memory plus swap, and it only means something when a memory limit is set. The same value as that limit means no swap. Unset, with a memory limit set, the container can use as much swap as the memory setting if the host has swap. 0 is ignored. -1 is unlimited swap up to the host. (Compose services)

deploy.resources.limits is what the platform must prevent the container from exceeding. reservations is what the platform must guarantee. (Compose deploy)

A reservation has to sit below the hard limit or it does not take precedence, and it still does not pin the container under that number. Until the kernel sees low memory or contention, a soft limit lets the container use as much memory as it needs. (Docker docs: resource constraints)

The file below uses the lab’s 64 MiB hard cap, a 32 MiB reservation under it, and swap disabled. The lab itself was docker run, so treat the YAML as the same policy written for Compose.

services:
  app:
    image: your-image:tag
    cpus: 0.25
    mem_limit: 64m
    mem_reservation: 32m
    memswap_limit: 64m
    deploy:
      resources:
        limits:
          cpus: "0.25"
          memory: 64m
        reservations:
          memory: 32m

Prove it was an OOM

Exit code 137 means signal 9. Docker’s events example is a die line with exitCode=137 after a kill with signal=9. That example is a kill. Treat it as an OOM when inspect shows OOMKilled true and the events stream shows action oom. Container events include oom. (Docker docs: events)

docker inspect mem-cap --format 'memory={{.HostConfig.Memory}} memorySwap={{.HostConfig.MemorySwap}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Status={{.State.Status}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}'
docker events --since 30m --until 0s \
  --filter event=oom \
  --format '{{.Time}} {{.Actor.Attributes.name}} {{.Action}}'

On the host, dmesg -T or journalctl -k is the kernel log around FinishedAt. Our recorded proof is the inspect fields and the oom event.

By default, if an out-of-memory error occurs, the kernel kills processes in the container. --oom-kill-disable without a --memory cap can leave the host out of memory, and the kernel may then kill host processes. Leave the killer on. Docker also says to avoid an extreme negative --oom-score-adj on the daemon or a container, and to avoid --oom-kill-disable as a way around the daemon’s OOM priority. (Docker docs: resource constraints)

Inside the container, free reports the host’s available swap. Read the flags you passed, and read docker info, when you need the container’s own swap budget. If docker info ends with WARNING: No swap limit support, a kernel capability is disabled. (Docker docs: resource constraints)

Symptom, cause, fix

What you see What it usually means What to change
OOMKilled true, exit 137, and an oom event The hard cap is under what the process tried to hold Raise --memory and --memory-swap together, or hold less
Exit 137 and OOMKilled false Signal 9 from a kill Read docker events for kill versus oom before you change the cap
bytes:0 and exit 0 on a dd test The shell dropped NUL bytes Pipe dd through tr
The host stalls and inspect shows no memory cap The container is on the default, which is no cap Set --memory before you buy a larger machine
The app crawls, then dies, and --memory-swap was unset The container may be swapping up to the size of --memory Set --memory-swap equal to --memory when you want no swap
free inside the container shows plenty of swap That number is the host’s swap Decide from the flags and from docker info
--memory-swap=0 and the process still swaps 0 is ignored and treated as unset Repeat the --memory value
A reservation seems to do nothing It is not below --memory, or the host is not under pressure Keep the reservation under the hard cap

Mistakes that waste a day

Buying RAM for one uncapped container. The default is no ceiling. Cap the hot process, or move it, and look at the host again. A larger VPS with the same missing flag repeats the stall. VPS for AI coding agents is the RAM floor for a long-running agent. The cap is how much of that floor one container may take.

Cloudflare vs a VPS is the neighboring article when the question is which bill you are paying.

Using 6m because it is in the docs. That is the minimum accepted flag. A real service needs the docker stats figure, rounded up to a unit you can type.

Turning the killer off to stop the crashes. With a --memory cap, the killer is what contains the process. Without a cap, disabling it pushes the shortage onto the host.

A soft limit used as the hard stop. --memory-reservation yields when the host is under pressure. The hard stop is --memory.

Alternatives and limitations

A soft limit on its own. --memory-reservation yields when the host is under pressure and does not keep the container under that number. Use it alongside a hard cap, not instead of one.

Leaving --memory-swap unset. The container can then use swap up to the size of its cap when the host has swap. A spike shows up as slowness instead of a clean kill, which is harder to diagnose.

A bigger server. It helps when the cap is already set and the real workload needs more. Without a cap, the same stall comes back on the larger machine.

What our test does not cover. The lab used a synthetic alpine payload on one host, not a real application. The 1.5× starting cap is a planning rule, not a measured value, so confirm it by running your heaviest job under the cap.

What we measured

How we test

One host, Engine 28.5.1, memory limits supported, swap limits supported, Docker reporting 47.05GiB. Two throwaway alpine containers, both with --cpus 0.25, both with swap disabled by setting memory-swap equal to memory. The payload is anonymous memory rewritten from NUL to A.

Run Flags Allocation What the log printed
A --memory 64m --memory-swap 64m --cpus 0.25 count 8, block 1048576 bytes:8388608. Client exit 0. Inspect memory and memory-swap 67108864 and 67108864. OOMKilled false. ExitCode 0. Started 2026-09-29T16:17:26.19210419Z, finished 2026-09-29T16:17:28.705774667Z.
B --memory 32m --memory-swap 32m --cpus 0.25 count 48, block 1048576 No bytes: line. Client exit 137. Inspect memory and memory-swap 33554432 and 33554432. OOMKilled true. ExitCode 137. Started 2026-09-29T16:17:29.21545262Z, finished 2026-09-29T16:17:36.427915673Z.

8 × 1048576 = 8388608, the length run A printed. 48 × 1048576 = 50331648, the hold run B asked for. Run B printed no bytes: line. 64 × 1024 × 1024 = 67108864 and 32 × 1024 × 1024 = 33554432, the two inspect pairs.

A 10-minute docker events filter for oom on that container printed no line (events_exit=0). A second query, 30 minutes wide, with no container filter, printed time 1790698656 and action oom. That Unix time is 2026-09-29T16:17:36Z, the same second as run B’s finished timestamp. When a narrow events query is empty, widen it before you decide the kill was something else.

Takeaway: With swap disabled, a cap above the payload exited 0, and a cap below the payload was killed by the kernel. The host line was 47.05GiB, so the kill was the 32 MiB ceiling. Exit 137, OOMKilled true, and the oom event were the same run. Use all three.

FAQ

What number should I put in --memory?

Read docker stats during ordinary traffic and during the heaviest job. Start at about 1.5× the steady MEM USAGE. If the heavy job is larger, set the cap above that job. Set --memory-swap to the same value unless you want the extra swap budget. Run the job and read OOMKilled.

Does exit code 137 mean the container ran out of memory?

It means signal 9. Call it an OOM when docker inspect shows OOMKilled true and docker events shows oom.

Should --memory and --memory-swap be equal?

Equal values mean the container has no swap. Leave --memory-swap unset when you want the extra budget Docker documents: as much swap as the memory cap, if the host has swap. Their illustration is 300m of memory able to use 600m including swap.

Why did --memory-swap=0 leave swap available?

Docker treats 0 as unset. With --memory set, unset allows swap up to the memory cap when the host has swap. Repeat the memory value on --memory-swap.

Can I check swap with free inside the container?

free there reports the host’s available swap. Use the flags and docker info.

Will a bigger VPS fix a container that keeps dying?

If OOMKilled is true, the ceiling is under the allocation. Raise the cap, or hold less. If inspect shows no memory cap, add the flag before you change the plan size.

What did the 29 September run show?

On Engine 28.5.1, 8 MiB of payload (bytes:8388608) under a 64 MiB cap with swap disabled exited 0, and OOMKilled was false. A count of 48 blocks of 1,048,576 bytes under a 32 MiB cap with swap disabled exited 137, OOMKilled was true, and a 30-minute events query printed oom at 1790698656. The 10-minute container filter printed nothing. Docker’s memory line for the host was 47.05GiB.

Need the cap set on a host you already run?

Stuck on this? We diagnose first, then quote. See /services/.

Sources

Primary sources, each loaded and checked on 10 October 2026. The date in brackets is the date the source page shows, or its last commit.

Get new ProDevs guides by email

ProDevs Newsletter Form

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top

IP Geolocation by DB-IP