Resource limits
Every container has deploy.resources.limits (hard cap) and deploy.resources.reservations (guaranteed minimum) set from env vars. The defaults target a 4-vCPU / 8 GB VPS, the cheapest production-viable tier on Hetzner / OVH / DigitalOcean. Bigger host? Bump the knobs in compose/.env.
Why limits matter
Section titled “Why limits matter”A misbehaving worker consuming all memory shouldn’t take Postgres down with it. Limits draw the boundaries; reservations guarantee a service can boot even when the host is busy. Without limits, one runaway process kills every other service on the host.
Design choices
Section titled “Design choices”- Limits plus reservations on every service. Failures stay isolated; the host stays responsive.
- Knobs in
compose/.env, not hardcoded. Right-sizing is a config change, not a code change. - Defaults target 4 vCPU / 8 GB. Cheapest production-viable VPS tier; everything else scales up from there.
- Reservations less than limits (about 1:4 ratio). Reserves the minimum to boot; allows bursts up to the limit.
- Bun and Node services share roughly the same shape. Avoids per-runtime tuning until measurements say otherwise.
The shape of a knob
Section titled “The shape of a knob”Each service has four env vars: limits CPU/memory, reservations CPU/memory. Example for Postgres:
POSTGRES_LIMITS_CPUS=1.0POSTGRES_LIMITS_MEMORY=512MPOSTGRES_RESERVATIONS_CPUS=0.25POSTGRES_RESERVATIONS_MEMORY=128MSame pattern for VALKEY_, TRAEFIK_, API_DEV_, UI_DEV_, API_, UI_, and the optional overlays.
Default sizing (4 vCPU / 8 GB)
Section titled “Default sizing (4 vCPU / 8 GB)”| Service | CPU limit | Memory limit | Notes |
|---|---|---|---|
| Postgres | 1.0 | 512 MB | Heaviest of the data plane; bump first when OOMing. |
| Valkey | 0.5 | 256 MB | Sufficient for cache + queues at small scale. |
| Traefik | 0.5 | 256 MB | Reverse proxy; low steady-state usage. |
| API (dev/prod) | 1.5 | 1 GB | Bun + Drizzle; generous to cover cold-cache bursts. |
| UI (dev) | 1.5 | 1 GB | Vite dev server memory during HMR. |
| UI (prod) | 0.25 | 128 MB | Static file serving is cheap. |
Limits sum to more than the host on purpose: containers don’t peak simultaneously. Reservations are small so the host stays responsive under load.
Setting limits
Section titled “Setting limits”Each service has four env vars in compose/.env:
POSTGRES_LIMITS_CPUS=1.0POSTGRES_LIMITS_MEMORY=512MPOSTGRES_RESERVATIONS_CPUS=0.25POSTGRES_RESERVATIONS_MEMORY=128MSame pattern for VALKEY_, TRAEFIK_, API_DEV_, UI_DEV_, API_, UI_, and optional overlays.
Sizing for bigger hosts
Section titled “Sizing for bigger hosts”- 4 vCPU / 8 GB host. Postgres at default. One of each worker / API replica.
- 8 vCPU / 16 GB host.
POSTGRES_LIMITS_MEMORY=2G,POSTGRES_LIMITS_CPUS=2.0. Consider horizontal API replication. - 16+ vCPU / 32+ GB host. Postgres deserves its own host. Multiple API + worker replicas; revisit Valkey Cluster.
Above the 16 vCPU mark, the single-host model itself becomes the bottleneck; that’s the right time to look at the planned Kubernetes path.
Diagnosing OOM kills
Section titled “Diagnosing OOM kills”Check if a container was killed for exceeding memory:
docker inspect <container-name> --format '{{.State.OOMKilled}}'docker inspect <container-name> --format '{{.State.ExitCode}}'OOMKilled=true means the memory limit was exceeded. Exit code 137 is SIGKILL by OOM.
docker statsShows live CPU and memory usage. Watch for services pegged >80% of their limit; they are candidates for a bump.
If a service OOMs repeatedly after bumping the limit, the code is likely leaking memory. Fix the leak rather than keep raising the ceiling.
Source
Section titled “Source”compose/.env.example; every knob, commented. compose/docker-compose.yml; where they’re wired into deploy.resources. docs/resource-limits.md; extended sizing guide.
Related
Section titled “Related”- Profiles & overlays; overlays add their own services with their own limits.
- Observability; Grafana dashboards show resource pressure over time.