Skip to content

fix(docker): drop Swarm-oriented deploy: keys so the stack deploys on NAS platforms - #108

Merged
tyler-rich merged 1 commit into
devfrom
claude/nas-deploy-resources-ukttwi
Jul 29, 2026
Merged

fix(docker): drop Swarm-oriented deploy: keys so the stack deploys on NAS platforms#108
tyler-rich merged 1 commit into
devfrom
claude/nas-deploy-resources-ukttwi

Conversation

@tyler-rich

@tyler-rich tyler-rich commented Jul 28, 2026

Copy link
Copy Markdown
Owner

A user reported that the deploy: resource limits prevent deployment on their NAS. Synology Container Manager and QNAP Container Station reject or mishandle the Swarm-oriented deploy: block, so docker/docker-compose.yml would not deploy there at all — even though Compose v2 honours it standalone.

The limits are a CIS-baseline hardening measure, so they were re-expressed rather than removed.

What was there

deploy: appeared on all three services, only ever as resources (no replicas, restart_policy, placement, update_config). A fourth copy sat in the README's paste-in stack.

Service Profile limits.cpus limits.memory reservations.memory
scrye (default) "2.0" 2G 256M
trivy-server trivy-server "1.0" 1G
docker-socket-proxy docker-env "0.5" 64M

What changed

Memory limits stay on by default, written with the portable mem_limit / mem_reservation keys that every Compose implementation accepts. Memory is the containment control that matters most here: it bounds the OOM blast radius, and the RAM-backed /tmp tmpfs is charged against it. No opt-in required.

CPU limits moved to a new opt-in overlay, docker/docker-compose.cpu-limits.yml, applied with a second -f:

docker compose -f docker/docker-compose.yml \
               -f docker/docker-compose.cpu-limits.yml up -d

They were moved rather than deleted: the caps are tuned, and the 0.5 on docker-socket-proxy is deliberate — that sidecar is the only container in the stack that mounts docker.sock, and its cap is the documented bound on a wedged or runaway proxy. CPU exhaustion degrades where memory exhaustion kills, which is what makes CPU the right half to make opt-in.

Two things the implementation turned up

The overlay can't use deploy:. The first draft did, on the reasoning that opting in implies a platform that supports it. docker compose config rejected the merged project:

services.scrye: can't set distinct values on 'mem_limit' and 'deploy.resources.limits.memory'

Compose normalizes mem_limit and deploy.resources.limits.memory into one field and validates that they agree, so an overlay contributing a deploy.resources.limits map with no memory in it conflicts with the base file's mem_limit. The overlay would not have applied at all. The portable cpus: key merges cleanly and is accepted by more implementations besides.

cpus: is the same limit, not a weaker one — verified rather than assumed. Compose enforces the identical normalization for CPU: a service setting both cpus: 2.0 and deploy.resources.limits.cpus: "3.0" is rejected with can't set distinct values on 'cpus' and 'deploy.resources.limits.cpus', and setting both to the same value normalizes to one limit. A key that were merely parsed and ignored could not participate in that check.

Verification

docker compose config was run on all four combinations — base alone and base + overlay, each with no profiles and with --profile trivy-server --profile docker-env. All four parse, and the merged config reports exactly the pre-change values:

  docker-socket-proxy    cpus=0.5   mem_limit=   64 MiB   mem_reservation=   0 MiB
  scrye                  cpus=2     mem_limit= 2048 MiB   mem_reservation= 256 MiB
  trivy-server           cpus=1     mem_limit= 1024 MiB   mem_reservation=   0 MiB

Only the Compose CLI is available in this environment (no daemon), so no container was started — what is verified is that the files parse and normalize to the original limits, not that a live container was inspected. docker compose up / /healthz is unverified here for that reason; CI's image job is the gate.

Five regression guards were added to backend/tests/test_compose_hardening.py: no deploy: key in either file, a mem_limit for every service, scrye's mem_reservation, a cpus entry in the overlay for every service the base file defines, and the overlay's use of the portable key. They were exercised against seven mutations of the real files (a deploy: block returning, the overlay deleted, mem_limit/mem_reservation dropped, a new uncapped service, the overlay reverting to deploy:, the socket-proxy cap loosened) — each fires on the regression it names, with the unmodified files clean. They are string-level like the rest of that module, deliberately: the backend declares no YAML parser.

The backend suite could not be run locally (this container has Python 3.11; the backend requires 3.14), so the new test bodies were executed standalone against the checked-in files. ruff and black are clean.

Docs

  • README gains a Resource limits (and NAS platforms) section: the memory table, the overlay invocation, the COMPOSE_FILE alternative, the warning that both -f flags must be repeated on every command for the stack, and a pointer for NAS users to their platform's own CPU controls. The paste-in stack, the security-model bullet, the sidecar commands, and the contents list are updated to match.
  • CHANGELOG.md under Unreleased, with the action-required note.
  • See docs/ARCHIVE.md § Deviations for changes made in this pass.

… NAS platforms

Synology Container Manager and QNAP Container Station reject or mishandle
`deploy:` keys, so docker-compose.yml would not deploy there at all, even though
Compose v2 honours the block standalone. All three services carried a
deploy.resources block (and a fourth copy sat in the README's paste-in stack).

Memory limits stay on by default, re-expressed with the portable mem_limit /
mem_reservation keys: scrye 2g + 256m reserved, trivy-server 1g,
docker-socket-proxy 64m. Memory is the containment control that matters most —
it bounds the OOM blast radius, and the RAM-backed /tmp tmpfs is charged
against it.

CPU limits move to a new opt-in overlay, docker/docker-compose.cpu-limits.yml,
rather than being deleted; the caps are a hardening measure and the 0.5 cap on
the socket proxy (the only container mounting docker.sock) is deliberate. The
overlay uses the portable `cpus:` key, not `deploy:` — Compose rejects a merged
project whose overlay contributes deploy.resources.limits against the base
file's mem_limit ("can't set distinct values on 'mem_limit' and
'deploy.resources.limits.memory'"), so a deploy-based overlay would not apply.

`cpus:` is the same limit, not a weaker one: Compose refuses a project that sets
`cpus` and `deploy.resources.limits.cpus` to different values, which a
parsed-and-ignored key could not do.

Verified with `docker compose config` across base-alone and base+overlay, each
with and without the sidecar profiles; the merged config reproduces the
pre-change limits exactly. Adds five regression guards to
test_compose_hardening.py covering both halves of the split.

See docs/ARCHIVE.md § Deviations for changes made in this pass.
@tyler-rich
tyler-rich merged commit 790a946 into dev Jul 29, 2026
4 checks passed
@tyler-rich
tyler-rich deleted the claude/nas-deploy-resources-ukttwi branch July 29, 2026 02:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant