Setting Docker CPU and Memory Limits the Right Way
AI generated
Docker · Resources · cgroups · DevOps
Setting Docker CPU and Memory Limits the Right Way
cgroups, the OOM killer and Compose configuration explained

A single container without limits can bring an entire host to its knees. Understanding Docker CPU and memory limits prevents resource monopolies, makes capacity planning measurable, and protects every service on the same host, not just your own.

12 min read --cpus · --memory · --memory-swap · cgroups · OOM diagnosis Docker 24+ · Compose v2 · Linux cgroups v2

1. Why Docker memory limits are not an optional nicety

A container without configured Docker memory limits can, in principle, consume an unlimited amount of the host's RAM. That might seem harmless in a development environment, but it is a serious problem on a shared production host. If the memory footprint of a single container grows uncontrolled, for example because a loop produces a memory leak or an unbounded import job is running, the Linux kernel starts terminating processes. The OOM killer does not necessarily target the responsible process, but rather the one using the most memory. That could be the database process of a completely different container.

The same applies to CPU. Without Docker CPU limits, a compute-heavy container can monopolize all available cores for extended periods. Other containers running on the same host, such as Nginx, Redis, or the PHP-FPM process of the main application, receive almost no CPU time during that window and become noticeably slow or unresponsive for end users. Setting Docker CPU and memory limits establishes clear, predictable capacity boundaries per service and makes the host as a whole more stable.

2. cgroups v1 and v2: what Docker actually does under the hood

Docker uses Linux control groups (cgroups) to constrain resources on a per-container basis. When you start a container with docker run --memory 512m, Docker internally creates a cgroup hierarchy and writes the limit into the corresponding cgroup subsystem. The kernel then enforces that limit without any further involvement from the container runtime. With the move to cgroups v2, active by default since Linux 5.10 and in modern distributions such as Ubuntu 22.04, the subsystems were unified: instead of separate directories for memory, cpu and blkio, there is now a single interface under /sys/fs/cgroup/.

Docker 24 and later fully supports cgroups v2. Configuration does not change for users, but enforcement on the kernel side is more consistent. One important difference: with cgroups v2, Docker memory limits can be enforced more precisely for kernel memory and memory-mapped files as well. The command cat /proc/cgroups shows which subsystems are active, and stat -fc %T /sys/fs/cgroup/ reveals whether cgroups v2 is active, the output will then read cgroup2fs.


# Check whether cgroups v2 is active on the host
stat -fc %T /sys/fs/cgroup/
# Output "cgroup2fs" = v2, "tmpfs" = v1

# Inspect the cgroup limits Docker set for a running container
CONTAINER_ID=$(docker inspect --format '{{.Id}}' my-app)
# cgroups v2 path
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-${CONTAINER_ID}.scope/cpu.max

# Docker's own view of resource constraints
docker inspect --format '{{.HostConfig.Memory}} bytes, {{.HostConfig.NanoCpus}} NanoCPUs' my-app

# Live resource usage of all running containers
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"

Important: Docker memory limits only constrain the container's userspace memory, not swap automatically. Without an explicit --memory-swap, a container can by default use the same amount again as swap, so a container with --memory 512m can end up using up to 1 GiB of memory (RAM plus swap). To prevent this, set --memory-swap explicitly to the same value as --memory.

3. Configuring memory limits: --memory, --memory-swap and --memory-reservation

The --memory (or -m) parameter sets the hard limit on a container's RAM usage. If the container exceeds this value, the OOM killer steps in and terminates processes inside the container, by default the process with the highest RSS (resident set size). --memory-swap controls the combined RAM and swap limit. Setting both to the same value disallows swap entirely. Setting --memory-swap -1 allows the container to use unlimited swap, which is rarely a good idea. The difference between --memory-swap and --memory is the amount of swap the container is allowed to use.

