Overview and What You Will Learn
By default, a Docker container can use all the CPU and memory on the host. This means one badly behaving container — a memory leak, an infinite loop, a sudden traffic spike — can consume every available resource and crash every other container running on the same host. In production, this is unacceptable.
In this guide you will learn how to set CPU and memory limits on Docker containers, what happens when those limits are exceeded (the answer is different for CPU and memory), how to monitor resource usage in real time, and how to set limits in Docker Compose. You will also understand the Linux cgroup mechanism that makes all of this possible.
Why This Matters in Production
At CRED, dozens of microservices run on shared EC2 instances. Without resource limits, a payment processing container with a memory leak could consume all available memory on the host, killing the database container, the cache container, and every other service on that machine. Resource limits are the insurance policy that prevents one container's failure from becoming every container's failure.
Core Principles
Docker uses Linux cgroups (control groups) to enforce resource limits. Cgroups are a kernel feature that limits the resources a group of processes can use. Docker creates a cgroup for each container and applies your limits to it.
+------------------------------------------+| Linux Host || 16 CPU cores, 32GB RAM || || +------------------+ || | Container A | <- CPU limit: 2 || | cgroup enforced | Memory: 512MB || +------------------+ || || +------------------+ || | Container B | <- CPU limit: 4 || | cgroup enforced | Memory: 2GB || +------------------+ || || +------------------+ || | Container C | <- No limits set || | NO cgroup limits | DANGEROUS || | Can use all 16 | || | CPUs and 32GB | || +------------------+ |+------------------------------------------+The most important thing to understand: CPU exceeded = throttled. Memory exceeded = killed.
CPU limit exceeded: Container tries to use more than its limit Kernel throttles it — slows down, stays running Other containers are not affected Application becomes slow but does not crash Memory limit exceeded: Container tries to allocate more than its limit Kernel OOMKills the container process immediately Exit code 137 Container stops (and restarts if restart policy is set)Detailed Step-by-Step Practical Lab
Milestone 1: Setting Memory Limits
# Set a hard memory limitdocker run -d \ --name payment-api \ --memory=512m \ registry.razorpay.in/payment-api:v3.1.0 # Memory values: b (bytes), k (kilobytes), m (megabytes), g (gigabytes)# --memory=512m = 512 megabytes# --memory=2g = 2 gigabytes # Verify the limit was applieddocker inspect --format '{{.HostConfig.Memory}}' payment-api# 536870912 = 512 * 1024 * 1024 bytes = 512MB # --memory-swap controls total memory + swap# By default: --memory-swap equals --memory (no swap allowed)# To allow swap: set --memory-swap higher than --memorydocker run -d \ --name payment-api \ --memory=512m \ --memory-swap=1g \ registry.razorpay.in/payment-api:v3.1.0# Container can use 512MB RAM + 512MB swap (1GB total - 512MB RAM) # To disable swap entirely for this container:docker run -d \ --name payment-api \ --memory=512m \ --memory-swap=512m \ registry.razorpay.in/payment-api:v3.1.0# --memory-swap equals --memory = no swap allowed # Soft memory limit (reservation) — Docker can use this for scheduling hintsdocker run -d \ --name payment-api \ --memory=512m \ --memory-reservation=256m \ registry.razorpay.in/payment-api:v3.1.0# Guarantees 256MB minimum, allows bursting to 512MB# Useful when running on Kubernetes-like schedulersMilestone 2: What Happens When Memory Limit Is Exceeded
# Simulate an OOMKilldocker run -d \ --name oom-test \ --memory=50m \ python:3.11-slim \ python3 -c "x = []while True: x.append(' ' * 10000000) # allocate 10MB chunks print(f'Allocated {len(x) * 10}MB')" # Watch it get killeddocker logs -f oom-test# Allocated 10MB# Allocated 20MB# Allocated 30MB# Allocated 40MB# (killed — no more output) # Check exit codedocker inspect --format '{{.State.ExitCode}} OOMKilled: {{.State.OOMKilled}}' oom-test# 137 OOMKilled: true # The kernel logs this in dmesgsudo dmesg | grep -i "killed process"# [1234567.890] Killed process 12345 (python3) total-vm:60000kB,# anon-rss:55000kB, file-rss:1000kB, shmem-rss:0kBMilestone 3: Setting CPU Limits
CPU limits work differently from memory limits. There are two main ways to limit CPU:
# Method 1: --cpus — fractional CPU cores (recommended)docker run -d \ --name api \ --cpus=1.5 \ registry.swiggy.in/order-api:v2.0.0# Container can use at most 1.5 CPU cores worth of processing# On a 4-core host: this container gets at most 37.5% of total CPU # --cpus=0.5 = half a CPU core# --cpus=1 = one full CPU core# --cpus=2.5 = two and a half CPU cores # Method 2: --cpu-shares — relative weight (soft limit)docker run -d --name high-priority --cpu-shares=1024 high-priority-servicedocker run -d --name low-priority --cpu-shares=512 low-priority-service# When both are competing for CPU:# high-priority gets 2x the CPU time of low-priority# When only one is running, it can use 100% of CPU# Default cpu-shares is 1024 # Method 3: Pin to specific CPUsdocker run -d \ --name cpu-pinned \ --cpuset-cpus="0,1" \ registry.zerodha.in/trading-engine:v4.1.0# This container ONLY runs on CPU cores 0 and 1# Useful for performance-sensitive workloads that benefit from CPU affinity# And for preventing noisy neighbours from sharing CPU cache # Verify CPU limits applieddocker inspect --format '{{.HostConfig.NanoCpus}}' api# 1500000000 = 1.5 * 1000000000 nanocpus = 1.5 CPUs docker inspect --format '{{.HostConfig.CpusetCpus}}' cpu-pinned# 0,1Milestone 4: Monitoring Resource Usage
# Live monitoringdocker stats # Sample output:# CONTAINER ID NAME CPU % MEM USAGE/LIMIT MEM % NET I/O BLOCK I/O# a84f9c2b1d3e payment-api 2.5% 128MiB/512MiB 25.0% 2MB/1MB 0B/0B# b72c8a9f4e1d order-api 45.2% 420MiB/512MiB 82.0% 10MB/5MB 1MB/0B# c91d8b3f2a5e postgres 0.8% 256MiB/1GiB 25.0% 100B/50B 50MB/5MB # Reading the table:# order-api at 45.2% CPU — high but within the 1.5 CPU limit# order-api at 82% memory — getting close to the 512MB limit, investigate!# postgres at 0.8% CPU — healthy, mostly idle # One-shot snapshot — useful in scriptsdocker stats --no-stream --format \ "{{.Name}}: CPU={{.CPUPerc}} MEM={{.MemUsage}} ({{.MemPerc}})"# payment-api: CPU=2.5% MEM=128MiB / 512MiB (25.0%)# order-api: CPU=45.2% MEM=420MiB / 512MiB (82.0%) # Watch for containers approaching their limitswatch -n 5 'docker stats --no-stream' # Update limits on a running container without stopping itdocker update --memory 1g payment-apidocker update --cpus 2 payment-api# This changes the cgroup limits live — no restart needed# Useful when you need to give a struggling container more resources immediatelyMilestone 5: Resource Limits in Docker Compose
Setting limits per-container with docker run flags does not scale. In Docker Compose, resource limits go in the deploy.resources section:
# docker-compose.ymlversion: "3.8" services: payment-api: image: registry.razorpay.in/payment-api:v3.1.0 ports: - "8080:8080" deploy: resources: limits: cpus: "1.5" # Hard ceiling — throttled if exceeded memory: 512M # Hard ceiling — OOMKilled if exceeded reservations: cpus: "0.5" # Soft guarantee — scheduler reserves this memory: 256M # Soft guarantee — scheduler reserves this order-api: image: registry.swiggy.in/order-api:v2.0.0 deploy: resources: limits: cpus: "2" memory: 1G reservations: cpus: "0.5" memory: 512M postgres: image: postgres:15 volumes: - postgres-data:/var/lib/postgresql/data deploy: resources: limits: cpus: "2" memory: 2G reservations: cpus: "0.5" memory: 1G environment: POSTGRES_PASSWORD_FILE: /run/secrets/db-password volumes: postgres-data:# Run compose with resource limits applieddocker compose up -d # Verify limits were applieddocker stats --no-streamMilestone 6: Choosing the Right Limits
The most common question is: how do I know what limits to set? The answer is always: measure first, then set.
# Step 1: Run the container without limits for a perioddocker run -d --name measure-api registry.razorpay.in/payment-api:v3.1.0 # Step 2: Generate realistic load (your normal traffic pattern)# Use a load testing tool: k6, wrk, Apache Bench, Locust # Step 3: Monitor resource usage under loaddocker stats --no-stream measure-api# NAME CPU % MEM USAGE MEM %# measure-api 35.2% 380MiB (no limit) # Step 4: Set limits based on observed peak + safety margin# CPU: set limit to ~2x observed peak (allows burst, prevents monopoly)# Peak observed: 35% of 1 CPU = 0.35 CPUs# Set limit to: 0.75 CPUs (2x with some headroom) # Memory: set limit to observed peak + 50% safety margin# Peak observed: 380MiB# Set limit to: 570MiB, round up to 600MiB docker run -d \ --name payment-api \ --cpus=0.75 \ --memory=600m \ registry.razorpay.in/payment-api:v3.1.0Connection to Kubernetes
If you are learning Docker as a path to Kubernetes, resource limits are where the two platforms align perfectly:
Docker Kubernetes--memory=512m -> resources.limits.memory: 512Mi--memory-reservation= -> resources.requests.memory:--cpus=1.5 -> resources.limits.cpu: 1500m--cpu-shares (soft) -> resources.requests.cpu:OOMKilled (exit 137) -> OOMKilled pod status (same kernel mechanism)CPU throttled -> CPU throttled (same cgroup mechanism)The exact same Linux cgroup mechanism is used. Understanding Docker resource limits means understanding Kubernetes resource limits.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| No memory limit on any container | One leaking container crashes the host | Set limits on every production container |
| Setting memory limit too low | Random OOMKills that look like app crashes | Measure peak usage + 50% margin |
Not setting --memory-swap |
Container can use unlimited swap, causing I/O thrash | Set --memory-swap equal to --memory to disable swap |
Using --cpu-shares alone |
Only affects CPU-contention periods, not a hard limit | Use --cpus for actual CPU ceiling enforcement |
Setting limits in compose but using docker compose up without -d |
On some Docker versions, deploy.resources is ignored in non-swarm mode | Verify with docker stats that limits are applied |
Troubleshooting Reference
| Symptom | Likely Cause | Diagnostic Command | Fix |
|---|---|---|---|
| Container exits with code 137 | OOMKilled | docker inspect --format '{{.State.OOMKilled}}' |
Increase --memory limit |
| Container slow but not crashing | CPU throttled | docker stats — check CPU % vs limit |
Increase --cpus limit |
| Container crashes and restarts repeatedly | Memory leak hitting limit | docker stats over time — memory growing |
Fix app memory leak or increase limit temporarily |
| Host runs out of memory | No limits on containers | docker stats --no-stream |
Set limits on all containers immediately |
PLACEMENT PRO TIP**Tip:** Always set both `limits` and `reservations` in Docker Compose. Limits prevent a container from consuming too much. Reservations tell the scheduler to guarantee a minimum — useful when containers compete for resources and you want to ensure critical services always have enough.
REMEMBER THIS**Remember:** The Linux OOM killer uses exit code 137 — the same exit code as `docker kill` (SIGKILL). Always check `docker inspect --format '{{.State.OOMKilled}}'` to confirm an OOMKill versus a manual kill before spending time investigating application code.
COMMON MISTAKE / WARNING**Common Mistake:** Setting memory limits without load testing first. Engineers often set limits based on gut feeling — 512MB for an API sounds reasonable — but the application might use 800MB under real load. The result is random OOMKills in production that look like application bugs. Always measure under realistic load before setting limits.
COMMON MISTAKE / WARNING**Security:** On shared infrastructure, resource limits are a security control as well as an operational one. A container without limits can perform a denial-of-service attack on other containers by consuming all CPU or memory. Even for trusted internal services, always set limits — a bug causing a memory leak in a trusted container is just as dangerous as a malicious container with no limits.