← Back to DevBytes

Docker Resource Limits: Best Practices and Common Pitfalls

Understanding Docker Resource Limits

Docker containers, by default, have unrestricted access to the host machine's resources. This means a single container can theoretically consume all available CPU cycles, memory, or disk I/O, leaving other containers and even the host system starved and unstable. Docker resource limits allow you to define precise boundaries—CPU shares, memory caps, swap limits, and more—ensuring predictable performance and preventing noisy neighbor problems in multi-container environments.

Why Resource Limits Matter

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Running containers without limits is like inviting guests to an all-you-can-eat buffet with no rules. One hungry container can ruin the experience for everyone else. Here are the core reasons why setting resource limits is non-negotiable in production:

How to Apply Resource Limits

CPU Limits: Shares, Quotas, and Cpuset

Docker offers three complementary mechanisms for CPU resource control. Understanding when to use each is key to building resilient systems.

1. CPU Shares (Relative Weight)

CPU shares provide a relative weighting system. The default is 1024 shares per container. A container with 2048 shares receives roughly twice the CPU time as one with 1024 shares when there's contention. Critically, CPU shares only matter when the CPU is under contention—during idle periods, any container can burst freely.

# Run a container with CPU shares = 512 (half the default weight)
docker run -d --cpu-shares 512 --name low-priority-app myapp:latest

# Run a container with CPU shares = 2048 (double the default weight)
docker run -d --cpu-shares 2048 --name high-priority-app myapp:latest

In Docker Compose, the equivalent configuration looks like this:

version: '3.8'
services:
  high-priority:
    image: myapp:latest
    cpu_shares: 2048
    
  low-priority:
    image: myapp:latest
    cpu_shares: 512

2. CPU Quota (Hard Limit)

CPU quota provides an absolute upper bound on CPU usage per scheduling period (default 100,000 microseconds, or 100ms). Setting --cpu-quota=50000 means the container can use at most 50ms of CPU per 100ms window—effectively capping it at 50% of a single core. This is a hard limit; the container cannot exceed it even when the host is completely idle.

# Cap this container at 1.5 CPU cores (quota=150000 per 100ms period)
docker run -d --cpu-quota=150000 --cpu-period=100000 --name rate-limited-app myapp:latest

# Equivalent to --cpus=1.5 (Docker 1.13+ shorthand)
docker run -d --cpus=1.5 --name rate-limited-app myapp:latest

The --cpus flag is the modern, intuitive way to set CPU hard limits:

# Limit container to exactly 2 CPU cores
docker run -d --cpus=2 --name analytics-worker myworker:latest

# Limit container to 0.75 CPU cores (75% of one core)
docker run -d --cpus=0.75 --name background-task myworker:latest

Compose file with hard CPU limits:

services:
  analytics:
    image: myworker:latest
    deploy:
      resources:
        limits:
          cpus: '2'
        reservations:
          cpus: '1'

3. CPU Set (Pin to Specific Cores)

For latency-sensitive workloads, pinning a container to specific CPU cores reduces cache misses and context-switch overhead. This is especially valuable for real-time processing or high-frequency trading systems.

# Pin container to CPU cores 0 and 4
docker run -d --cpuset-cpus="0,4" --name pinned-app myapp:latest

# Pin container to a range of cores (0 through 3)
docker run -d --cpuset-cpus="0-3" --name range-pinned myapp:latest

Memory Limits: Hard Cap, Swap, and Soft Limit

Memory limits are arguably the most critical resource constraint. Without them, a container with a memory leak can exhaust host RAM, triggering the Linux OOM killer to terminate processes—potentially killing your database container or SSH daemon.

Hard Memory Limit

# Hard limit: 256MB of RAM
docker run -d --memory=256m --name mem-limited-app myapp:latest

# Hard limit in gigabytes
docker run -d --memory=2g --name large-mem-app myapp:latest

# The minimum allowed memory limit is 6MB
docker run -d --memory=6m --name tiny-app myapp:latest

When a container exceeds its hard memory limit, Docker kills the container (OOM) and potentially restarts it depending on the restart policy. The kernel OOM score adjusts automatically based on the limit, so the container is killed before host processes are affected.

Swap Limit

