Intro
Docker Compose makes multi-service development and operations straightforward, but it’s easy to ship a slow stack if you accept every default. The most frequent bottlenecks are:
- File I/O: Bind mounts on macOS and Windows can be 5–10× slower than Linux native volumes. Databases suffer most.
- CPU and memory: Undersized limits or noisy neighbors cause throttling and swapping.
- Networking: Extra hops and NAT add latency; DNS lookups and chatty health checks add load.
- Logging: Unbounded logs grow and steal I/O from real work.
- Containers that write heavily to disk: Caches and temp files degrade throughput if not moved to
tmpfs.
This guide shows a safe, staged way to find bottlenecks, right-size resources, and tune for latency and throughput. You’ll see concrete Compose snippets for NGINX, PostgreSQL, and Redis, plus quick checks you can run locally before changing anything in shared environments.
Workflow Overview
Use a short, repeatable workflow so improvements are obvious and safe.
1. Define goals
- Latency SLOs: p50, p95, p99 for your key endpoints and commands.
- Throughput targets: requests per second, TPS, ops/sec.
- Resource caps: max CPU and memory per service.
2. Baseline quickly
- Start the stack and wait until healthy.
- Record a 1–2 minute snapshot of CPU, memory, and I/O with
docker stats. - Measure HTTP latency:
curl -s -o /dev/null -w "time_connect=%{time_connect} time_starttransfer=%{time_starttransfer} time_total=%{time_total}\n" http://localhost:8080/
- Measure simple DB and cache operations: run a trivial
SELECTin PostgreSQL and aGET/SETin Redis.
3. Identify constraints
- File I/O hot spots: databases or services with high write rates on bind mounts.
- CPU saturation: containers near 100% of a single core or often throttled.
- Memory pressure: OOM kills or heavy reclaim; database shared buffers too small.
- Network issues: slow connect times, DNS delays, or high SYN backlog.
- Logging: high log throughput or large log files.
4. Apply one change at a time
- Prefer named volumes for databases. Use bind mounts with
cached/delegatedonly for source code or configs that benefit from live editing. - Add
tmpfsfor hot caches and temp dirs. - Set conservative CPU and memory limits, then raise gradually.
- Cap and compact logs with the
localdriver. - Tweak health-check intervals to cut noise without reducing safety.
5. Re-test and compare
Repeat the same curl and workload checks. Keep the time window, concurrency, and data set identical to your baseline. If numbers improve and error rates remain flat, keep the change. If not, revert.
Local Pilot Plan
Start with a narrow pilot that is easy to inspect locally. The example below uses NGINX, PostgreSQL, and Redis. It focuses on file I/O, resource caps, logging, and health checks. Replace the web upstream and database credentials to fit your stack.
Baseline compose.yml
version: "3.9"
services:
web:
image: nginx:1.25-alpine
ports:
- "8080:80"
volumes:
# Config as read-only bind mount for convenience
- ./nginx.conf:/etc/nginx/nginx.conf:ro
# Cache to a named volume to avoid bind-mount slowness
- nginx_cache:/var/cache/nginx
healthcheck:
test: ["CMD", "wget", "-qO-" , "http://localhost" ]
interval: 10s
timeout: 2s
retries: 3
# Keep logs bounded and lightweight
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
# Conservative starting limits (supported by docker compose on Linux)
cpus: "1.5"
mem_limit: "512m"
# Increase file descriptors for high-conn tests
ulimits:
nofile: 65536
# Reduce connection queue drops under load
sysctls:
net.core.somaxconn: "65535"
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: example
POSTGRES_DB: app
POSTGRES_USER: app
# Use a named volume for durable storage (faster than bind mounts on macOS/Windows)
volumes:
- pgdata:/var/lib/postgresql/data
# Increase shared memory for Postgres to reduce IPC contention
shm_size: "512m"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app" ]
interval: 10s
timeout: 3s
retries: 5
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
cpus: "2"
mem_limit: "1g"
cache:
image: redis:7-alpine
command:
- "redis-server"
- "--appendonly"
- "no"
- "--maxmemory"
- "256mb"
- "--maxmemory-policy"
- "allkeys-lru"
healthcheck:
test: ["CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 2s
retries: 5
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
cpus: "1"
mem_limit: "384m"
# Faster accept queue under bursty load
sysctls:
net.core.somaxconn: "65535"
volumes:
pgdata: {}
nginx_cache: {}
Notes
- Use named volumes for Postgres and NGINX cache. This avoids slow bind mounts on macOS and Windows and keeps Linux fast too.
- Keep config as read-only bind mounts for convenience, not for high-throughput I/O.
- The
cpusandmem_limitsettings are conservative starting points. Adjust to match your host.
Optional bind mount tuning for macOS/Windows
When you must bind mount a write-heavy path (not recommended for databases), add consistency flags:
services:
app:
volumes:
- ./src:/app/src:cached # read-mostly
- ./tmp:/app/tmp:delegated # write-mostly
Optional Linux-only host networking for bench runs
For latency experiments on Linux, you can avoid a NAT hop by using host networking for the HTTP entrypoint:
services:
web:
network_mode: host # Linux only
ports: [] # not used with host networking
Do not use host networking on macOS or Windows, and avoid it for general development unless you specifically need it for benchmarking.
Add tmpfs for hot write paths
Reduce disk I/O by moving temporary or cache directories to RAM:
services:
web:
tmpfs:
- /var/cache/nginx
cache:
tmpfs:
- /tmp
Quick latency and throughput checks
Run after the stack is healthy to capture a baseline:
- HTTP latency (p50 across a small sample):
curl -s -o /dev/null -w "time_connect=%{time_connect} time_starttransfer=%{time_starttransfer} time_total=%{time_total}\n" http://localhost:8080/
- HTTP throughput (if you have
hey):
hey -z 30s -c 50 http://localhost:8080/
- PostgreSQL quick check:
psql "postgresql://app:example@localhost:5432/app" -c "select 1;"
If you have pgbench:
pgbench -S -T 30 -c 16 -j 4 "postgresql://app:example@localhost:5432/app"
- Redis quick check:
redis-cli -h localhost -p 6379 ping
If you have redis-benchmark:
redis-benchmark -t get,set -n 100000 -q
Repeat the same commands after each change. Keep duration, concurrency, and dataset constant so results are comparable.
Practical Tuning Plays
- Remove slow bind mounts where they hurt most
- Use named volumes for databases and service caches.
- Keep source code and configs as bind mounts if you need live editing.
- On macOS/Windows, prefer
:cachedfor read-mostly mounts and:delegatedfor write-mostly mounts.
- Right-size CPU and memory
- Start with conservative
cpusandmem_limitand raise until throttling disappears and latency stops improving. - Consider pinning CPUs for noisy-neighbor isolation:
services:
db:
cpuset: "1-2"
- Control logging overhead
- Use the
localdriver and cap size and rotation. Avoidjson-filewith unbounded growth.
- Tune health checks
- Health checks protect availability but can create load. Use a simple, fast probe, a 10–30s interval, and a small timeout.
- Move hot temp data to
tmpfs
- Good for caches and ephemeral files that are safe to lose.
- Raise kernel queues where safe
- High connection rates benefit from larger
somaxconn.
- Measure, do not guess
- Re-run the same tests for before/after comparisons. Keep notes of changes and results.
Safe Optimization via Overrides and Profiles
Keep your base compose.yml simple. Put tuning in an override so you can opt in:
# compose.override.yml
services:
web:
tmpfs:
- /var/cache/nginx
environment:
NGINX_ENV: bench
db:
mem_limit: "2g"
cache:
command:
- "redis-server"
- "--appendonly"
- "no"
- "--save"
- ""
- "--maxmemory"
- "512mb"
- "--maxmemory-policy"
- "allkeys-lru"
Or use profiles for benchmark-only settings:
services:
web:
profiles: ["bench" ]
network_mode: host # Linux only
ports: []
Enable with:
COMPOSE_PROFILES=bench docker compose up -d
Troubleshooting Checklist
- Latency spikes after a few minutes: check logs for rotation stalls, increase
tmpfsfor caches, confirm no OOM kills. - Postgres slow writes: ensure data is on a named volume, increase
shm_size, keep WAL on the same named volume. - Redis timeouts under burst: raise
somaxconnandnofile, consider host networking on Linux for tests, verifymaxmemorypolicy. - CPU throttling: raise
cpusor reduce concurrency; verifycpusetisolation. - macOS/Windows file I/O: replace bind mounts with named volumes for hot paths; apply
:cachedor:delegatedonly when you must bind mount.
Conclusion
Keep the loop tight: define goals, baseline, change one thing, remeasure, and record results. Start with the safe defaults above, prefer named volumes for hot I/O, bound your logs, and use tmpfs for ephemeral writes. Once a local pilot shows clear gains, capture them in overrides or profiles so the team can opt in consistently. Recheck your numbers as images, host OS versions, and workloads change so your Compose stack stays fast and predictable.