This is Part 4 of the Building a Homelab with AI series. Previously: Ansible Bootstrap | Next: Caddy DMZ Migration
What GitOps Means for a Homelab
GitOps is a simple idea: your Git repository is the source of truth, and your infrastructure watches the repo and reconciles itself to match. In the enterprise world, this usually means ArgoCD or Flux watching a Kubernetes cluster. In my homelab, it means Portainer watching a GitHub repo for Docker Compose changes.
The workflow becomes: edit a compose file, commit, push, and within five minutes Portainer pulls the change and redeploys the stack. No SSH, no clicking through UIs, no “did I remember to restart the container?”
Setting It Up in Portainer
Portainer’s Git-based stack deployment is straightforward. For each stack, you configure:
- Repository URL: your GitHub repo URL
- Repository reference:
refs/heads/main - Compose path: e.g.,
stacks/openwebui/docker-compose.yml - Authentication: GitHub username and a Personal Access Token (PAT)
- Automatic updates: Enable polling, set interval to 5 minutes
That’s it. Portainer clones the repo, finds the compose file at the specified path, and deploys it. When the next poll detects a new commit that changes that file, it redeploys.
I have two stacks managed this way:
- Open WebUI:
stacks/openwebui/docker-compose.yml— an AI chat interface running on port 3000 - Caddy:
stacks/caddy/docker-compose.yml— the reverse proxy (later migrated to a dedicated VM)
The Repo Structure Makes This Clean
The key insight from the repo design is that stacks/ exists purely for Portainer. Each subdirectory is a self-contained stack with its own docker-compose.yml and any supporting files (like a Caddyfile or .env.example). Portainer doesn’t need to know about the Ansible playbooks or documentation — it just watches its compose path and ignores everything else.
stacks/
├── caddy/
│ ├── docker-compose.yml
│ ├── Caddyfile
│ └── .env.example
├── openwebui/
│ └── docker-compose.yml
└── portainer/
└── docker-compose.yml
The Portainer Paradox
There’s one stack that can’t be managed via GitOps: Portainer itself. If Portainer redeploys its own container, the redeployment process dies mid-execution. It’s a restart loop waiting to happen.
So Portainer runs from a compose file on docker-host directly (/home/<user>/docker-homelab/portainer/docker-compose.yml) and is managed with plain docker compose commands. The compose file is still in the repo for documentation purposes, but Portainer doesn’t watch it. This is an acceptable trade-off — Portainer’s config rarely changes, and when it does, a manual docker compose up -d on docker-host takes ten seconds.
The Portainer API
Beyond the UI, Portainer exposes a REST API that turned out to be really useful during the Caddy DMZ migration. When I needed to programmatically delete the old Docker-based Caddy stack, I used:
source ~/.portainer.env
# List stacks
curl -sk "$PORTAINER_URL/api/stacks" \
-H "X-API-Key: $PORTAINER_API_KEY" | jq '.[].Name'
# Delete a stack by ID
curl -sk -X DELETE \
"$PORTAINER_URL/api/stacks/4?endpointId=3" \
-H "X-API-Key: $PORTAINER_API_KEY"
The API key is stored in ~/.portainer.env (mode 600) on the dev machine. Having programmatic access to Portainer means stack lifecycle management can be scripted and integrated into larger automation workflows.
The Day-to-Day Workflow
Here’s what a typical change looks like now:
- Edit a compose file on
devin the homelab repo - Test the syntax:
docker compose -f stacks/foo/docker-compose.yml config git add,git commitwith a descriptive messagegit push- Wait up to 5 minutes (or trigger a manual redeploy in Portainer)
- Verify the service is running
It’s not instant like a Kubernetes operator, but for a homelab, a 5-minute polling interval is perfectly fine. The important thing is that the change is in Git, the deployment is automatic, and I never had to SSH into the Docker host.
What GitOps Doesn’t Cover
GitOps handles the “what should be running” question, but not “how should the host be configured.” That’s still Ansible’s job — as covered in the bootstrap post. And some changes, like moving Caddy out of Docker entirely and into a dedicated DMZ VM, require tearing down the GitOps stack and replacing it with something different. GitOps is a tool, not a religion.
Next up: the biggest architectural change of the project — moving the reverse proxy to an isolated DMZ.