By default a Docker container can use all the CPU and memory of the host. One leaking process or busy build is enough to slow down every other service on the server, or to make the kernel OOM killer pick a random victim. In this tutorial you will cap CPU, memory and process count for containers with docker run, change limits on running containers with docker update, declare them in Docker Compose, and learn to recognize a container that was killed for exceeding its memory limit. All examples are for Ubuntu 24.04, which uses cgroups v2.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
- A non-root user with
sudoprivileges. - Docker Engine and the Docker Compose plugin installed from Docker's official repository, with your user in the
dockergroup so you can rundockerwithoutsudo.
Step 1 - Checking the cgroup setup
Docker enforces limits through Linux control groups (cgroups). Ubuntu 24.04 uses the unified cgroup v2 hierarchy and Docker uses the systemd cgroup driver, which supports every limit shown in this guide, including swap limits. Confirm it:
docker info --format 'Driver: {{.CgroupDriver}}, version: {{.CgroupVersion}}'
Driver: systemd, version: 2
If docker info prints warnings such as No swap limit support, you are on an older kernel or cgroup v1 host, and some swap options will be ignored.
Step 2 - Setting a memory limit
The --memory (or -m) flag sets a hard limit. When the processes inside the container try to use more, the kernel reclaims what it can and then kills a process in that container, not elsewhere on the host.
Start an Nginx container limited to 256 MB:
docker run -d --name web --memory 256m nginx:alpine
Check the value Docker stored, in bytes:
docker inspect web --format 'Memory: {{.HostConfig.Memory}}, Swap: {{.HostConfig.MemorySwap}}'
Memory: 268435456, Swap: 536870912
MemorySwap is the combined limit of memory plus swap. If you do not set it, Docker allows the same amount of swap as memory, so this container can use 256 MB of RAM and another 256 MB of swap if the host has swap enabled. The table summarizes the options:
| Flag | Meaning |
|---|---|
--memory 256m | Hard RAM limit. |
--memory-swap 256m | RAM plus swap. Equal to --memory means no swap at all. |
--memory-swap -1 | Unlimited swap on top of the RAM limit. |
--memory-reservation 128m | Soft limit. The kernel pushes the container back toward this value when the host is short of memory. It must be lower than --memory. |
For predictable behavior on a server, set memory and swap to the same value so a container never slows down by swapping:
docker rm -f web
docker run -d --name web --memory 256m --memory-swap 256m --memory-reservation 128m nginx:alpine
You can also read the limit directly from the cgroup the kernel enforces. With the systemd driver each container lives in a scope named after its full ID:
CID=$(docker inspect web --format '{{.Id}}')
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.swap.max
268435456
0
Step 3 - Limiting CPU usage
Docker offers three different CPU controls. Use them for different goals:
| Flag | What it does | cgroup v2 file |
|---|---|---|
--cpus 1.5 | Hard cap: at most 1.5 CPUs worth of time, even if the host is idle. | cpu.max |
--cpu-shares 512 | Relative weight, only applied when CPUs are contended. Default is 1024. | cpu.weight |
--cpuset-cpus 0,1 | Pins the container to specific cores. | cpuset.cpus |
A hard cap is what most people need. Start a container that runs a busy loop and cap it at half a CPU:
docker run -d --name burn --cpus 0.5 busybox sh -c 'while :; do :; done'
Watch its usage for a few seconds:
docker stats --no-stream burn
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
3f1c0a9d2b7e burn 50.12% 412KiB / 3.82GiB 0.01% 866B / 0B 0B / 0B 1
The loop would use 100% of a core without the limit. In cgroup terms, --cpus 0.5 becomes a quota of 50,000 microseconds every 100,000:
CID=$(docker inspect burn --format '{{.Id}}')
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
50000 100000
Use --cpu-shares instead when you only want a batch job to yield to more important containers under load, but still use the whole machine when it is idle. Remove the test container when you are done:
docker rm -f burn
Step 4 - Limiting the number of processes
A fork bomb or a runaway worker pool can exhaust the process table of the host even when memory looks fine. --pids-limit caps the number of processes and threads a container can create:
docker run --rm --pids-limit 50 busybox sh -c 'for i in $(seq 1 100); do sleep 30 & done; wait'
Once the limit is reached the shell reports errors like can't fork: Resource temporarily unavailable instead of affecting the host. A value between 100 and 500 is a reasonable ceiling for most web applications; check the PIDS column of docker stats to see what yours really use.
Step 5 - Changing limits on a running container
docker update changes limits in place, without restarting the container. Raise the memory of the web container to 512 MB:
docker update --memory 512m --memory-swap 512m web
Pass --memory-swap together with --memory. If you only raise --memory above the current swap value, Docker refuses with Memory limit should be smaller than already set memoryswap limit.
Verify the new values:
docker inspect web --format 'Memory: {{.HostConfig.Memory}}, Swap: {{.HostConfig.MemorySwap}}'
Memory: 536870912, Swap: 536870912
docker update also accepts --cpus, --cpu-shares, --cpuset-cpus and --pids-limit, and it takes several container names at once. The change is stored in the container configuration and survives restarts, but not a docker rm followed by a new docker run, so add the same values to your run command or Compose file.
Step 6 - Declaring limits in Docker Compose
In Compose, set limits under deploy.resources. Docker Compose v2 applies them to normal containers, not only to Swarm services. Create a project directory and a Compose file:
mkdir -p ~/limits-demo && cd ~/limits-demo
nano compose.yaml
services:
web:
image: nginx:alpine
ports:
- "8080:80"
deploy:
resources:
limits:
cpus: "0.50"
memory: 256M
pids: 200
reservations:
memory: 128M
cache:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "200mb", "--maxmemory-policy", "allkeys-lru"]
deploy:
resources:
limits:
cpus: "0.25"
memory: 256M
The Redis example shows a good habit: when an application has its own memory setting, set it below the container limit so the application evicts data before the kernel kills it.
Start the stack:
docker compose up -d
Confirm the limits on the created containers:
docker inspect limits-demo-web-1 --format 'Memory: {{.HostConfig.Memory}}, NanoCpus: {{.HostConfig.NanoCpus}}, Pids: {{.HostConfig.PidsLimit}}'
Memory: 268435456, NanoCpus: 500000000, Pids: 200
NanoCpus is CPUs multiplied by one billion, so 500000000 means half a CPU. For a view of every container at once:
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}"
NAME CPU % MEM USAGE / LIMIT PIDS
limits-demo-web-1 0.00% 3.1MiB / 256MiB 3
limits-demo-cache-1 0.31% 3.6MiB / 256MiB 6
web 0.00% 3.2MiB / 512MiB 3
The LIMIT column now shows the container limit instead of the host's total memory.
Step 7 - Detecting out-of-memory kills
When a container exceeds its memory limit the kernel kills the offending process. If it was the main process, the container stops with exit code 137. Reproduce it with a Python one-liner that tries to allocate 200 MB inside a 64 MB container:
docker run --name oomtest --memory 64m --memory-swap 64m python:3.12-alpine \
python -c "data = b'x' * (200 * 1024 * 1024)"
The command returns without any Python traceback. Check how the container ended:
docker inspect oomtest --format 'OOMKilled: {{.State.OOMKilled}}, ExitCode: {{.State.ExitCode}}'
OOMKilled: true, ExitCode: 137
The kernel log records the same event:
sudo journalctl -k --since "10 minutes ago" | grep -i "memory cgroup out of memory"
Sep 25 10:14:03 server kernel: Memory cgroup out of memory: Killed process 48211 (python) total-vm:215840kB, anon-rss:64820kB ...
To be notified as it happens, keep docker events running in a second terminal while you reproduce the test:
docker events --filter event=oom
Clean up the test container:
docker rm oomtest
When a real service is OOM killed, do not just double the limit. Check its normal usage with docker stats over a busy period, set the limit about 20 to 30 percent above the observed peak, and configure the runtime inside the container to respect it (for example --maxmemory for Redis, -XX:MaxRAMPercentage for Java or worker counts for Gunicorn and PHP-FPM).
Troubleshooting
The container restarts in a loop with exit code 137. Check docker inspect <container> --format '{{.State.OOMKilled}}'. If it is true, the limit is too low for the workload or the application does not honor it. If it is false, something else sent SIGKILL, such as docker stop timing out.
docker stats shows the host memory as the limit. No memory limit is set on that container. For Compose, make sure the limits are under deploy.resources.limits and recreate the container with docker compose up -d --force-recreate.
The application is slow but CPU usage never goes above the --cpus value. The container is being throttled. Read nr_throttled in /sys/fs/cgroup/system.slice/docker-<id>.scope/cpu.stat; if it keeps growing, raise the limit with docker update --cpus.
Conclusion
You set hard memory, swap, CPU and process limits on containers, changed them live with docker update, declared them in Compose and learned how to confirm an OOM kill. Good next steps are adding healthchecks and a restart: unless-stopped policy so a killed container comes back automatically, collecting docker stats data in a monitoring system to size limits from real usage, and auditing the rest of your Docker configuration with Docker Bench for Security.