The soft limit --memory-reservation is a gentler threshold. Docker does not actively enforce it, but when the host comes under memory pressure, the system tries to push container memory usage down toward the reservation level. This is useful for services that normally need little RAM but should be allowed to use more briefly, as long as enough is available. Combining --memory 1g with --memory-reservation 512m means the hard limit sits at 1 GiB, but under host pressure the system aims to keep the container closer to 512 MiB.

4. Configuring CPU limits: --cpus, --cpu-shares and --cpuset-cpus

The --cpus parameter is the most modern and direct way to set Docker CPU limits. With --cpus 0.5, the container may use at most half a CPU core; with --cpus 2.0, up to two full cores. Internally, Docker translates this into the cgroup parameters cpu.cfs_quota_us and cpu.cfs_period_us. This is hard throttling: if the container exceeds its quota, it is not scheduled again for the rest of the period, even if other cores are sitting completely idle.

The --cpu-shares option works differently: it does not set an absolute ceiling, but a relative weighting. With the default value of 1024, every container gets an equal share. A container with 2048 gets twice as much CPU time as one with 1024, but only when there is contention. If the CPU is idle, every container can compute without limit. That makes --cpu-shares useful for priority assignment, but not for hard resource limiting. --cpuset-cpus pins a container to specific CPU cores, which is useful for NUMA architectures or latency-sensitive services that cannot tolerate CPU cache thrashing caused by other processes.


# Hard CPU limit: max 1.5 cores, max 512 MiB RAM, no swap allowed
docker run -d \
  --name php-fpm \
  --cpus 1.5 \
  --memory 512m \
  --memory-swap 512m \
  --memory-reservation 256m \
  my-php-fpm:latest

# Priority-based sharing: db gets twice the CPU weight vs. app
docker run -d --name db   --cpu-shares 2048 mysql:8.4
docker run -d --name app  --cpu-shares 1024 my-app:latest

# Pin container to cores 0 and 1 (NUMA / cache isolation)
docker run -d --cpuset-cpus "0,1" --memory 2g my-compute:latest

# Verify actual cgroup quota written by Docker
docker inspect php-fpm | grep -E '"NanoCpus"|"Memory"'
# NanoCpus: 1500000000 = 1.5 CPUs, Memory: 536870912 = 512 MiB

5. Defining resource limits in Docker Compose

In Docker Compose v2, Docker CPU and memory limits are configured under the deploy.resources key. This mirrors the Swarm syntax, but since Compose v2 it also applies to plain docker compose up deployments without Swarm. The limits key sets hard upper bounds, while reservations sets soft minimum reservations. It is important to understand that Docker Compose passes the reservations value to the kernel as a soft hint, it does not guarantee dedicated memory, but it does influence scheduling under host pressure.

If you run several environments with different resource budgets, for example tighter limits for CI and more generous ones for production, you can parametrize the Compose file through environment variables. ${PHP_MEMORY_LIMIT:-512m} in the Compose file reads the value from the environment and falls back to 512m if the variable is not set. This allows a single Compose file to serve every environment without hardcoding values.


# docker-compose.yml: resource limits for a PHP/MySQL stack (Compose v2 syntax)
services:
  php-fpm:
    image: my-php-fpm:8.4
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: ${PHP_MEMORY_LIMIT:-512m}
        reservations:
          cpus: "0.25"
          memory: 256m

  mysql:
    image: mysql:8.4
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 2g
        reservations:
          cpus: "0.5"
          memory: 1g
    environment:
      # Tell MySQL to stay within Docker memory limits
      MYSQL_INNODB_BUFFER_POOL_SIZE: "1073741824"  # 1 GiB

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 192m

6. Detecting and diagnosing OOM situations

When a container is terminated by the OOM killer, it shows up with status Exited (137), exit code 137 corresponds to signal 9 (SIGKILL). That is the first clue that an OOM situation occurred. To confirm it, check the kernel journal: dmesg | grep -i "oom\|killed" shows the OOM entries with a timestamp, the affected processes, and a meminfo snapshot of that moment. This entry also records the configured Docker memory limit and how much the container was actually using.

