Architecture
Trace how a schedule tick or API call becomes an update cycle, how each container is checked and swapped, and how the image runs the updater as PUID and PGID instead of root.
Overview
Outpost is a TypeScript monorepo that builds into one image. The packages keep Docker, registry, and update logic separate, and apps/server wires them together and owns the dashboard, the API, and notifications:
| Workspace | Responsibility |
|---|---|
apps/server | Reads the environment, runs the cron schedule, keeps the check history, stores the account, access tokens, and notification destinations in SQLite, sends notifications, and serves the web dashboard and HTTP API. |
packages/updater | Selection, dependency order, the update cycle, and recreation with rollback. |
packages/registry | Image reference parsing, registry host names for sign-ins, and digest lookups. |
packages/docker-engine | Typed Docker Engine API client over the unix socket or plain TCP. |
packages/docker | s6-overlay service definitions and the image's root filesystem. |
apps/docs | This documentation site. |
Update Cycle
- Trigger —
Runner.triggerinapps/server/src/scheduler.tsstarts a cycle unless one is already running, so the schedule, the API, and the dashboard can never overlap. It records what started each cycle (schedule,startup,api, ordashboard), andPOST /api/v1/checkscan pass a container ID to limit the cycle to that container. - Select —
selectContainersinpackages/updater/src/select.tsapplies the label mode to every container, Outpost's own included, and excludes both sides of a shared network namespace and containers configured for automatic removal. Without its own ID, which comes from cgroup or mount metadata with a Docker-style hostname only as a fallback, it also skips every container whose image is namedoutpost. - Order —
sortByDependencieswalks thecom.docker.compose.depends_onlabel depth-first, scoped to each Compose project.runUpdateCyclethen moves Outpost's own container to the end, because updating it restarts Outpost. - Check —
checkContainerinpackages/updater/src/run.tsinspects the container and skips pinned images, shared network namespaces, and containers configured for automatic removal. It skips an image that Docker recorded as built on this host, then asks the registry for the remote digest. An image Docker can't place, on Docker 28 or older with the containerd image store, is skipped as locally built when the registry doesn't have it or won't say without credentials. Otherwise it compares digests, where on Docker's containerd image store a matching image ID counts as up to date, falls back to pull-and-compare when the registry can't be reached, and hands off to recreation, or for Outpost's own container toupdateSelf, described in Self-Update. - Report —
notifyUpdateReportinapps/server/src/lib/notifier.tsturns the report into at most one notification per event, and the scheduler adds the report to the history, updates the running totals for the API, and tells the event stream. Failures that stop the whole cycle become one failed result, so they are notified and retained too.
Dashboard and History
createApp in apps/server/src/app.ts sets the 64 KB JSON body limit, the API rate limit of 120 requests per minute, and the trusted proxy check. It then registers one route module per area from apps/server/src/routes:
| Module | Serves |
|---|---|
web.ts | The dashboard at /admin/, with long caching for /admin/assets, a page fallback for every other /admin path, and the redirect from /. |
auth.ts | First-run setup, password sign-in with its code step, passkey sign-in, sign-out, GET /api/v1/auth/me, and DELETE /api/v1/auth/removed-secrets. |
security.ts | The Security page: password and username changes, the authenticator app, recovery codes, and passkeys. |
tokens.ts | Listing, creating, editing, and revoking access tokens. |
notifications.ts | Listing, adding, editing, testing, and removing notification destinations, and listing the events they can choose. |
dashboard.ts | The overview, containers, history, settings, status, checks, and event stream under /api/v1. |
watchtower.ts | POST /v1/update, GET /v1/status, and GET /metrics. |
The dashboard itself is a React app in apps/server/src/web, which Vite builds into apps/server/build/admin. Running npm run mock in apps/server serves it on port 5176 against an in-memory mock API.
- Access —
lib/access.tschecks a bearer token first and the session cookie second, and enforces each route's scope. Session changes also need theX-CSRF-Tokenheader, and the account, security, token, notification, and registry routes refuse tokens. - State —
lib/state.tskeeps the account, sessions, sign-in challenges, the authenticator app, recovery codes, passkeys, access tokens, notification destinations, and private registry sign-ins in<OUTPOST_DATA_DIRECTORY>/outpost.sqlite3throughnode:sqlite. Anaccount.jsonfrom an earlier build is moved into the database once. - Secrets — The password is a scrypt hash with a random salt (
N=32768,r=8,p=1), and tokens, sessions, and recovery codes are SHA-256 hashes. The authenticator secret, each destination's secret fields, and each registry sign-in's password are sealed with AES-256-GCM using the encryption key inkeys/installation.key, which is checked against the database at startup. - Lost key — When the key file is missing,
lib/state.tsmakes a new key, turns off the authenticator apps whose secrets the old key sealed, deletes their recovery codes, and clears unfinished setups. It also drops every destination's sealed secrets and lists those fields inmissing_secrets, so the notifier skips the destination until they're entered again. Registry sign-ins keep their host and username but lose their password until it's entered again. - Removed secrets — Each account that lost its authenticator app, each destination that lost secrets, and each registry sign-in that lost its password gets a row in
removed_secrets, whichGET /api/v1/auth/mereturns asremovedSecrets. A row goes away when its secret is entered again, or whenDELETE /api/v1/auth/removed-secretsclears them all. - Setup code — While no account exists, the state store creates a setup code at startup and keeps only its SHA-256 digest and expiry in memory. The code itself goes only to the container log.
- Attempt limits —
lib/attempts.tscounts failures per client address, 10 per 15 minutes, in memory. Sign-in methods and password confirmations share one count, and setup codes have their own. - Proxies —
lib/http.tstrusts forwarded headers only from loopback and private addresses, and takes the scheme and hostname for passkeys from each request. - Notifications —
lib/notifier.tsturns reports, sign-in lockouts, and a lost key into notifications, andlib/notifications.tsbuilds each service's request with a 10-second limit. Sends run in the background, and each destination's last attempt and error are saved for the Notifications page. - History —
apps/server/src/history.tskeeps the newest 50 reports in<OUTPOST_DATA_DIRECTORY>/history.json. It writes through a temporary file and a rename, and validates the file with zod when loading.
The scheduler reads lastReport from the history, so /v1/status and /metrics show the same latest report after a restart. The dashboard's container table searches that newest-first history for each container's own latest result.
Digest Lookup
fetchRemoteDigest in packages/registry/src/digest.ts sends a HEAD request for the tag's manifest. The Accept header includes the OCI index and Docker manifest list types, so the registry returns the multi-platform digest that Docker records in RepoDigests.
When the registry answers 401, the client follows the WWW-Authenticate challenge with a Bearer token request or Basic credentials. Any lookup error is caught, and the updater falls back to pulling the image and comparing image IDs.
Recreation
buildCreatePlan in packages/updater/src/plan.ts turns the inspected container into a create body:
- Image defaults — Whole-value settings (
Cmd,Entrypoint,WorkingDir,User,Healthcheck,StopSignal) that equal the old image's config are deleted. Env entries, labels, exposed ports, and volumes are filtered entry by entry. - Anonymous volumes — Volume mounts not already covered by
BindsorMountsare added toBindsby volume name. - Networks — Only one network can be attached at create time, so the primary network goes in
EndpointsConfigand the rest are connected before start. Aliases equal to the old short ID are dropped.
recreateContainer in packages/updater/src/recreate.ts runs stop, rename to <name>-outpost-previous, create, connect, start, and waitUntilHealthy. The wait rejects a stopped, non-running, restarting, unhealthy, or newly restarted replacement.
Any error after the stop triggers the rollback path.
Self-Update
Outpost can't stop its own container and keep watching the replacement, so apps/server/src/self-update.ts hands that swap to a helper container:
- Hand over —
handOverwrites the cycle's results so far toself-update.jsonin the data directory, then starts<name>-outpost-self-updatefrom the image Outpost runs now. The helper runsnode build/self-update-helper.jswithout s6-overlay, with Outpost's environment, data and socket mounts, network, and the server's user and groups, but no ports, no healthcheck, andio.cbnventures.outpost.enable=false. - Swap —
apps/server/src/self-update-helper.tsruns the samerecreateContaineras every other update, adds its outcome to the file, and exits. Stopping Outpost normally ends the waitinghandOver; if the helper exits first, or runs past the stop timeout plus the health timeout plus 60 seconds,handOverrecords why and removes it. - Resume — The Outpost running afterwards, new or restored, finds the pending cycle at startup, and
Runner.resumeholds the scheduler's lock whilefinishSelfUpdatewaits for the helper. The resumed cycle keeps its original start time and trigger, and stands in for that start's Check when Outpost starts check. - Report —
finishSelfUpdateadds Outpost's own result to the saved ones, so the cycle becomes one report that is notified and recorded once, even if Outpost restarts again. It removes the helper after an update or a rollback, and with cleanup on, the previous image after an update. - Settle —
Runner.settleinapps/server/src/handoff.tsremoves the file, or keeps only the ID of a new image that rolled back or failed, whichhandOverthen skips. A helper that failed after stopping Outpost is kept fordocker logs, andremoveExitedHelpersremoves stopped helpers, found by theio.cbnventures.outpost.self-update-helperlabel, at the next start.
Each release keeps the database readable by the release before it, because a rollback starts the old Outpost on the same data directory.
Error Strategy
| Layer | Strategy |
|---|---|
| Config | Invalid environment values stop startup with a readable message. |
| Engine client | Non-2xx responses throw with the daemon's error message. |
| Registry | Lookup failures fall back to pull-and-compare. |
| Recreation | Any failure after stopping triggers rollback; a failed rollback is reported as failed. |
| Self-update | A new image that rolled back or failed is skipped until a newer one appears, and a helper that failed after stopping Outpost is kept for its log. |
| Cycle | One container's failure is recorded and the cycle continues. A whole-cycle failure becomes one failed report. |
| Notifications | A failed send is saved on its destination and never thrown, so it can't stop a cycle. |
| History | A missing or invalid file starts an empty history; save failures are logged and never thrown. |
| State | A mismatched or damaged encryption key, or a data directory that PUID can't use, stops startup instead of failing on first use. A missing key is replaced with a new one. |
Containers are never removed with their volumes, and Outpost never swaps its own container from inside it, because the self-update helper does that.
Image and Process Model
The image is built on Debian trixie slim with s6-overlay as PID 1, which starts as root. Its nova-container-init step runs packages/docker/rootfs/usr/local/libexec/nova-docker/init-user as root only to prepare /data: it checks PUID, PGID, and TZ, then creates /data and hands it to PUID and PGID.
The server service then starts through packages/docker/rootfs/etc/s6-overlay/s6-rc.d/server/run:
- Resolves the socket path from
DOCKER_HOST. - Reads the group ID that owns the socket.
- Runs
node build/index.jswiths6-applyuidgidasPUIDandPGID, adding the socket's group as a supplementary group.
The updater process therefore runs as PUID and PGID, never as root, and PGID doesn't need to match the host's docker group. The image healthcheck calls GET /health on the configured PORT, which defaults to 22601.
warning
Access to the Docker socket is equivalent to root on the host, because the Docker API can start privileged containers and mount any host path. Running the updater as PUID limits the process itself, not what it can ask Docker to do.
Dependency Injection
runUpdateCycle receives the Docker engine, fetch, the clock, sleep, the credential lookup, and the updateSelf step as arguments. Every decision, including rollback, the health wait, and the hand-off of Outpost's own container, can be unit-tested without a Docker daemon.