Homelab & networking · Build log
How a deploy works with Dockhand
On my home server a deploy is a git push: Dockhand picks up the changed stack from Forgejo, redeploys it, and secrets come from 1Password, never the repo.
On the old server, I was managing each service in an Ansible playbook. That sounded tidy, but in practice I’d often make quick fixes by hand on the box instead of going through the playbook, so the running containers started to drift from what was in the playbook.
The new home server gave me a chance to split those two jobs. I’m using Ansible to manage the base system (like it already handles my network gear), and a dedicated tool for containers.
Last week I did a trial install of Dockhand on the old server and I’m happy with it. It’s lighter than Portainer and can import existing compose stacks. The source is on GitHub under the BSL 1.1 license, so you can read the code. The whole thing is built around its git integration, so kudos to the Finsys project for that.
The repo is the definition
I’m using a monorepo for home-infra, with a folder for each service: stacks/<service>/compose.yaml. The image: line pins the running version, so upgrading is just changing the tag and pushing. Dockhand clones the repo and runs compose from its own copy, so any hand edits on the host don’t matter. For example, I have a self-built search service with its tag pinned in my own Forgejo registry.
# stacks/vault-search/compose.yaml (excerpt)
services:
vault-search:
image: <registry>:<port>/<user>/vault-search:2.5.0
container_name: vault-search
restart: unless-stopped
And the same repo deploys Dockhand itself, which pins its own release the same way. So even the tool doing the deploying is defined by a git line.
# stacks/dockhand/compose.yaml (excerpt) - Dockhand itself, pinned the same way
services:
dockhand:
image: fnsys/dockhand:v1.0.29
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /storage/containers/dockhand:/storage/containers/dockhand
environment:
- DATA_DIR=/storage/containers/dockhand
I didn’t configure anything through the web UI. There were lots of forms, so I asked if they could be scripted. That got me making REST calls to a largely undocumented API. I figured out the field names the hard way: sshPrivateKey instead of sshKey, stackName instead of name. The repo URL needed the ssh:// form because Forgejo’s SSH runs on a non-standard port. I learned to push the stack folder before creating the stack, otherwise Dockhand wouldn’t find the compose file. It was fun to figure out, and everything ended up in a single helper script.
Push to redeploy
Each stack has its own webhook URL and its own HMAC-SHA256 secret. The helper script generates a secret with openssl rand -hex 20 and sets it on both ends: in the Dockhand stack and in the new push hook in Forgejo.
# .claude/skills/dockhand/scripts/deploy-stack.sh (excerpt)
SECRET="$(openssl rand -hex 20)"
curl -s -X PUT "$DH/api/git/stacks/$SID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"webhookEnabled\":true,\"webhookSecret\":\"$SECRET\"}" >/dev/null
curl -s -X POST "$FORGEJO/api/v1/repos/<user>/home-infra/hooks" \
-H "Authorization: token $FTOKEN" -H "Content-Type: application/json" \
-d "{\"type\":\"gitea\",\"active\":true,\"events\":[\"push\"],\"config\":{\"url\":\"$DH/api/git/stacks/$SID/webhook\",\"content_type\":\"json\",\"secret\":\"$SECRET\"}}"
With a monorepo, a single push triggers every stack’s Forgejo webhook. Each stack checks whether its folder was changed, and if not, reports the deploy as skipped without restarting. This means I can put my Ansible files in the same repo: editing those triggers all hooks, but all stacks skip.
One wall, on the Forgejo side, is that it refuses webhooks to private addresses by default. That makes sense for a public instance, but it prevents my home server from talking to itself. I fixed that by adding Dockhand’s host to the ALLOWED_HOST_LIST under [webhook] in app.ini (config cheat sheet) and restarting Forgejo. Now the push path worked end to end.
Secrets from 1Password
My first approach to secrets was to check .env.op files into git with op:// references, then run op inject in a systemd oneshot at boot to render the real .env files next to them.
# stacks/observability/.env.op (deleted in 817397d)
GF_ADMIN_USER=admin
GF_ADMIN_PASSWORD=op://<vault>/<item>/password
# ansible/files/monitoring/refresh-secrets.sh (deleted in 817397d), systemd oneshot at boot
for envop in "$STACKS_DIR"/*/.env.op; do
dir=$(dirname "$envop")
if op inject -i "$envop" -o "$dir/.env" --force 2>/dev/null; then
chmod 600 "$dir/.env"
else
ERRORS=$((ERRORS + 1))
fi
done
I gave up on that plan by the afternoon. The oneshot was racing with Docker on boot and not retrying, so it was fragile. The commit message removing it says “Single-user homelab doesn’t need the op-inject indirection layer.”
The replacement is simpler: I read values from 1Password and send them by PUT into the Dockhand per-stack environment as regular variables. Dockhand stores them in an env file in its data directory on ZFS, so they persist across restarts. The compose file references only the variable name, using compose interpolation, never the value.
# stacks/observability/compose.yaml (grafana excerpt)
environment:
- GF_SECURITY_ADMIN_USER=${GF_ADMIN_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GF_ADMIN_PASSWORD:-admin}
This keeps 1Password as the source of truth, with Dockhand merely handling delivery. I avoided using Dockhand’s own “secret” variable type because those values are only stored in its database (encrypted, but still lost when Docker restarts).
The inject-secrets.sh script was born out of about 7 manual curl runs of this entire sequence. The environment PUT operation clobbers all variables, so the script reads the existing set, merges, and writes it back. It only ever outputs key names and value lengths, never the values themselves.
# .claude/skills/dockhand/scripts/inject-secrets.sh (excerpt)
case "$VALSPEC" in
op://*) VAL="$(op read "$VALSPEC")";;
lit:*) VAL="${VALSPEC#lit:}";;
esac
new_json="$(jq -c --arg k "$KEY" --arg v "$VAL" '. + [{key:$k,value:$v,isSecret:false}]' <<<"$new_json")"
# PUT replaces the whole set, so merge with what's there first
EXIST="$(curl -s "$DH/api/stacks/$SVC/env?env=$ENVID" -H "Authorization: Bearer $TOKEN" | jq -c '(.variables // .)')"
MERGED="$(jq -c --argjson new "$new_json" \
'($new | map(.key)) as $nk | (map(select((.key as $k | $nk | index($k)) | not))) + $new' <<<"$EXIST")"
curl -s -X PUT "$DH/api/stacks/$SVC/env?env=$ENVID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"variables\":$MERGED}"
The ordering is important if your stack needs the secret to start. The helper creates the stack and wires the webhook without deploying (RUN_DEPLOY=0), injects the secrets, then deploys. Review caught the initial wrong ordering, which I hadn’t noticed.
Proving the push path
The first six stacks deployed via direct API calls, so I hadn’t actually exercised the webhook path. I wanted to see it work before trusting it. I tested by pushing a one-line comment to the compose file of Scrutiny (my disk SMART dashboard).
# chore: scrutiny compose comment (webhook path test)
--- a/stacks/scrutiny/compose.yaml
+++ b/stacks/scrutiny/compose.yaml
@@ -1,3 +1,4 @@
+# Disk SMART dashboard + history (complements smartd+Pushover alerting from Phase 2b).
services:
scrutiny:
As expected, Scrutiny redeployed and nothing else did. The injected Scrutiny secret survived the process too. It was satisfying to see the first full loop work as designed. I’ve now made the test push a standing step in my deployment runbook.. every new stack gets one before I consider it done.
But that check has a limit. A green deploy from Dockhand only means the compose command ran, not that the config you think should reach the container actually did. I’d mounted my observability stack’s configs with absolute paths from a second repo copy Dockhand never syncs, so my Loki limits edits never landed and it ran defaults. Switching to relative mounts fixed it and I added a “mounts must be relative” rule to my stack standard runbook. I also learned the hard way that while git stacks deploy via POST /api/git/stacks/{id}/deploy, /api/stacks/<name>/deploy?env=1 just returns an empty body.. now I check the actual container state and Dockhand’s sync status instead of assuming the response means anything.
Nine stacks from git
By the end of the first night, I had nine stacks running from git, each with its own Forgejo webhook. I migrated Dockhand’s data from a Docker named volume to a ZFS bind mount backed by Sanoid snapshots. I enabled authentication, backed up the API token and encryption key to 1Password, and the next day started building my own images through Forgejo’s container registry with pinned tags. Dockhand pulls them using its own stored registry credential (not the host’s docker login). Because git defines what should run, I now check the running container against it after every deploy. The registry is part of my self-hosted Forgejo, and I verified the stored credential works on the search service.
Next up: a commit and the running container that disagreed.