Gotchas worth remembering
August 14, 2026 · View on GitHub
Things that cost real debugging time here, kept so they cost it once. This is engineering knowledge about this codebase — not a style guide, and not a list of everything that could go wrong. If something bit you and the cause was non-obvious, it belongs here.
Testing has its own set: see Testing, which covers mutation-testing every guard and the fixture traps that come with a real Docker daemon.
API and data shapes
decodeJSONusesDisallowUnknownFields()(internal/api/respond.go), so a request body must contain only struct-declared fields. Read-only fields such ashasPasswordhave to be stripped client-side — seesmtpPayload/ldapPayloadfor the pattern.- Image and object refs contain
:and/, so pass them as query params, never as chi path segments: chi will not decode%3A. - Go
nilslices marshal to JSONnull, not[]. The SPA then crashes onx.length/.map. Initialise API-returned slices (make/[]T{}) so empty means[], and still guard with?? []on the TypeScript side. This bit us viaResourceOverview.Containerswhen no containers were running. ORDER BYcannot be a bound parameter. Anything sortable from the UI must map its sort key through a fixed whitelist (seeAlertQuery.orderBy), or you are building SQL out of a query string.time.Durationis int64 nanoseconds, sodays * 24 * time.Houroverflows above ~106,751 days. Asking for a 200,000-day token expiry produced a date in 1989 — a credential dead the moment it was issued — and larger values wrapped to arbitrary dates. Validate the number of days against a ceiling before it ever becomes aDuration.- SQLite
LIKEhas no default escape character. Escaping%and_only works together with an explicitESCAPE '\'clause — without it the backslashes are matched literally and the filter silently searches for the wrong string.
Docker behaviour
docker statsCPU is per-core: 100% is one core. A container busy on four cores reads ~400%, so any fixed threshold or dashboard built on it is wrong on a multi-core host unless divided by the core count. The engine exposes the count asCPUCoresfor exactly this.docker compose up -dbuilds only a missing image. Without--build, the second deploy after editing a Dockerfile or its context silently keeps running the old image and reportsContainer Running.- A build context is uploaded by Docker itself; a bind mount is not. That is
why remote deploys need bind seeding but nothing for
build:. Conflating the two put a phantom item on the roadmap for months. - Alert-rule cooldown:
docker stopemits several events (kill → die → stop), so a 1s cooldown can double-fire. Defaults are 60s. - Container network stats are cumulative counters that reset on recreate.
Store the raw counters and derive rates at read time; a delta computed across a
reset is negative, and rendering it produces either a phantom spike or a
negative rate.
applyNetRatesskips the delta when the new counter is lower than the previous one, so a reset reads as a gap at zero. /containers/{id}/statsis keyed by interface, not by network. The API'sendpoint_idfield exists but the daemon fills it in on Windows only, which is whydocker statsitself shows a single aggregateNET I/Ocolumn. Sum across interfaces and say so; anything per-network needs MAC/namespace inspection via netlink (Linux-only, hostile to remote hosts).- On older engines
/containers/jsonlagsinspect. After stopping a stack, Engine 24 reported a container asrunningfor ~250 ms whileinspectalready saidexited. The app polls, so it is only a problem for tests that assume instantaneous consistency. COMPOSE_PROFILEShas three sources, and they don't merge the way you'd guess. Verified empirically against a realdocker compose up(Compose v5.4.0): when--profileis passed on the command line at all, Compose uses only the--profileflags and ignoresCOMPOSE_PROFILESentirely — no union.COMPOSE_PROFILES(from the shell/process environment, or the project's own.envfile, whichcomposeauto-loads from its working directory) only takes effect when no--profileflag is given at all — exactly the "no profiles selected" deploy case. And between the twoCOMPOSE_PROFILESsources, process/shell env wins over.env: setting it to the empty string in the subprocess's env (last entry wins inexec.Cmd.Env) suppresses a project's own.envsetting too, it isn't only a shell-level override.ComposeUpFilesrelies on exactly this: it always setsCOMPOSE_PROFILES=on the subprocess so its explicit--profilelist is the sole source, closing the gap where an operator's env or a project's.envcould silently activate more than what gets persisted as "last deployed profiles".
HTTP timeouts and streaming
- A handler runs from the moment the HEADERS arrive, not the body. With no
ReadTimeouta client can send headers and dribble the body indefinitely, holding a handler open — and any check the handler makes before reading the body is made against a state the client can then outlive. That is why the authorisation for pairing a second factor lives in theINSERTrather than in a check above it. - Re-arming a read deadline after the body ends cancels the request. When a
handler finishes reading,
net/httpclears the deadline itself and starts a background read to notice the client leaving (startBackgroundRead); any failure of that read is taken as "client gone" and cancels the request context. WithContent-Length, the final read returns bytes andio.EOFtogether, so a naive "extend on n > 0" re-arms it at exactly the wrong moment. Every longdocker builddied two minutes after its context finished uploading. - Clearing that deadline on any error is the opposite mistake. If the handler
returns without draining the body,
net/httpdrains the remainder inline, insidechunkWriter.writeHeader, before the response headers go out — unbounded if the deadline is gone. A client that declares a body, sends two bytes and goes quiet without closing pins a goroutine and an fd for the life of the process, and never receives the timeout the handler wrote. Clear onio.EOFonly. Connection: closeskips that drain, so a reproduction that closes the connection will not show the bug. Test stalled-body behaviour over keep-alive.- Hijacked connections are off the clock:
net/httpclears deadlines on hijack, so WebSocket streams are unaffected byReadTimeout— but an idle keep-alive connection now closes after it, becauseServer.idleTimeout()falls back toReadTimeoutwhenIdleTimeoutis unset.
Authorization
- A sequential id is not an access control. Any endpoint or tool that takes an
integer id (project, alert, container-metrics series) must resolve the host
that record belongs to and authorize against it — checking the section alone
leaves the id space walkable, since ids are consecutive. Three MCP tools shipped
checking their section against host 0 while acting on someone else's record.
The tell is machine-detectable: an integer id argument with no
host_id. - A missing record and an out-of-reach one must answer identically. Otherwise the error itself confirms the record exists, and the endpoint becomes a way to map what runs on hosts you cannot see.
- Over-tightening is a bug too, not a safe default. Host reach is derived from
grants across all sections, so also demanding the
hostssection for a host-scoped alert route hid alerts from users who legitimately reached that host. Guards need a test for what they must still allow.
Deployment
- systemd
ProtectHome=truebreaksdocker composeplugin discovery. The shipped unit runs as a dedicated user withProtectHome=true, which makes that user's home inaccessible (EACCES, notENOENT). The docker CLI bases plugin discovery on its config dir (~/.docker) and treatsEACCESthere as fatal, sodocker composereads as an unknown command:ComposeAvailable()returns false and Projects' Deploy/Down are disabled. It works in a shell and not under the service, which is what makes it confusing. Fixed byEnvironment=DOCKER_CONFIG=/var/lib/dockercmd/.dockerindeploy/dockercmd.service; theinstall-*scripts cover each OS.
Secrets
net/httptransport errors embed the full request URL.*url.Error's message includes it, so logging or storingerr.Error()from a failed webhook call writes any token in that URL wherever the error goes. Report the wrapped cause instead (redactURLininternal/monitor/webhook.go).- A backup archive is equivalent to the plaintext of every stored secret, because the at-rest encryption key is a row in the same database. Hence 0600 and the optional passphrase.
Destructive operations
- NEVER call a host-global prune (
Prune{Networks,Images,Volumes}) from an integration test. The integration tests run against the developer's real local daemon, and a global prune removes EVERY unused network / image / volume — not just the test's. This is exactly how a network lifecycle test once wiped a developer's networks. Test create / connect / remove on test-owned resources only, and exercise prune by hand on a throwaway host. - A plain file write follows a symlink at the destination. Anywhere the app writes next to a user-controlled path, write to a temp file and rename — rename replaces the link instead of writing through it.
Frontend build
- A local
make uirebuild ofweb/distis not guaranteed to match CI's. Sameweb/src, sameweb/package-lock.json, same Node/npm version (checked by installing CI's exactnode -vvia nvm) — and the committed CSS still came out smaller locally than CI's own rebuild: CI's included 10 extra Tailwind v4 utility classes (transform,transition,underline,blur,isolate,list-item,invert,ease-in/ease-in-out/ease-out) that a local rebuild pruned. Root cause not fully pinned down — suspected is Tailwind v4's automatic content-candidate scanning behaving differently based on something environment-specific beyond Node/npm version — but the practical fix is procedural: when aweb/distgate failure looks surprising, trust CI's own rebuild over a local one. Download it (gh run download <run-id> -n <artifact>after a temporaryactions/upload-artifactstep onweb/dist, or just let a pushed branch's own CI run be the thing you commit from) rather than assuming your local build is right and re-pushing it repeatedly. ci.yml's push trigger only coveredmain+release/v*; theweb/distgate had never actually run against several already-merged commits onrelease/v1.6.1. Every individual PR'spull_request-triggered check validated that PR's own diff correctly, but the merge commits combining them (a manual conflict-resolution merge, a forwarded Dependabot merge) were never independently re-checked until push-triggered CI was extended torelease/v*— which is exactly when the above discrepancy surfaced. A merge commit needs its own CI run, not just trust in its parents' green checks.