A more subtle problem is swap thrashing: the container exceeds its RAM limit, starts using swap, and becomes extremely slow without being killed outright. You can spot this via the MemUsage metric in docker stats approaching the limit, along with a sharp rise in disk I/O latency. In this case, the right response is not to raise the memory limit, but to first measure the container's memory profile under real load, because there is often a genuine problem in the application code that more RAM would only mask.

7. Determining sensible thresholds for PHP, Node and database containers

The most common question about Docker memory limits is: what value should I set? A sound method is profiling under realistic load. Start the container without any limits, run typical load against it, and observe peak memory usage with docker stats over several minutes. Then set the limit to 1.5 times the measured peak, as a buffer for short-term spikes. Starting directly from a theoretical maximum tends to set limits too tight and produces OOM kills during normal operation.

For PHP-FPM containers, a good rule of thumb is: memory per worker process multiplied by the number of configured workers, plus 10 to 20 percent overhead. With 10 workers at roughly 40 MiB each, you land around 440 to 480 MiB as the limit. For Node.js containers, --max-old-space-size is the critical parameter, it must be set explicitly, otherwise the garbage collector has no awareness of the Docker limit and will exceed it. MySQL and MariaDB should be configured with innodb_buffer_pool_size so that the buffer pool plus overhead does not exceed the Docker memory limit, otherwise an OOM kill can strike at the worst possible moment.


# Profile memory peak under realistic load (sample every 2s for 5 min)
docker stats --no-stream --format "{{.MemUsage}}" php-fpm
# Run load test in parallel, then read peak from stats output

# Node.js: inform V8 garbage collector about the memory limit
docker run -d \
  --name node-app \
  --memory 512m \
  --memory-swap 512m \
  -e NODE_OPTIONS="--max-old-space-size=400" \
  my-node:20-alpine

# PHP-FPM: calculate per-worker memory and set limit accordingly
# pm.max_children = 10, ~40 MiB each => set limit to 512m with buffer
docker run -d \
  --name php-fpm \
  --memory 512m \
  --memory-swap 512m \
  -e PHP_MEMORY_LIMIT=256M \
  my-php-fpm:8.4

# Check OOM events in kernel log
dmesg --time-format iso | grep -i "out of memory\|oom_kill\|killed process" | tail -20

8. Continuously monitoring resource usage

The built-in docker stats command provides real-time metrics: CPU percentage, RAM usage relative to the limit, network I/O and block I/O. That is enough for manual checks, but not sufficient for alerting or historical analysis. For production environments, integrating Prometheus and cAdvisor is recommended: cAdvisor exports all Docker metrics in a Prometheus-compatible format and makes them usable in Grafana dashboards. Particularly relevant metrics are container_memory_usage_bytes relative to container_spec_memory_limit_bytes, as well as container_cpu_cfs_throttled_seconds_total, which shows how often the container was throttled by its Docker CPU limit.

A simple early-warning setup without Prometheus: a cron job that evaluates docker stats --no-stream and sends an alert if a container is using more than 80 percent of its Docker memory limit. That buys time to react before the OOM killer intervenes. docker events also streams real-time events from the Docker daemon, including OOM events, which can be filtered directly with --filter event=oom.

9. Configuration options compared

The various resource parameters behave differently and serve different purposes. Choosing the right parameter for Docker CPU and memory limits depends on whether you need a hard maximum, a relative priority, or a soft reservation.

Parameter Type Effect Typical use
--memory Hard limit OOM kill when exceeded All production containers
--memory-swap Hard limit RAM plus swap combined Disabling swap entirely
--memory-reservation Soft limit Target under host pressure Services with variable demand
--cpus Hard limit Throttling via CFS quota Compute-heavy containers
--cpu-shares Relative weight Priority under contention Prioritization without a hard cap

