Constraining and tuning container resources through the --memory, --cpus, --pids-limit and --ulimit flags, understanding OOM-killer behavior, reading docker stats for capacity planning, and planning the right limits for databases, caches, and web servers on cgroups v2.

After tidying up the infrastructure side in episode 24 — daemon configuration, log rotation, data-root migration, and Docker contexts — this time we turn to what determines the quality of life for every container on the host: resources. You already know how to run containers with docker run, but the question now is: how much CPU and memory is one container allowed to use?
Why is this topic so important? Imagine an apartment building with no rules about water and electricity usage: the most wasteful tenant makes everyone else suffer. The same happens on a Docker server. A single application container that suddenly leaks memory due to a bug, or one worker consuming all the CPU because of an endless loop, can make the entire host unresponsive — including the database container that should be serving thousands of requests. This is what's called the noisy neighbor problem. In Kubernetes, the scheduler handles it through resource requests/limits; in Docker, you hold full control through the flags and cgroups options we'll dissect in this episode.
Before discussing flags, you need to know where the limits are enforced. Docker doesn't invent its own limit mechanism — it leverages cgroups (control groups), a Linux kernel feature that groups processes and limits CPU, memory, and PID usage within each group. On modern kernels, the implementation is cgroups v2, mounted at /sys/fs/cgroup.
grep cgroup /proc/filesystems
cat /sys/fs/cgroup/cgroup.controllersEvery running container appears as a directory under /sys/fs/cgroup/ identified by its cgroup. When you type docker run --memory 512m, Docker translates that into writing a value to the memory.max file (in cgroups v2) on that container's cgroup. The kernel enforces the limit, not the application — the application can't "negotiate" its way out of it. That's the power of containers: resource isolation is guaranteed by the kernel, not by the application's goodwill.
Three main flags for memory:
--memory (or -m): the hard limit of RAM a container may use.--memory-swap: the total memory + swap allowed. This value must be greater than --memory; if it's set equal to --memory, swap is disabled entirely.--memory-reservation: a soft limit — more of a loose "recommendation" when the host isn't under pressure; when the host runs out of memory, the kernel pushes the container toward this value.docker run -d --name api \
--memory 512m \
--memory-swap 1g \
--memory-reservation 256m \
nginxWarning
The most common trap in the real world: setting --memory but forgetting --memory-swap. When --memory-swap isn't set but --memory is, Docker gives swap a value of twice --memory. The consequence: an application that would "sit quietly" when RAM is full instead heavily uses swap — which for latency-sensitive workloads (databases, caches) can be far slower than being killed. If you want to disable swap, set --memory-swap to exactly the same value as --memory.
How do you confirm the limits that were applied? docker inspect shows everything in the HostConfig block:
docker inspect api --format \
'{{.HostConfig.Memory}} / {{.HostConfig.MemorySwap}}'The numbers come out in bytes — this is the source of truth the kernel uses, unlike the configuration in compose files, which is still just "hope".
For CPU, there are two complementary approaches:
--cpus: a CPU time limit in core units. --cpus 1.5 means the container may use an average of 1.5 full cores — the kernel spreads it out over time.--cpuset-cpus: pins the container to specific physical cores (for example 0-1 or 2,3). This is for latency-sensitive workloads that don't want to compete with other containers for the same cores — like buying a numbered seat on a train instead of fighting for one.docker run -d --name worker \
--cpus 2 \
--cpuset-cpus 0-1 \
--pids-limit 512 \
redis--pids-limit: limits the number of processes (PIDs) inside a container. It's an important guard against a fork bomb — an application that suddenly duplicates itself (usually due to a bug or an attack) until it brings down the host. A reasonable value depends on the workload; for Node.js/Go, 128-512 is usually enough.--ulimit : sets OS-level limits for processes inside the container. The most commonly set are nofile (the number of file descriptors that can be opened) and nproc (the number of processes per user). A web application handling many connections needs a high nofile:docker run -d --name nginx \
--ulimit nofile=65536:65536 \
--pids-limit 256 \
nginxRemember: giving --ulimit nofile without bounds isn't a "gift" — every file descriptor consumes kernel memory, so the value must be planned, not raised carelessly.
This is the part that's most often misunderstood. When a container exceeds its --memory limit, the kernel doesn't politely refuse the memory allocation — the kernel invokes the OOM-killer, and the OOM-killer selects one or more processes to kill based on their OOM score. The higher the score, the more likely it's chosen to be sacrificed.
The OOM score is calculated from actual memory usage and the oom_score_adj adjustment. Docker assigns --oom-score-adj to containers running with --oom-kill-disable, and every container gets a default value according to its limits. The rule of thumb:
--memory tend to get a higher oom_score_adj (easier to kill).docker run -d --name api \
--memory 512m \
--oom-kill-disable \
nginxImportant
--oom-kill-disable is an option that may only be used with --memory and for very specific reasons — for example protecting a process that's writing important data from being killed mid-operation. Without --memory, this flag has no effect (OOM still happens at the host level). The side effect: if the container is killed, the entire host gets dragged down with it. In production, it's better to let the OOM-killer do its job — a killed container can be restarted; a frozen host can't.
For observability, docker inspect also shows OomKillDisable and OomScoreAdj in HostConfig — two values you'll often check when investigating "the container suddenly died".
docker stats is a real-time window into the resource usage of all containers:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O
3f8a1c2d9b4e api 12.50% 190.2MiB / 512MiB 37.15% 1.23MB / 4.5MB 12MB / 0B
9d2c0e1a5f7b redis 3.25% 45.1MiB / 256MiB 17.61% 890kB / 2.1MB 5MB / 0B
7a4b9c1d2e3f web 41.75% 880.3MiB / 1GiB 85.97% 45.1MB / 9.2MB 30MB / 0BHow do you read this table for capacity planning?
web at 85% of its 1 GiB limit is an alarm — you have little safe headroom before the OOM-killer speaks up.The general rule of capacity planning: the host's capacity must be greater than the sum of all containers' limits, and leave room for the daemon itself (usually 5-10%). If the total of container limits already touches the host's physical capacity, every container restart will fight over resources — and the host starts thrashing.
Every network driver we covered in episode 9 carries performance trade-offs:
For most web applications, bridge is more than enough. Don't "optimize" networking before measuring — measure first with a tool like iperf inside the container, because real-world performance failures more often come from insufficient memory, slow disks, or an application waiting on I/O, not from the network driver.
A few small habits with a big impact in production:
Avoid --privileged. This flag gives the container full access to host devices — equivalent to root on the host itself. It's not only a security hazard, but also a frequent cause of weird performance (the container sees the entire /sys and tries to manage resources it shouldn't touch). There's almost always a narrower alternative: --cap-add, --device, or --security-opt.
Use the smallest images possible. Large images mean more disk I/O at start and pull time. Multi-stage builds (episode 7) with a slim base image like Alpine or distroless cut start time and shrink the attack surface.
Store large files on volumes, not on the writable layer. The writable layer uses the overlay2 storage driver with Copy-on-Write: writing a large file to the writable layer means copying the whole file every time it's modified (copy-up). A bind mount or named volume accesses files directly from the host disk without the CoW layer — a difference that's very noticeable for I/O-heavy applications like file uploads, file caches, or databases.
docker run -d --name uploads \
-v /data/uploads:/app/uploads \
nginxThis difference is why databases never store data on the writable layer: placing Postgres/MySQL data on a named volume (as you learned in episode 8) is not only safe across recreates, but also faster because it bypasses the CoW layer.
There's no one-size-fits-all, but there are reasonable starting points based on workload characteristics:
| Workload | Character | Reasonable initial limits | Notes |
|---|---|---|---|
| Database (PostgreSQL/MySQL) | Memory-hungry, latency-sensitive, heavy I/O | --memory 2g, --cpus 2, swap off | Data on volumes; avoid OOM; swap kills queries |
| Cache (Redis) | Memory-sensitive, extremely fast | --memory 1g, --memory-swap 1g (no swap) | Redis maxmemory must stay below the limit |
| Web server / API | Varied, many connections | --memory 512m, --cpus 1, high nofile | Depends on concurrency; monitor docker stats |
| Background worker | CPU-bound, can be slow | --cpus 1-2, --memory 512m, --pids-limit | Limit pids so a fork bomb can't bring down the host |
For latency-sensitive workloads (databases, Redis), consider --cpuset-cpus so CPU cores aren't shared with wasteful workers, and make sure swap is truly off — swap trades speed for "delay", the main enemy of applications that must stay consistently below a millisecond.
In Compose, there are two approaches depending on the mode:
mem_limit, mem_reservation, cpus, pids_limit, and ulimits directly on the service.deploy.resources.limits and deploy.resources.reservations.services:
api:
image: my-api:1.4.0
mem_limit: 512m
mem_reservation: 256m
cpus: 1.5
pids_limit: 256
ulimits:
nofile:
soft: 65536
hard: 65536
restart: unless-stoppedservices:
api:
image: my-api:1.4.0
deploy:
replicas: 2
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
cpus: "0.5"
memory: 256MNote the semantic difference: reservations is the minimum guarantee promised by the scheduler (the container is "entitled" to it), while limits is the hard fence that must not be crossed. Validate the result with docker inspect or, for stacks, docker service inspect.
Memory without memory-swap. As discussed: swap becomes twice --memory, and latency-sensitive workloads swallow huge delays. Set --memory-swap explicitly, or make it equal to --memory to disable swap.
Host swappiness too high. The vm.swappiness value (default 60 on many distros) makes the kernel move pages to swap prematurely — bad for memory-sensitive containers. On container-dominated hosts, sysadmins often lower it to 10 or even 0:
sudo sysctl -w vm.swappiness=10Giving --pids-limit no bounds. A container without a PID limit can become a fork bomb machine: one self-duplicating process exhausts the host's PID space and eventually the whole host can't create new processes — including SSH login processes. Set limits from the start.
Limits without monitoring. Setting limits isn't the end of the work — you must watch docker stats and long-term metrics. Limits too small cause repeated OOMs; limits too large make the host over-committed. Both only show up with data.
In this episode 25 you've understood that behind every Docker resource flag stands cgroups v2, enforced by the kernel: --memory, --memory-swap, --memory-reservation for memory limits; --cpus and --cpuset-cpus for CPU; --pids-limit and --ulimit for processes and file descriptors. You also understood OOM-killer behavior — including why --oom-kill-disable is a risky decision that needs a strong reason — how to read docker stats for capacity planning, the impact of network drivers on latency, and reasonable starting limits for databases, caches, and web servers.
Core takeaways:
--memory-swap explicitly; forgetting it means giving twice the swap.--oom-kill-disable.docker stats is a capacity planning tool; record baselines, not snapshots.--privileged.A healthy host with well-guarded containers sets the foundation for a deeper topic: security. In the next episode, episode 26, we'll discuss Docker Security Advanced — Secrets, Rootless & Supply Chain: how to store and deliver secrets without baking them into image layers, running the daemon without root with Rootless Docker and userns-remap, and a supply chain security checklist from digest pinning, CI scanning, image signing with Cosign, to SBOM. See you in episode 26!