GitOps Patterns Without Kubernetes
Concrete, copyable GitOps patterns for servers, homelabs, and personal machines — no cluster required. Nine patterns, from one-server scripts to fleet tooling. Each pattern follows the same shape: the idea, the repo layout, the loop that applies it, and the honest limits. For the concepts behind them, see GitOps Without Kubernetes.
1. Ansible-pull fleet (the workhorse)
The canonical non-K8s GitOps setup. A repo holds Ansible playbooks describing every machine; each machine periodically pulls and applies them to itself.
Repo layout:
inventory/
homelab.ini # which machines exist, in groups
playbooks/
base.yml # every machine: users, ssh, firewall, updates
web.yml # web servers: nginx + config
roles/
...
The loop on each machine — a systemd timer, not cron, so failures get logged properly:
# /etc/systemd/system/ansible-pull.service
[Service]
Type=oneshot
ExecStart=/usr/bin/ansible-pull -U https://github.com/you/fleet.git \
-i inventory/homelab.ini playbooks/base.yml
# /etc/systemd/system/ansible-pull.timer
[Timer]
OnCalendar=hourly
Persistent=true
systemctl enable --now ansible-pull.timer
Check: journalctl -u ansible-pull shows the last run; re-running
a converged machine reports changed=0.
Reconciliation: hourly, automatic. Drift gets overwritten — which is the point, so make sure the team knows hand-edits don't survive.
Honest limits: Ansible is push-oriented by nature; ansible-pull
flips it to pull, but there's no live drift dashboard — you read the
logs. Secrets need Ansible Vault or SOPS (see
Secrets and Safety).
2. Cron + idempotent apply script (one server)
The smallest real GitOps loop. A repo holds configs and a script that applies them; a timer pulls and runs it.
# /usr/local/bin/sync.sh, run every 10 minutes by systemd timer
set -e
cd /srv/desired-state
git pull --quiet --ff-only
cp -r nginx/* /etc/nginx/
cp -r sshd/* /etc/ssh/sshd_config.d/
systemctl reload nginx
systemctl reload sshd
The key word is idempotent: running the script twice must be safe. Copying files and reloading services qualifies; appending lines to a file does not. Write every step so re-running is harmless.
Honest limits: no diffing, no reporting — you only know it worked because nothing broke. Fine for one server you also watch; not for a fleet.
3. etckeeper (track /etc in git)
etckeeper puts /etc under
version control automatically, committing before and after every
package operation. Hand-edits show up as uncommitted changes.
sudo apt install etckeeper # then it just works
cd /etc && git log --oneline | head
Check: edit a config by hand, then cd /etc && git status shows
exactly what you changed.
Honest limits: this is principles 1–2 (declarative-ish, versioned) without 3–4 — nothing applies or reconciles. It's audit trail, not GitOps proper. Still valuable: "who changed the firewall rule?" becomes answerable. Pair it with pattern 1 or 2 for the full loop.
4. Dotfiles with chezmoi (personal machines)
GitOps for your own configuration. chezmoi keeps dotfiles in git and applies them per machine, with templates for differences:
chezmoi init --apply you/dotfiles # new machine, one command
Per-machine differences live in templates:
# .chezmoi.toml.tmpl
[vars]
is_work = {{ eq .chezmoi.hostname "work-laptop" }}
Change a setting, commit, push; each machine chezmoi updates on its
own schedule (or a timer). New laptop setup goes from an afternoon to
minutes.
Honest limits: personal scope — no one else's machine should pull your dotfiles repo. Secrets in dotfiles need chezmoi's template + password-manager integration, never plaintext.
5. Compose-from-git timer (docker homelab)
For the docker-compose homelab: service definitions live in git; a timer pulls and reconciles.
# /usr/local/bin/compose-sync.sh, every 5 minutes
set -e
cd /srv/apps
git pull --quiet --ff-only
docker compose up -d --remove-orphans
docker compose up -d is idempotent — already-correct services are
left alone, changed ones recreated. Image updates: add docker compose
pull before the up, on whatever cadence you want (nightly, not every
5 minutes).
Honest limits: no health-gated rollouts; a bad compose file takes the service down until you revert. Keep the timer interval sane and the repo's main branch protected.
6. Check-mode drift reporter (alert-only reconciliation)
Sometimes you don't want auto-heal — during migrations, or on machines where surprises are worse than drift. Run the same Ansible playbooks in check mode and report instead of applying:
ansible-playbook --check --diff playbooks/base.yml > /tmp/drift.txt
if grep -q "changed=" /tmp/drift.txt; then
curl -d "drift detected on $(hostname)" https://ntfy.sh/your-topic
fi
You get GitOps principles 1–3 (declarative, versioned, pulled) with reconciliation as notification rather than correction. A human decides whether the drift is a problem or a pending change.
This pairs well with pattern 1: report on a short interval, auto-apply on a longer one, or auto-apply everywhere except the machines you flag.
7. Terraform provisions, GitOps configures (the split)
A common confusion: Terraform in git is not GitOps by itself (no reconciliation loop — it's push-based). The pattern that works:
- Terraform/OpenTofu provisions the existence of things: VMs, networks, DNS records. Run from CI on merge (plan on PR, apply on merge to main).
- Ansible-pull / agent configures what's on them, on its own loop.
The repo boundary matters: infrastructure repo (Terraform) vs. configuration repo (Ansible), or clearly separated folders. Don't mix "create the VM" and "install nginx" in one apply — they fail differently and roll back differently.
Honest limits: Terraform state files are sensitive and finicky — remote state with locking (S3, Terraform Cloud, etc.), never local state in a shared setup.
8. PowerShell scheduled sync (Windows)
Windows machines get the same loop with native tooling: a repo holds
an idempotent Apply.ps1; a Scheduled Task pulls and runs it.
# Apply.ps1 — idempotent: safe to re-run
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
git pull --quiet --ff-only
# ensure a Windows feature is present
if (-not (Get-WindowsFeature Web-Server).Installed) {
Install-WindowsFeature Web-Server
}
# ensure config file matches the repo
$desired = Get-Content .\nginx\nginx.conf -Raw
$current = Get-Content C:\nginx\conf\nginx.conf -Raw
if ($desired -ne $current) {
Copy-Item .\nginx\nginx.conf C:\nginx\conf\nginx.conf -Force
Restart-Service nginx
}
Register the loop as a scheduled task:
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-ExecutionPolicy Bypass -File C:\desired-state\Apply.ps1'
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 15)
Register-ScheduledTask -TaskName 'DesiredStateSync' `
-Action $action -Trigger $trigger -RunLevel Highest
Check: Get-ScheduledTaskInfo DesiredStateSync shows the last
run; the Task Scheduler history shows each execution.
Honest limits: hand-rolled like pattern 2 — no built-in diff view or drift dashboard. When the scripts outgrow you, PowerShell DSC (Desired State Configuration, v3 is cross-platform) is the native declarative step up: you write what, DSC handles how.
9. Python converge script (cross-platform agent)
When the logic outgrows shell — conditions, data files, error handling — a small Python agent is the mere-mortals answer. One language runs the loop on Linux (systemd timer) and Windows (Scheduled Task).
desired.yaml in the repo — the declarative part:
packages:
- nginx
- fail2ban
files:
- src: nginx/nginx.conf
dest: /etc/nginx/nginx.conf
reload: nginx
converge.py — the agent part:
import subprocess, sys
import yaml # pip install pyyaml
state = yaml.safe_load(open("desired.yaml"))
changed = False
for pkg in state.get("packages", []):
r = subprocess.run(["dpkg", "-s", pkg], capture_output=True)
if r.returncode != 0:
print(f"installing {pkg}")
subprocess.run(["apt-get", "install", "-y", pkg], check=True)
changed = True
for f in state.get("files", []):
want = open(f["src"]).read()
try:
have = open(f["dest"]).read()
except FileNotFoundError:
have = None
if want != have:
print(f"updating {f['dest']}")
open(f["dest"], "w").write(want)
subprocess.run(["systemctl", "reload", f["reload"]], check=True)
changed = True
print("changed" if changed else "already converged")
sys.exit(0)
The timer does git pull then runs the script. Because the desired
state is data (YAML) and the agent is code, you can unit-test the
convergence logic — something shell scripts rarely get.
Honest limits: you're now maintaining software, with its bugs and dependencies. Keep the agent small and boring; the moment it grows a plugin system, you've reinvented Ansible badly — just use Ansible.
Is your pattern GitOps? (the checklist)
Run any setup against the four principles:
| Principle | Question |
|---|---|
| Declarative | Is the desired state written down, not a runbook? |
| Versioned | Is it in git, with history and review? |
| Pulled automatically | Does software (not a human) apply it? |
| Reconciled | Does something keep checking, or at least report? |
Three out of four is still useful automation — just name it honestly. The checklist is for evaluating, not gatekeeping.
Related: GitOps Without Kubernetes, Tools Overview, Secrets and Safety.