In practice, it is worth combining a hard --memory limit with a --memory-reservation value set at roughly 50 to 60 percent of the hard limit. This gives the container room for short-term spikes while still signaling to the kernel what its baseline needs are. For CPU, --cpus with a realistic value is enough in most web service scenarios, a PHP-FPM container with 8 workers typically has no need to claim more than 2 cores.

Mironsoft

Docker infrastructure, resource planning and production deployments

Docker stack without resource conflicts?

We analyze existing container stacks, measure real resource profiles under load, and set sensible Docker memory limits and CPU caps, so that no single container can destabilize the whole host again.

Resource profiling

Peak measurement under realistic load and derivation of sensible per-service limits

OOM analysis

Diagnosing existing OOM situations and fixing root causes in the application code

Monitoring setup

Configuring cAdvisor plus Prometheus plus Grafana for continuous resource monitoring

10. Summary

Docker CPU and memory limits are not an optional feature, they are a prerequisite for stable production environments running multiple containers on the same host. The kernel enforces these limits via cgroups, Docker translates the --memory and --cpus parameters directly into cgroup configuration. A hard --memory limit prevents a container from consuming unlimited RAM and steering the OOM killer toward critical processes. Setting --memory-swap to the same value as --memory disables swap entirely and avoids invisible swap thrashing. --cpus throttles CPU usage via CFS quotas.

Sensible thresholds are determined through profiling under realistic load, not by guesswork. On the application side, Node.js processes must be informed of their memory ceiling via --max-old-space-size, and MySQL via innodb_buffer_pool_size, because these processes have no automatic awareness of the Docker limit. In Docker Compose v2, all limits are configured centrally under deploy.resources, and values can be varied per environment through environment variables.

Docker CPU and memory limits: the essentials at a glance

Hard memory limit

Set --memory and --memory-swap to the same value to disable swap entirely. Limit = 1.5x the measured peak under load.

CPU throttling

--cpus sets a hard CFS quota limit. Under contention, additionally use --cpu-shares for relative priority assignment.

OOM diagnosis

Exit code 137 means an OOM kill. Confirm via dmesg | grep oom. Fix the cause in the application code instead of just raising the limit.

Compose configuration

deploy.resources.limits and reservations in Compose v2. Parametrize values per environment through environment variables.

11. FAQ: Docker CPU and Memory Limits

1What happens when the memory limit is exceeded?
The OOM killer terminates processes inside the container. Exit code 137 and dmesg entries are the reliable diagnostic signs.
2--memory vs. --memory-swap?
--memory limits RAM, --memory-swap limits RAM plus swap combined. Same value means no swap allowed.
3How do you determine sensible limits?
Measure the peak under realistic load (docker stats), then set the limit to 1.5x that value, do not guess.
4Node.js and Docker memory limits?
Always set --max-old-space-size explicitly. V8 has no automatic awareness of the Docker limit and grows beyond it without this parameter.
5--cpus vs. --cpu-shares?
--cpus is hard throttling via CFS quota. --cpu-shares is a relative weighting that only matters under contention.
6Resource limits in Docker Compose?
deploy.resources.limits for hard limits, deploy.resources.reservations for soft hints. Compose v2, no Swarm required.
7What is cgroups v2?
A unified Linux resource management system. Docker 24+ fully supports it. Check with: stat -fc %T /sys/fs/cgroup/.
8Ongoing monitoring of limits?
cAdvisor plus Prometheus plus Grafana for production setups. Simple option: a cron job with docker stats --no-stream.
9Slow container without an OOM kill?
CPU throttling from --cpus, or swap thrashing from a tight memory limit. container_cpu_cfs_throttled_seconds_total in Prometheus shows throttling.
10Set limits in development too?
Recommended. Prevents crashes on the developer machine and surfaces resource problems early, before they occur in production.