Codeman running as a container updates itself from App Settings → Updates, the same place and the same button as a bare-host install. This document explains how that works, what it deliberately refuses to do, and how to recover when it stops.
The bare-host updater is documented in
architecture-invariants.md#self-update;
this file covers only what the container changes.
| Change in the release | Applied by |
|---|---|
| Application code | The in-app updater |
docker/server.Dockerfile |
docker/Start-Codeman.sh on the host |
docker/docker-compose.yaml |
docker/Start-Codeman.sh on the host |
New key in docker/.env.example |
Add it to docker/.env, then Start-Codeman.sh |
The in-app updater detects all three of the bottom rows itself and refuses with a message naming what changed, so you never have to work out which case you are in.
The bare-host updater does git checkout <tag> && npm install && npm run build,
then asks systemd or launchd to restart the service. Two of those assumptions are
false in a container:
- There is no init system. A container's supervisor is the Docker daemon, which acts on the container, not on processes inside it.
- The image is immutable. A
git pullinto the image's baked/opt/codemanwould land in the container's writable layer, survivedocker restart, and be silently discarded by the nextdocker compose up.
Both are solved by configuration rather than by a second updater:
- The checkout is a host bind mount.
docker-compose.yamlmounts the repo (the same directory used as the build context) over/opt/codeman, so the updater'sgit checkoutwrites to the host filesystem and survives the container being recreated. - The restart is the server exiting.
restart: unless-stoppedrelaunches the container whenever its main process ends, including on a clean exit — so the updater's final step is to signal the server, and Docker starts it again on the freshly builtdist/.
Everything else — the release-tag channel, the auto-stash, the atomic
update-status.json the browser polls across the connection drop, the boot-time
reconcile that flips restarting to completed — is the existing machinery,
unchanged. The container path is a new SupervisorKind, not a new updater.
| Piece | Role |
|---|---|
Repo bind mount at /opt/codeman |
Makes the pull persistent. Without it, self-update is unavailable. |
codeman-node-modules, codeman-dist volumes |
Container-owned build artefacts, layered over the bind mount. |
CODEMAN_IN_CONTAINER=1 |
Tells detectSupervisor() to restart by exiting. |
restart: unless-stopped |
Turns that exit into a restart. Verified before every update. |
CODEMAN_RESTART_BY_EXIT=1 |
The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. |
| Toolchain + devDependencies in the image | Lets npm install and npm run build run inside the container. |
docker-env-applied.json |
Fingerprint baseline, written by Start-Codeman.sh on every start. |
node_modules and dist are mounted as named volumes on top of the repo bind
mount. Without that, an update's npm install would write into the host checkout,
leaving container-compiled native modules (node-pty builds from source here) in a
directory that may also be used to run Codeman natively, and leaving git status
permanently noisy.
Docker seeds an empty named volume from the image, so the first start inherits the
image's already-built node_modules and dist and pays no bootstrap cost.
docker compose down -v is the supported reset: the next start re-seeds them.
npm run build is tsc plus esbuild, both devDependencies, so the image no
longer runs npm prune --omit=dev. And npm install may rebuild node-pty, which
ships no Linux prebuild, so python3, make and g++ are installed as well.
This is the real cost of in-place updates: a noticeably larger image than a
runtime-only one. It buys an update that takes about a minute instead of a full
image rebuild, and it is why NODE_ENV=production is paired with an explicit
npm install --include=dev in the updater.
An in-place update applies code only. A restarted container reuses its existing
image and configuration, so a release that changes the environment cannot take
effect that way — and would half-apply: new code against an old environment. The
updater therefore checks the target release's own files, read straight out of
git with git show <tag>:<path> before anything is checked out.
Compared by sha256 against the fingerprint Start-Codeman.sh recorded when the
running container was built.
Same mechanism. A restart cannot pick up a new mount, port or environment variable; only recreating the container can.
The check that matters most, because Compose will not tell you. An unset
${VAR} interpolates to the empty string; Compose prints a warning to a terminal
nobody is watching and starts anyway. A new required setting therefore arrives as
a silently blank environment variable and misbehaves later, far from the cause.
The updater names the missing keys instead.
Commented-out lines in .env.example are deliberately not keys — that is how
the file marks optional overrides such as # PUID=1000, and counting them would
block updates on settings you are meant to leave alone.
Before signalling the server, the updater asks the Docker daemon for its own
container's restart policy. If it is no, the update is refused: applying it
would take Codeman down and leave no UI to recover from.
If the policy cannot be read at all (no Docker socket mounted) the update is
still allowed, but the final step changes: the server exits only when the
Compose file declared CODEMAN_RESTART_BY_EXIT=1 (the shipped one does, because
it is the file that sets restart: unless-stopped) or the daemon confirmed an
auto-restart policy. Otherwise the build completes and the panel asks you to
restart the container by hand. A container started by plain docker run with no
restart policy therefore gets a staged update, never an outage.
Every unknown fails open:
- A missing fingerprint baseline (a container started before this feature existed) is not treated as a change, or those installs could never update at all.
- An unreadable
.env, an unreachable Docker socket, or a target tag whose files cannot be read all yield "no blocker" rather than a refusal.
The one place an unknown does NOT fail open is the kill itself: with neither the Compose declaration nor a daemon answer, the updater stages the build and asks for a manual restart rather than exiting a server nothing may bring back.
The gate catches a specific, detectable class of mistake; it is not a last line of
defence. It is also re-evaluated server-side on POST /api/system/update, so
hiding the button in the UI is a courtesy rather than the control.
The gate is derived from the diff, so it cannot see a release that needs a newer environment without changing any of those files — for example, code that depends on newer agent-CLI behaviour.
That is why the four global CLIs in server.Dockerfile are pinned. Unpinned,
the versions a user ends up with are a function of when their image was built
rather than of any commit, and in-app updates make rebuilds rarer, which makes
that drift worse over time. Pinned, "this release needs a newer CLI" becomes a
Dockerfile change, which check 1 already detects. Bump them deliberately, as part
of a release.
The complementary merge-side guard is test/docker-compose-env-parity.test.ts,
which fails CI when a variable is added to docker-compose.yaml without an entry
in .env.example, or the reverse.
- Check —
GET /api/system/update/checkfinds the latest release tag, fetches that one ref so the gate can read the target's files, and returns any blockers. - Start —
POST /api/system/updatere-evaluates the gate, writesqueuedtoupdate-status.json, stagesself-update.shoutside the repo and runs it. - Apply — stash if dirty, fetch the tag, check it out,
npm install --include=dev,npm run build. A failure at any step rolls back to the previous commit, rebuilds it and reportsfailed; the server is never restarted into a broken build. - Restart — write the terminal
restartingmarker, then signal the server. The container exits and Docker restarts it. - Reconcile — the rebooted server compares its own version against the target
and flips the status to
completedorfailed. The browser, still polling, picks that up.
Step 4 kills the updater script along with the container — unlike the systemd path, it does not outlive the restart. That is safe only because the terminal marker is written first, which is why nothing may be appended after the kill.
"This install can't update itself (unknown)" — the repo bind mount is missing,
so the container is running the baked image copy. Check CODEMAN_REPO_PATH and
confirm the mounted directory really contains .git.
The update fails immediately with a git ownership or permission error — the
mounted checkout belongs to a different user than the one Codeman runs as
(PUID), so git refuses it as "dubious ownership". Start-Codeman.sh warns
about this at start; fix it by chowning the checkout to the same account that
owns CODEMAN_APPDATA_PATH.
A rebuild is reported as required every time — the fingerprint baseline does
not match the checkout. Start-Codeman.sh writes it on every start, so start
through that script rather than a bare docker compose up after either file
changes.
Codeman does not come back after an update — the build succeeded, since the
updater gates the restart on it, so read the container logs with docker compose logs codeman. To roll back, check out the previous tag in the host checkout and
run docker/Start-Codeman.sh.
The update failed during npm install — most likely a native rebuild with no
toolchain, meaning the image predates the toolchain being added. Rebuild once from
the host and the in-app path works from then on.
Resetting the build artefacts — docker compose down -v, then
Start-Codeman.sh. This discards the named volumes and re-seeds them from a fresh
image.
Set CODEMAN_DISABLE_SELF_UPDATE=1 in docker/.env and pass it through in the
compose file's environment: block. The Updates panel then reports that in-app
updates are disabled, and the host-side script is the only way to update.