background-shape
Docker Compose Resource Limits, Memory and CPU
July 22, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — Set deploy.resources.limits per service to cap memory/CPU. Prevents one runaway container from killing the dev machine. JVM needs JAVA_OPTS=-XX:MaxRAMPercentage=75; Node needs --max-old-space-size. CPU limits use cpus: "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 up runs 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.