# Strategy ## Pattern Pull-based GitOps via Gitea Actions runners on the owner host. ``` dev box ── git push ──► Gitea (Charlie/) ── webhook ──► runner on owner host │ ▼ git -C /mnt/nas/stacks/ pull --ff-only docker compose pull && docker compose up -d ``` The git working tree at `/mnt/nas/stacks//` **is** the deployment. Runtime state lives separately under `/mnt/nas/data//` (see [layout.md](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](#shared-stacks)) follow the same NFS-shared-tree model but each host runs its own `docker compose up -d` against host-specific overrides. - No hand-edits on the host once a stack is repo-managed. `git pull --ff-only` will refuse to clobber. - Runtime state never enters git. Compose files bind into `/mnt/nas/data//`, not into the working tree. - `.env` ignored, `.env.example` tracked. Secrets stay out. - Image tags pinned. No `:latest`. - Cross-host self-update: the runner that deploys `Charlie/gitea` lives 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](#why-no-upstream-cloning).) ### `init` Stack is currently just a NAS folder. `git init` in the new `/mnt/nas/stacks//`, push to `Charlie/`. Recipe: [migration.md](migration.md#init). ### `rewrite` Code we author, currently a clone of a github.com repo. Move to `/mnt/nas/stacks//`, `git remote set-url origin` to Charlie. Recipe: [migration.md](migration.md#rewrite). ### `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/-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// ├── docker-compose.yml # the "what" — default, used by hosts without an override ├── docker-compose..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) │ └── .yml ├── deploy.sh # picks env + compose file based on `hostname -s` └── README.md ``` ### Resolution order in `deploy.sh` (per host) 1. If `docker-compose..yml` exists → use **only** that (full per-host compose). 2. Else `docker-compose.yml` + (if present) `overrides/.yml` layered on top. 3. If `envs/.env` exists → loaded with `--env-file` in 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/.env` | | Add an extra service or scalar field | `overrides/.yml` (compose merges additively) | | **Replace** a list (volumes, command, networks) | `docker-compose..yml` (full file) | | Different image | `docker-compose..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` ```bash #!/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/.env`. - Per-host structural deltas = `overrides/.yml` for additive, `docker-compose..yml` for 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: ```yaml strategy: matrix: host: [morgott, melina, mohg, miquella] runs-on: [self-hosted, "${{ matrix.host }}"] steps: - run: cd /mnt/nas/stacks/ && ./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.