Docker Compose Resource Limits, Memory and CPU
TL;DR — Set
deploy.resources.limitsper service to cap memory/CPU. Prevents one runaway container from killing the dev machine. JVM needsJAVA_OPTS=-XX:MaxRAMPercentage=75; Node needs--max-old-space-size. CPU limits usecpus: "0.5"notation.
After build vs image, the operational dimension: keeping a Compose stack from eating the host. Especially on Mac, where Docker Desktop’s VM has fixed RAM, unbounded containers crash everything.
The syntax
services:
api:
image: my-api:dev
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
Two concepts:
limits— hard caps. Container is throttled (CPU) or OOM-killed (memory) if exceeded.reservations— soft floor. Scheduler tries to allocate at least this much.
The deploy: key is technically Swarm-mode syntax, but Compose v2 honors deploy.resources in standalone mode too. For non-deploy contexts you can also use the older mem_limit: and cpus: directly:
services:
api:
mem_limit: 512m
cpus: 1.0
Either works; deploy.resources is more explicit.
Why memory limits matter in dev
One JVM service with no limit will allocate as much heap as it can. On a Mac with Docker Desktop set to 8GB, one bad service can starve everything else. Symptoms:
docker compose upruns fine for 10 min then everything stops responding- Other containers OOM-kill
- “Docker is using too much RAM” warnings
The fix: limit each service. The stack’s total memory: should sum to less than your Docker Desktop allocation.
Per-language tuning
JVM (Java, Scala, Kotlin):
Default JVM heap is 25% of container memory. With memory: 1G, JVM heap is 256M — usually too small. Force explicit:
api:
environment:
JAVA_OPTS: "-XX:MaxRAMPercentage=75"
deploy:
resources:
limits:
memory: 1G
JVM 11+ respects container memory limits via cgroups. Older versions need -XX:+UseContainerSupport flag.
Node:
api:
environment:
NODE_OPTIONS: "--max-old-space-size=512"
deploy:
resources:
limits:
memory: 768M
max-old-space-size is in MB. Set ~75% of container limit; reserve room for non-heap memory.
Python (memory):
Python doesn’t have a “max heap” setting. Limit is per-process. For workers spawning subprocesses, limit total memory:
worker:
deploy:
resources:
limits:
memory: 512M
If the worker OOMs, you’ll see it killed; tune up or refactor.
Go:
Default behavior fine. Go’s runtime auto-detects container memory (Go 1.19+). For Go 1.18 and earlier, set GOMEMLIMIT:
api:
environment:
GOMEMLIMIT: 400MiB # Go 1.19+
deploy:
resources:
limits:
memory: 512M
CPU limits
deploy:
resources:
limits:
cpus: "1.5" # 1.5 cores; floats allowed
Container is throttled if it tries to use more. Useful for:
- Preventing one service from starving others
- Simulating production CPU constraints during dev
- Stress testing scaling
For most dev, CPU limits are less critical than memory limits. CPU throttles back to “running slowly”; memory exceeds OOM-kills.
Reservation vs limit
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
reservations— “I need at least this much guaranteed.” Compose tries to schedule; warning if can’t.limits— “I get at most this much.”
In dev these are mostly informational. The scheduler isn’t tight (single machine, lots of headroom). They matter in Swarm or k8s.
A complete stack with limits
name: dev-stack
services:
postgres:
image: postgres:14-alpine
deploy:
resources:
limits: { cpus: "1.0", memory: 512M }
redis:
image: redis:7-alpine
deploy:
resources:
limits: { cpus: "0.5", memory: 256M }
api:
build: ./services/api
deploy:
resources:
limits: { cpus: "1.0", memory: 512M }
bff:
build: ./services/bff
deploy:
resources:
limits: { cpus: "0.5", memory: 384M }
worker:
build: ./services/worker
deploy:
resources:
limits: { cpus: "0.5", memory: 256M }
Total: ~3.5 CPU, ~1.9 GB. Comfortably under Docker Desktop’s 8 GB allocation. Leaves 6 GB for OS + other apps.
Monitoring usage
docker stats
Shows live per-container CPU, mem, mem%, network, block I/O. Use to see if your limits are right.
docker compose top
Shows processes inside each service. Useful for finding the rogue process inside a container.
For more sophisticated monitoring, the Prometheus + Grafana stack from September’s posts works in dev too.
Common Pitfalls
No limits = one service kills others. Always set memory limits. CPU optional.
JVM with no MaxRAMPercentage. Heap = 25% of container; you wonder why it OOMs at 200MB when you set limit to 800MB.
Limits too low. Container OOM-kills repeatedly during normal operation. Either raise limit or fix the leak.
mem_swappiness for swap. Docker Desktop on Mac doesn’t really swap. Don’t rely on it.
deploy: key being ignored. Compose v2 honors deploy.resources; older v1 needed mem_limit: at top level. Mixed env: use the older syntax.
Identical limits across all services. Different services have different needs. Tune per workload.
Wrapping Up
Memory limits are non-negotiable for multi-service dev. CPU limits optional but useful for simulating constraints. Per-language tuning (JVM heap, Node max-old-space, Go GOMEMLIMIT) is the part most teams skip. Monday: Compose for CI.