By default, Docker allows a container to swap twice its memory limit. You can control swap independently. Setting --memory-swap equal to --memory disables swap entirely, forcing the container to operate purely in RAM.

# 256MB RAM + 256MB swap (total 512MB virtual memory)
docker run -d --memory=256m --memory-swap=512m --name with-swap myapp:latest

# 256MB RAM, no swap allowed (container cannot swap at all)
docker run -d --memory=256m --memory-swap=256m --name no-swap-app myapp:latest

# 256MB RAM + unlimited swap (swap limit set to -1)
docker run -d --memory=256m --memory-swap=-1 --name unlimited-swap myapp:latest

Warning: Allowing unlimited swap while limiting RAM can mask memory leaks and cause severe performance degradation as the container thrashes to disk. Always set swap limits deliberately.

Memory Reservation (Soft Limit)

Memory reservation is a soft limit that Docker uses for scheduling decisions. It doesn't enforce a hard boundary but signals to the Docker engine how much memory the container "needs." When reclaiming memory under contention, Docker prioritizes containers that are below their reservation.

# Hard limit 512MB, soft reservation 256MB
docker run -d --memory=512m --memory-reservation=256m --name soft-limit-app myapp:latest

Combined CPU + Memory Limits in Compose

version: '3.8'
services:
  web:
    image: nginx:latest
    mem_limit: 128m
    mem_reservation: 64m
    mem_swappiness: 10
    cpus: '1.5'
    cpu_shares: 2048
    restart: unless-stopped
    
  worker:
    image: python-worker:latest
    mem_limit: 512m
    cpus: '2'
    cpu_shares: 1024
    restart: on-failure:5

Disk I/O Limits (Block IO Weight and Throttling)

For I/O-heavy workloads like databases or log processors, controlling disk throughput prevents a single container from saturating storage bandwidth.

# Set relative I/O weight (default 500, range 10-1000)
# Higher weight = more I/O bandwidth under contention
docker run -d --blkio-weight=700 --name io-heavy-app myapp:latest

# Throttle read/write bandwidth on a specific device
# Limit writes to 10MB/s and reads to 50MB/s on /dev/sda
docker run -d \
  --device-read-bps=/dev/sda:50mb \
  --device-write-bps=/dev/sda:10mb \
  --name throttled-io myapp:latest

# Throttle IOPS (operations per second)
docker run -d \
  --device-read-iops=/dev/sda:100 \
  --device-write-iops=/dev/sda:50 \
  --name iops-limited myapp:latest

PIDs Limit (Preventing Fork Bombs)

A fork bomb inside a container can consume all process IDs on the host. The --pids-limit flag caps the number of processes a container can spawn.

# Limit container to 200 processes
docker run -d --pids-limit=200 --name safe-container myapp:latest

# The default is unlimited—always set this for untrusted workloads

Inspecting and Monitoring Resource Limits

After applying limits, verify they're active. Use docker inspect to confirm the configuration is in place:

# Check resource limits on a running container
docker inspect --format='{{json .HostConfig.Memory}}' mem-limited-app
# Output: 268435456 (256MB in bytes)

# Inspect all resource-related fields
docker inspect --format='CPU Shares: {{.HostConfig.CpuShares}} | Memory: {{.HostConfig.Memory}} | CPU Quota: {{.HostConfig.CpuQuota}}' mycontainer

# Live resource usage via docker stats
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.PIDs}}"

The docker stats command shows real-time resource consumption against the defined limits, making it an invaluable tool for capacity planning:

NAME                CPU %      MEM USAGE / LIMIT       MEM %     PIDS
mem-limited-app     2.50%      127.4MiB / 256MiB       49.77%    12
io-heavy-app        15.30%     89.2MiB / 512MiB        17.42%    8
pinned-app          5.00%      45.1MiB / 1GiB          4.40%     4

Best Practices for Resource Limits

1. Always Set Memory Limits in Production

This is the single most important rule. Every production container must have a memory limit. A container without one can take down the entire host. Start with a generous limit based on observed usage, then tighten over time as you gather metrics.

# Start with a generous limit and monitor
docker run -d --memory=1g --name app-container myapp:latest
# After observing actual usage (e.g., 350MB steady-state), tighten:
docker update --memory=512m app-container

