How it works
How yoink works internally: spec_hash drift detection, the deploy lock, healthcheck-gated rolling swaps, and wave-ordered deploys.
docker compose up, ssh && docker run, and hand-rolled bash share four failure modes: they re-create containers that haven't changed (or skip re-creating ones that did); they swap atomically only if you wire the healthcheck flags; they keep no audit trail; and two concurrent runs corrupt host state silently.
Yoink addresses each: a content hash on every container for drift detection, a deploy lock for concurrent runs, a healthcheck-gated swap so a broken build can't replace a working one, and an audit trail with one-key rollback. This page covers each.
Drift detection
Every yoink-managed container carries a yoink.spec_hash label. The hash is computed from a deterministic encoding of the container's effective spec:
image:tag(resolved with the operator's--tagoverrides applied)- the full env map (literals + every
secrets:value resolved at deploy time) - network aliases + the set of attached networks
- mounts:
binds(after the auto-:rorewrite) +volumes+tmpfs(after the auto-noexec,nosuid,nodevrewrite) - run options:
memory,cpus,pids_limit,cap_drop,cap_add,security_opt,read_only,init,user,restart - ports (
publishstrings) entrypoint+cmd- the content hash of every
files:mount (so a config-file change reroles the service)
The hash is sha256 of the encoding, hex-encoded. Two containers with the same spec_hash are byte-for-byte the same effective spec.
What's deliberately not hashed:
- container name (changes per deploy because it embeds the short hash)
- the
yoink.deployed-by/yoink.deployed-ataudit labels - the host address (the same spec runs on every applicable host)
Four things depend on this hash:
yoink upshort-circuits any service whose desired hash matches the running one: no pull, no swap, no event noiseyoink up --dry-runreports per-service Create / Update / NoOp by computing the desired hash and comparing- The TUI dashboard's drift column reads the labels off running containers and renders β / β accordingly
- Two services on different hosts or replicas with matching specs share a hash; drift is per-spec, not per-container
If a yoink up rerolls every service when you didn't expect it, the cause is always something that fed into the hash changing. The most common cases: a default flipped between yoink versions (every v0.x.0 security-defaults change has done this once), a secret value rotated (resolved env changes β hash changes), a files: mount's content changed.
Deploy lock
yoink up takes a per-host advisory lock so two operators (or an operator + a CI runner) don't fight over the same docker daemon mid-deploy. The mechanism is a sentinel container, not a file lock, so it survives operator-side crashes cleanly.
How it works:
- On
up, yoink tries to start a container namedyoink-deploy-lockon each host. Image:alpine:latest, command: a tiny shell loop that watches/tmp/heartbeatand exits when the file is older than ~30s. - If the container is already running on a host,
uperrors with "another deploy in progress." The competing operator either waits or (if the sentinel is stale) re-runs after the sentinel self-exits. - If the container exists but is stopped (a crashed previous deploy), yoink force-removes it and starts fresh.
- Once acquired, the operator side
docker execs into the sentinel every 5 seconds to touch/tmp/heartbeat. After ~30s of missed pings the sentinel self-exits. - On
upfinish (success or error), yoink aborts the heartbeat and force-removes the sentinel. Next deploy: instant acquire.
Failure modes the design handles:
- Operator crash / SIGKILL / network drop: heartbeat task dies with the process, sentinel self-exits within 30s, next deploy reaps the stopped orphan.
- Operator's TUI session leaves a sentinel behind: same as above; close the session and the sentinel exits within 30s, OR run
yoink lock --releaseto reap it explicitly. - Two operators race: whoever's
create_containerlands first wins; the loser'sacquireerrors loudly.
Logs you'll see in the field:
lock heartbeat exec failed host=β¦ error=β¦container is not running: your local yoink's heartbeat tried to touch a sentinel that someone else swept. Your lock is gone; close + reopen.
Dependency-ordered deploys
Services declare what they need:
services:
- name: api
depends_on: [redis]
- name: web
depends_on: [api]
- name: caddy
depends_on: [api, web]yoink up does a topological sort + runs services in waves. Within a wave (services whose deps are all satisfied) services run concurrently via try_join_all. Between waves they run sequentially.
For the config above:
- Wave 0:
redis(no deps) - Wave 1:
api(redis is done) - Wave 2:
web(api is done) - Wave 3:
caddy(api + web are done)
Where it matters: the pre_deploy hooks for a service run before that service's wave starts, so a database migration completes before the api container that depends on it ever pulls.
yoink validate (and any deploy command) rejects depends_on: cycles at config load time, before yoink touches a host.
Secrets resolution
Two providers, dispatched by secrets.provider::
age(default, batteries-included): yoink decrypts a sealedsecrets.agefile at deploy time using one X25519 identity, resolved in priority order:YOINK_AGE_KEYenv (CI),YOINK_AGE_KEY_FILEenv (explicit override),~/.config/yoink/keys/<recipient>.key(laptop default; yoink scans the dir and picks the key whose public matchessecrets.recipients:), then~/.config/yoink/age.key(legacy fallback).yoink secrets key generatewrites to the keys dir; the firstyoink upafter that finds the right key automatically. For CI,yoink secrets key generate --print | gh secret set YOINK_AGE_KEYpipes the secret into your store, and the workflow surfaces it asYOINK_AGE_KEYat deploy time. See Sealed secrets (age) for the full mental model.command: yoink invokes the configured command, captures stdout, and parses it as a secrets bundle. Format is auto-detected: stdout starting with{is JSON, anything else is dotenv (KEY=value\n). One spawn peryoink up(and once on TUI startup). Stderr is captured and surfaced when the command exits non-zero.
There's no first-party integration with any specific manager. Operators wire their tool of choice via its standard CLI: doppler secrets download --format env, infisical export --format=dotenv, vault kv get -format=json, aws secretsmanager get-secret-value, etc. See external secrets via CLI for per-tool recipes.
Both providers produce the same shape: a keyβvalue bundle. Every service that lists secrets: (or env_from_secrets:) gets its values picked out of that bundle and injected as env vars on the container. Those values feed into yoink.spec_hash, which is why a rotated secret triggers a redeploy.
Prune semantics
yoink prune removes containers that match all three:
- Carry the
yoink.managed=truelabel (so we never touch unmanaged containers) - Are not declared in the current config: either the service was renamed, removed, or this is a stale generation from a previous deploy
- Are not the active sentinel container for an in-flight deploy (the lock survives prune)
What prune does not remove:
- Containers without the
yoink.managed=truelabel (legacy containers, pre-yoink hand-runs, etc.) - Stopped containers that are in the current config (they might be part of a paused deploy, or
yoink upwill reap them on the next reconcile) - Volumes or networks (use
docker volume prune/docker network prunefor those)
yoink prune --dry-run prints what would be removed without acting. Run it before merging a service rename; the staging container with the old name shows up in the list.
Runtime container shape
When yoink creates a container, the docker host config it sends is built from service.run.options plus a few fixed pieces:
- Auto-add to
tmpfsmount options:noexec,nosuid,nodev(unless operator explicitly opted in toexec/suid/dev) - Auto-add to
binds::rosuffix when no mode set (operator opts in to:rwexplicitly) init: trueby default; tini as PID 1 reaps zombies + forwards SIGTERM- Network mode = first network from the merged service+deploy
networks:list; additional networks attached post-start - Port bindings parsed from
publish:entries - Restart policy = the
restart:string (no/always/unless-stopped/on-failure); default unset = no restart
The full security-defaults table is on Secure by default.
Healthcheck-gated rolling swap
For each replica of each service, yoink does:
- Resolve the desired tag, build the spec, compute
spec_hash. - Check the host snapshot: if a container with the matching name +
spec_hashis already running, skip (no-op). - Otherwise: pull the image (or skip when an earlier
load_images_to_hostsphase already shipped the build artifact, or when--no-registryforced local-only mode), create the new container with the resolved name (<service>-<short_hash>or<service>-<short_hash>-<idx>for replicas), start it. - Probe the configured healthcheck (
healthcheck_pathHTTP GET, or a TCP-connect probe onportif no path is set). Retry on a backoff until it passes orhealthcheck_timeoutelapses. - On healthy: stop the previous-generation container (waiting
drain_timeoutfor graceful shutdown), then force-remove it. - On healthcheck failure: leave the new container running but exited, leave the old one running, surface the error. The operator inspects via
yoink logs/yoink shelland either fixes config or rolls back.
Replicas run sequentially (one container at a time per replica index) so capacity stays at N-1 during the swap. Across services in the same wave, swaps run concurrently.
See also
- Deploy modes: how the build origin and distribution axes interact.
- Networking: multi-host distribution patterns + the no-publish default.
- Configuration reference: every field that flows into
spec_hash. - Pre-merge dry-run on every PR: see drift detection in action via PR comments.