Intro
Compose makes multi-service development and ops straightforward, but it is 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 much 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 a lot 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 will 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.
- 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.
- 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 SELECT in PostgreSQL and a GET/SET in Redis.
- 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.
- Apply one change at a time
- Prefer named volumes for databases. Use bind mounts with cached/delegated only for source code or configs that benefit from live editing.
- Add tmpfs for hot caches and temp dirs.
- Set conservative CPU and memory limits, then raise gradually.
- Cap and compact logs with the local driver.
- Tweak health check intervals to cut noise without reducing safety.
- 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 cpus and mem_limit settings 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 :cached for read-mostly mounts and :delegated for write-mostly mounts.
- Right-size CPU and memory
- Start with conservative cpus and mem_limit and 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 local driver and cap size and rotation. Avoid json-file with unbounded growth.
- Tune health checks
- Health checks protect availability but can create load. Use a simple, fast probe, a 10-30s interval, and 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 tmpfs for 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 somaxconn and nofile, consider host networking on Linux for tests, verify maxmemory policy.
- CPU throttling: raise cpus or reduce concurrency; verify cpuset isolation.
- macOS/Windows file I/O: replace bind mounts with named volumes for hot paths; apply :cached or :delegated only 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.