2. Use CPU Shares for Priority, CPU Quotas for Isolation

Use shares to express relative priority between related services. Use quotas when you need strict isolation—for example, preventing a tenant workload from impacting others. In multi-tenant SaaS platforms, hard CPU limits are essential for fair billing and predictable performance.

# Priority-based (shares): web tier gets more CPU under load
docker run -d --cpu-shares=2048 --name web-tier myapp:latest
docker run -d --cpu-shares=512 --name background-tier myapp:latest

# Isolation-based (quotas): each tenant gets exactly 2 cores
docker run -d --cpus=2 --name tenant-a myapp:latest
docker run -d --cpus=2 --name tenant-b myapp:latest

3. Set Memory Swap Explicitly

Never rely on the default swap behavior (2x memory). Explicitly set --memory-swap to control swap usage. For latency-sensitive services like databases, disable swap entirely to prevent GC pauses from disk thrashing. For batch processing, allow moderate swap to handle transient spikes.

# Database: no swap, pure RAM
docker run -d --memory=4g --memory-swap=4g --name postgres postgres:16

# Batch processor: 2GB RAM + 1GB swap for spikes
docker run -d --memory=2g --memory-swap=3g --name batch-processor myworker:latest

4. Reserve Memory for Critical Services

Use --memory-reservation alongside hard limits for services that must remain responsive. The reservation signals intent to the scheduler without imposing a hard ceiling.

# API gateway: hard limit 512MB, reservation 256MB
docker run -d --memory=512m --memory-reservation=256m --name api-gateway gateway:latest

5. Pin Latency-Sensitive Workloads to Cores

For real-time applications, use --cpuset-cpus to pin containers to specific cores, reducing scheduling jitter. Pair this with CPU quota to prevent the pinned container from monopolizing those cores.

# Pin to cores 4-7, but limit to 2 cores total usage
docker run -d --cpuset-cpus="4-7" --cpus=2 --name realtime-processor myapp:latest

6. Limit PIDs for Untrusted or User-Facing Containers

Any container that runs user-submitted code, handles arbitrary input, or is exposed to the internet should have a PID limit to prevent fork-bomb denial-of-service attacks.

# User code execution sandbox
docker run -d --pids-limit=100 --memory=256m --cpus=0.5 --name sandbox executor:latest

7. Apply Disk Limits for I/O-Intensive Workloads

Databases, log aggregators, and backup jobs should have I/O limits to prevent starving other containers of disk bandwidth. Monitor I/O pressure via docker stats and iotop inside the container, then set appropriate throttles.

# Log processor limited to 20MB/s writes
docker run -d --device-write-bps=/dev/sda:20mb --name log-processor processor:latest

8. Use Docker Compose for Declarative Limits

Define resource limits in version-controlled Compose files rather than ad-hoc CLI flags. This ensures limits are consistently applied across environments and easily auditable.

version: '3.8'
services:
  api:
    image: myapi:latest
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 512M
        reservations:
          cpus: '1'
          memory: 256M
    pids_limit: 200
    restart: unless-stopped

9. Monitor and Iterate

Resource limits are not set-and-forget. Establish a feedback loop: deploy with conservative limits, monitor actual usage via Prometheus + Grafana or the Docker stats API, then tighten limits incrementally. Over-provisioned limits waste cluster capacity; under-provisioned limits cause crashes.

# Query Docker stats programmatically
curl --unix-socket /var/run/docker.sock http://localhost/containers/app-container/stats

10. Test OOM Behavior Deliberately

Validate that your application handles OOM kills gracefully. Deliberately stress containers beyond their limits in staging to ensure restart policies, health checks, and graceful shutdown hooks work as expected.

# Deliberately trigger OOM in a test container
docker run --memory=64m --name oom-test alpine:latest \
  sh -c "dd if=/dev/zero of=/dev/stdout bs=128m count=1"

# Check exit code and restart behavior
docker inspect oom-test --format='{{.State.ExitCode}} {{.State.OOMKilled}}'

Common Pitfalls

Pitfall 1: Setting Only Memory Limits, Ignoring CPU

A common mistake is setting a memory limit but leaving CPU unbounded. The container can still starve other workloads of CPU time, causing mysterious slowdowns. Always pair memory limits with either CPU shares or quotas.

