stow is a small GitOps reconciler for code-described service deployments on a Docker host. The GitLab deployment repository is the source of truth: each service is declared as a stow.yaml deployment descriptor plus its versioned configuration files.
It is deliberately small: GitLab merge requests are the change-control flow, and the host continuously converges actual runtime state toward the desired state in Git. stow downloads the service descriptor, decrypts SOPS-managed configuration, computes a content hash, starts Docker containers with matching labels, and rolls back automatically if the new service instance fails verification.
The usual workflow is:
- An application pipeline builds and pushes a Docker image.
stow suggest-imageopens or updates a GitLab merge request that pins the new image digest in the deployment repo.- After the MR is merged, a deployment-repo pipeline calls the host daemon.
stow daemonreconciles the Docker host to the merged Git commit.
In OTF-style terms, stow treats services as code: declarative service descriptors, desired-state reconciliation, immutable digest-pinned artifacts, versioned configuration, auditable approvals, convergence status, and boring rollback behavior in a small single-binary tool.
suggest-image is the image-bump mode. It is meant to run from the application build pipeline after a successful Docker build.
It:
- reads the target deployment repo through the GitLab API
- loads
<subfolder>/stow.yaml - replaces the selected container image with the new image digest
- force-updates a fresh
suggest/...branch from current default branch - creates or updates a merge request
- sets the MR source branch to delete on merge
- adds a linked convergence badge if
deployment.daemonBaseUrlexists instow.yaml - optionally adds changelog entries from a markdown file
Example:
CI_API_V4_URL=https://git.example.com/api/v4 \
GITLAB_ACCESS_TOKEN=... \
stow suggest-image \
--project ops/deployments \
--subfolder deploy-host.example.com \
--image registry.example.com/apps/webapp:20260428.0 \
--digest 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
--container webapp \
--assign gitlab_user_id,id:146,user:some.username \
--changelog-file CHANGELOG.mdRequired:
CI_API_V4_URLGITLAB_ACCESS_TOKENorCI_JOB_TOKEN--project--subfolder--image
Optional:
--digest; if omitted,stowasks Docker for the digest--container; target container name (required)--assign; comma-separated MR assignment attempts, tried in order:gitlab_user_id,id:<gitlab-user-id>,user:<gitlab-username>--changelog-file; adds only the added markdown lines between old and new image tags
--digest must be the registry manifest digest. Use the digest: sha256:... line from docker push; Docker image IDs are local image/config digests rather than pullable registry manifest digests.
Put the daemon URL in the target stow.yaml:
deployment:
name: webapp
daemonBaseUrl: https://deploy-host.example.com:17403/Badge logic:
suggest-imagecreates/updates the suggest branch first- the MR badge points at the Git commit that contains the proposed
stow.yaml - the badge image is:
<daemonBaseUrl>/gitlab.svg?git_hash=<suggest-commit-sha> - the badge links to:
<daemonBaseUrl>/status?head_hash=<suggest-commit-sha> - the daemon resolves that Git commit to the expected deployment hash, then compares it with the host's current running deployment hash
runningmeans the host is running the deployment produced by that Git commitreconcilingmeans the daemon is actively applying that Git commitqueuedmeans that Git commit is queued behind another reconciledifferentmeans the host is running a deployment from another Git commiterrormeans the last reconcile failed
daemon runs on the Docker host. It exposes a small HTTPS API for triggering reconcile and reading status.
Put daemon config in:
/etc/stow/daemon.yaml
The whole config directory must be locked down:
- owned by
root - directories mode
0500 - files mode
0400 - regular files and directories only
Good:
sudo install -d -o root -g root -m 0500 /etc/stow
sudo install -o root -g root -m 0400 daemon.yaml /etc/stow/daemon.yaml
sudo install -o root -g root -m 0400 tls.crt /etc/stow/tls.crt
sudo install -o root -g root -m 0400 tls.key /etc/stow/tls.keyDaemon config format:
gitlabBase: https://git.example.com/api/v4
project: ops/deployments
gitlabToken: glpat-...
subfolder: deploy-host.example.com
keys: /root/keys.txt
sopsBinary: /usr/bin/sops
listen: 0.0.0.0:17403
tlsCrt: /etc/stow/tls.crt
tlsKey: /etc/stow/tls.keySet gitlabToken in this root-only config file as the single token location.
Dry-run reconcile:
stow reconcile --config /etc/stow/daemon.yaml --dry-run --plan-jsonRun daemon manually:
stow daemon --config /etc/stow/daemon.yamlThe daemon listens on HTTPS.
Rollout failure behavior is automatic:
stowsaves the previous running state before applying a new one- after
docker run, it waits up to 60 seconds for every desired container to become valid and stay valid for 20 seconds - a container is valid when it exists, is running, is stable, has zero restarts, has the expected stow labels, and is
healthyif it has a Docker healthcheck - if apply or verification fails,
stowrestores the previous state and reapplies it
Intentional rollback should be done by reverting Git and letting the daemon reconcile that commit.
The reconcile loop is deliberately boring: fetch desired state, hash it, compare it with Docker, apply the delta, verify, then either commit the new runtime state or roll back.
Git revision
-> download deployment repo archive
-> select configured subfolder
-> decrypt SOPS files in place
-> compute hashes
-> load stow.yaml
-> inspect Docker containers
-> plan noop / replace / delete
-> apply plan
-> verify running containers
-> keep new state or restore previous state
Hashing is path-sensitive and content-sensitive. Files are walked in sorted order, and each hashed file contributes:
relative/path + NUL byte + file contents
stow keeps four hashes in the plan output:
manifest_hash:stow.yamlonly.config_hash: all regular files exceptstow.yamland decrypted secret files.secrets_hash: decrypted secret files only.deployment_hash: the combined hash ofmanifest_hash,config_hash, andsecrets_hash.
The deployment_hash is the identity of the desired runtime state. It changes when:
- the container definition in
stow.yamlchanges - an image tag or digest changes
- any non-secret config file changes
- any decrypted secret value changes
- a hashed file is renamed
It does not change because of Git metadata, commit message text, file mtimes, directory mtimes, Docker image IDs, or unreferenced containers on the host.
Runtime state transitions:
downloaded repo
-> staging directory
-> ~/.stow/snapshots/<deployment_hash>
-> ~/running-config symlink
On each successful apply:
- The new staged config is moved to
~/.stow/snapshots/<deployment_hash>. - The old
~/running-configsymlink is moved to~/running-config.previous. ~/running-configis pointed at the new snapshot.- Metadata is written into the running config:
.git-revision.config-sha256.deployment-name.stow-snapshot.json.stow-rendered-manifest.yaml
- Docker containers are reconciled.
- If verification passes,
running-config.previousis removed.
If apply or verification fails, stow restores running-config.previous, reapplies that previous manifest, and verifies it. This is why rollback is local and fast: the previous snapshot is already on disk.
Docker reconciliation is label-based:
- every managed container gets
stow.deployment=<deployment name> - every managed container gets
stow.hash=v1:<deployment_hash> - a desired container is
nooponly when it is running and both labels match - a missing, stopped, or stale-hash container is replaced
- a labeled container no longer present in
stow.yamlis deleted
Verification requires every desired container to exist, run, keep the expected labels, avoid restarts, avoid Docker's Restarting state, and report healthy if it has a healthcheck. The deployment must remain stable for 20 seconds inside a 60 second verification window.
A deployment is not considered complete when docker run exits. It is complete only after the new desired state has been applied and verified.
Normal deploy cycle:
trigger received
-> fetch desired Git revision
-> decrypt and hash desired state
-> move desired state into running-config
-> stop/remove containers that should change
-> start replacement containers with stow labels
-> verify all desired containers
-> remove running-config.previous
-> report success
stow decides a deployment is good when all desired containers pass the full verification window:
- the container exists
- it is running
- it is not in Docker's
Restartingstate - it has restart count
0 - it has
stow.deployment=<deployment name> - it has
stow.hash=v1:<deployment_hash> - if Docker reports a healthcheck, the health status is
healthy - the whole desired deployment stays valid for 20 continuous seconds
- this all happens before the 60 second verification timeout
If any condition fails, stow keeps waiting until the timeout. A container that briefly looks good and then restarts resets the stable timer.
Automatic rollback cycle:
new deploy fails apply or verification
-> move running-config.previous back to running-config
-> load the previous manifest
-> plan Docker back to the previous hash
-> apply the rollback plan
-> verify the previous deployment
-> report the new deploy as failed
Rollback is therefore state rollback, not a best-effort container restart. The previous on-disk snapshot includes the previous manifest, config, decrypted secrets, Git revision metadata, and deployment hash. Docker is reconciled back to that snapshot using the same label and verification rules as a normal deploy.
Intentional rollback is simpler: revert the deployment repository, merge that revert, and trigger the daemon. To stow, that is just another desired Git revision with its own deployment hash.
Copy the stow binary to the host first, normally:
sudo install -o root -g root -m 0755 stow /usr/local/bin/stowThen upsert the service:
sudo stow install-systemd --config /etc/stow/daemon.yamlThis writes/updates:
/etc/systemd/system/stow.service
Then it runs:
systemctl daemon-reloadsystemctl enable stow.servicesystemctl restart stow.service
Check it:
systemctl status stow.service
journalctl -u stow.service -fTrigger a reconcile. Clients should use bounded retry with exponential backoff and jitter, especially from CI pipelines, so repeated webhook or network failures create gentle load instead of a request storm:
curl --fail --silent --show-error \
--request POST \
"https://deploy-host.example.com:17403/trigger?head_hash=<git-commit-sha>"Check status:
curl --fail --silent --show-error \
"https://deploy-host.example.com:17403/status?head_hash=<git-commit-sha>"Badge URL:
https://deploy-host.example.com:17403/gitlab.svg?git_hash=<git-commit-sha>
For the deployment repo, use stow-merge-gitlab-ci.yaml as the post-merge pipeline shape. It:
- runs on default branch
- detects which deployment directories changed
- reads each directory's
deployment.daemonBaseUrl - posts
/trigger?head_hash=$CI_COMMIT_SHA - leaves convergence reporting to the daemon status and badge endpoints
If the daemon uses a private CA, set:
STOW_CACERT=/path/to/ca.pem