6.6 KiB
Strategy
Pattern
Pull-based GitOps via Gitea Actions runners on the owner host.
dev box ── git push ──► Gitea (Charlie/<stack>) ── webhook ──► runner on owner host
│
▼
git -C /mnt/nas/stacks/<stack> pull --ff-only
docker compose pull && docker compose up -d
The git working tree at /mnt/nas/stacks/<stack>/ is the deployment. Runtime state lives separately under /mnt/nas/data/<stack>/ (see layout.md).
Rules
- One owner host per stack (singleton stacks). Only that host's runner ever touches the working tree.
- Shared stacks (run on multiple hosts — see Shared stacks) follow the same NFS-shared-tree model but each host runs its own
docker compose up -dagainst host-specific overrides. - No hand-edits on the host once a stack is repo-managed.
git pull --ff-onlywill refuse to clobber. - Runtime state never enters git. Compose files bind into
/mnt/nas/data/<stack>/, not into the working tree. .envignored,.env.exampletracked. Secrets stay out.- Image tags pinned. No
:latest. - Cross-host self-update: the runner that deploys
Charlie/gitealives on morgott; the runner that updates morgott's runner lives on melina. A runner never redeploys itself mid-job.
Repo types
Three. (We dropped upstream+overrides — see Why no upstream cloning.)
init
Stack is currently just a NAS folder. git init in the new /mnt/nas/stacks/<stack>/, push to Charlie/<name>. Recipe: migration.md.
rewrite
Code we author, currently a clone of a github.com repo. Move to /mnt/nas/stacks/<stack>/, git remote set-url origin to Charlie. Recipe: migration.md.
mirror
Pull-mirror of an upstream we don't customise (or want as offline backup). Configured via Gitea's pull-mirror feature. Read-only, never deployed from. Naming: Charlie/<name>-mirror.
Shared stacks
Some stacks run on multiple hosts with the same image but slightly different configuration (e.g. otelcollector, portainer-agent). The pattern, borrowed from Ansible inventories + Kustomize overlays + Kubernetes DaemonSets:
Charlie/<stack>/
├── docker-compose.yml # the "what" — default, used by hosts without an override
├── docker-compose.<host>.yml # full per-host compose (when the delta is structural)
├── envs/
│ ├── morgott.env # per-host env (Ansible host_vars equivalent)
│ ├── melina.env
│ ├── mohg.env
│ └── miquella.env
├── overrides/ # additive overlay deltas (when env can't express it)
│ └── <host>.yml
├── deploy.sh # picks env + compose file based on `hostname -s`
└── README.md
Resolution order in deploy.sh (per host)
- If
docker-compose.<host>.ymlexists → use only that (full per-host compose). - Else
docker-compose.yml+ (if present)overrides/<host>.ymllayered on top. - If
envs/<host>.envexists → loaded with--env-filein either case.
Hostname is lowercased (hostname -s | tr '[:upper:]' '[:lower:]').
When to use which
| Delta type | Use |
|---|---|
| Different scalar value (port, env var, URL) | envs/<host>.env |
| Add an extra service or scalar field | overrides/<host>.yml (compose merges additively) |
| Replace a list (volumes, command, networks) | docker-compose.<host>.yml (full file) |
| Different image | docker-compose.<host>.yml |
Compose's !override and !reset YAML tags exist for list-replacement, but support is uneven across compose versions (notably broken on Synology DSM's bundled compose). Prefer the full-file approach when replacing lists.
Canonical deploy.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
HOST=$(hostname -s | tr '[:upper:]' '[:lower:]')
if [ -f "docker-compose.$HOST.yml" ]; then
args=(-f "docker-compose.$HOST.yml")
else
args=(-f docker-compose.yml)
[ -f "overrides/$HOST.yml" ] && args+=(-f "overrides/$HOST.yml")
fi
[ -f "envs/$HOST.env" ] && args=(--env-file "envs/$HOST.env" "${args[@]}")
exec sudo docker compose "${args[@]}" "$@"
Mental model
- "What" =
docker-compose.yml(or per-host file). - "Where" = the host running
./deploy.sh(no inventory file; the host knows its own name). - Per-host vars =
envs/<host>.env. - Per-host structural deltas =
overrides/<host>.ymlfor additive,docker-compose.<host>.ymlfor replacement. - Group defaults = compose env-var defaults (
${VAR:-default}).
Once runners exist (Phase 2)
Workflow declares a host matrix; one job per host, all parallel:
strategy:
matrix:
host: [morgott, melina, mohg, miquella]
runs-on: [self-hosted, "${{ matrix.host }}"]
steps:
- run: cd /mnt/nas/stacks/<stack> && ./deploy.sh up -d
That's a Kubernetes DaemonSet shape adapted to compose + Gitea Actions.
Why no upstream cloning
Previously we considered cloning upstream's source on the host and layering a docker-compose.override.yml. Dropped because:
- For every "upstream image" stack (
invidious,searxng,victoriametrics,mealie,paperless-ngx), we only ever ran the published image. Their source tree is dead weight on the host. - Override-on-template means the upstream's compose can change under us silently.
- Two-remote / rebase / force-push dance was the price for dubious value.
Instead: our docker-compose.yml references the published image directly. It is the only compose. Upstream's compose is a reference document we read in their mirror repo when we want to see what changed.
Image-update flow (Phase 3)
Diun watches registries; on a tag/digest change it POSTs to a Gitea webhook that triggers a redeploy workflow with force-pull: true on the owner host. Replaces watchtower.
Secrets
Open question. Candidate: vaultwarden as the source, with a runner-side fetch step that materialises .env per job. Decided when we design the runner stack.
Why not Portainer/Komodo/Dockge
- Portainer Stacks-from-Git: works, but no submodule support, opinionated about working-tree location, adds a control plane on top of git.
- Komodo: nice, also pull-based, also relocates working trees. Reconsider if we ever want a UI.
- Dockge: file mgmt only, no git wiring.
The runner-driven pattern keeps us in plain git + docker compose and adds nothing the team doesn't already understand.