Pitfall 2: Misunderstanding CPU Shares vs. Quotas

Many developers confuse shares with quotas. Shares are relative and only kick in under contention. Quotas are absolute. Setting --cpu-shares=512 expecting the container to never exceed 50% CPU will lead to disappointment—it can still burst to 100% during idle periods. Use --cpus for hard caps.

Pitfall 3: Using --memory-swap=-1 (Unlimited Swap)

Allowing unlimited swap while capping RAM is dangerous. The container will slow to a crawl as it swaps to disk, and the memory leak that prompted the limit will silently grow on disk. Always pair --memory with a bounded --memory-swap.

# Dangerous: memory leak fills disk via swap
docker run -d --memory=256m --memory-swap=-1 --name dangerous-app myapp:latest

# Safe: total virtual memory capped at 512MB
docker run -d --memory=256m --memory-swap=512m --name safe-app myapp:latest

Pitfall 4: Applying Limits Without Monitoring

Limits without observability are blind. A container silently hitting its memory limit and being OOM-killed may go unnoticed if restart policies mask the crash. Set up alerts on OOM events and container restarts.

# Monitor OOM kills in kernel log
dmesg | grep -i "out of memory"
journalctl -k | grep -i oom

Pitfall 5: Ignoring PID Limits

Containers share the host PID namespace by default (unless --pid=host is used, which is its own risk). A fork bomb inside a container without --pids-limit can exhaust the host's PID table, preventing new processes system-wide—including your SSH session.

# Always set PID limits for untrusted containers
docker run -d --pids-limit=200 --name sandboxed-app myapp:latest

Pitfall 6: Hardcoding Limits Without Understanding Workload

Guessing limits leads to either wasted capacity or frequent OOM kills. Profile your application under realistic load first. Use docker stats and load testing tools to establish baseline resource consumption before setting limits.

Pitfall 7: Forgetting That JVM/Go/Python Runtimes Have Their Own Limits

Java applications often have -Xmx set for heap size. If your container memory limit is 512MB but the JVM heap is set to 1GB, the container will OOM on startup or under load. Container limits must account for heap + native memory + metaspace + thread stacks.

# Example: Java app with 256MB heap needs ~384MB total container memory
# Heap (256MB) + Metaspace (64MB) + Thread stacks (32MB) + Native (32MB) = ~384MB
docker run -d --memory=512m --name java-app \
  -e JAVA_OPTS="-Xmx256m -Xms256m -XX:MaxMetaspaceSize=64m" my-java-app:latest

Pitfall 8: Not Testing Swap Disablement on Databases

Disabling swap for databases is good in theory, but some databases (notably older versions of certain engines) assume swap space exists for emergency allocation paths. Test thoroughly before deploying --memory-swap=--memory to production database instances.

Pitfall 9: Setting Limits on docker run but Not in Orchestration Manifests

In Kubernetes, limits set via docker run are irrelevant—the orchestrator controls cgroups. Ensure your Kubernetes pod specs have proper resources.limits and resources.requests defined.

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: myapp
    resources:
      requests:
        memory: "256Mi"
        cpu: "500m"
      limits:
        memory: "512Mi"
        cpu: "1"

Pitfall 10: Applying Limits to Init/Sidecar Containers Incorrectly

Init containers and sidecars (like Envoy proxies) need their own limits. A common mistake is setting generous limits on the main app container but forgetting the sidecar, which then silently consumes resources. Profile sidecars separately.

Resource Limits in Kubernetes vs. Docker Standalone

If you're transitioning from standalone Docker to Kubernetes, note the mapping of concepts:

Conclusion

Docker resource limits are not optional in any environment beyond a single-container development setup. They are the foundation of multi-tenant safety, predictable performance, and efficient resource utilization. Start with memory limits on every production container, pair them with CPU constraints appropriate to your workload model (shares for priority, quotas for isolation), and explicitly control swap and PIDs. Monitor actual usage continuously, iterate on limits based on empirical data, and always test OOM behavior in staging before deploying to production. With these practices in place, you transform your container infrastructure from a fragile shared resource pool into a robust, predictable, and secure runtime environment.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles