This is Part 3 of a 3-part series: “GitOps for Homeservers”

Introduction

In Part 1, I covered my homeserver setup and the Ansible-based deployment workflow along with its pain points. In Part 2, I evaluated alternatives like Komodo and Dockhand — solid tools, but none fully fit my needs.

So I decided to build my own tool: ComposeFlux.

The name is inspired by Flux CD — the GitOps toolkit for Kubernetes. ComposeFlux does the same thing, but for Docker Compose.

This post covers the features and design decisions behind ComposeFlux. For internals and how it works under the hood, check out the documentation.

Goals

Based on the problems I faced with Ansible and the gaps I found in other tools, here are the goals I set while creating ComposeFlux:

  • True GitOps — Push to Git, deployment happens automatically
  • Lightweight — No backend database
  • Minimal and flexible config — Don’t make me define apps in two places
  • External secrets provider support — Bitwarden, Infisical
  • No UI — It would be nice to have a UI, but I have an SRE/DevOps background and I’m not a frontend developer
  • Pure Go implementation — I don’t like running shell commands internally
  • Bonus: Smart Change Detection — Detect which stack changed in Git and deploy only that stack, instead of running docker compose up blindly on everything like the Ansible playbook did

Let me walk through some of these features in detail.

Pure Go Implementation

This was made possible by Docker Compose version v5.0.0. Right when I wanted to start the project, they released the Compose SDK, which made the implementation clean and straightforward.

That said, the SDK was still in its initial release. I had to make some workarounds — particularly around logging — with help from AI coding tools. You can see the wrapper package here. In the end, it worked out well.

To run docker compose up/down, etc., I just need to load the project with:

  • Docker Compose files (including override files)
  • Working directory
  • Environmental variables

I tried using the “auto discover” functionality from the SDK — just set the working directory and let it discover all docker compose files (like docker compose --project-directory /path/working/dir up in the CLI). But during testing, I found that it picks up the parent directory’s docker compose file if it can’t find one in the current directory. I had to do some extra work to identify the correct compose files. See internal/reconcile/utils.go#L68 for the implementation.

Secrets Management

I initially wanted to support only Bitwarden Secrets Manager. Building a standalone binary was tricky because I had to enable CGO (see INSTRUCTIONS.md in the Bitwarden Go SDK). Later I found Infisical — open source, has a Go SDK, and a nice UI.

The way secrets management works in ComposeFlux:

  1. Secrets are exported as environmental variables while running docker compose up via the SDK
  2. When a change is detected in Git, ComposeFlux pulls updates from Git and fetches all secrets from the secrets manager
  3. Secrets are stored in an in-memory cache
  4. During LoadProject, the env vars are added to the project
  5. Once the stack is up, the cache is cleared

During the initial implementation, I kept secrets in the in-memory cache indefinitely and updated them regularly. Later I changed it to clear after each deployment for better security.

Flexible Configuration

Imagine you have docker compose files for apps in dedicated folders like this:

tree servers/helium/
servers/helium/
├── gocron
│   └── compose.yml
├── immich
│   └── compose.yml
├── squoosh
│   └── compose.yml
├── stack.yml  # <--- Config file
├── traefik
│   └── compose.yml
└── zeroclaw
    └── compose.yml

You might have common environmental variables across apps. You can put those in the config file. You can also set a startup order — for example, Traefik should be deployed first so it’s ready to create certs for apps deployed after it.

The config looks like this:

---
startup_order:
  - traefik

envs:
  PUID: 1000
  PGID: 1000

Note that you don’t have to specify the full order — just the apps that need to come first. The rest are deployed in any order.

Smart Change Detection

Instead of blindly running docker compose up on every folder when changes are detected in Git, ComposeFlux detects changes based on the hash of the compose file. During deployment, it stores a SHA256 hash in container labels:

$ docker inspect homer
[
    {
...
            "Labels": {
                "composeflux.deployed-at": "2026-04-29T22:40:16+02:00",
                "composeflux.managed": "true",
                "composeflux.stack-hash": "sha256:59c3b691b95dc3eed688871b4430520844983f22ea5662a50a857dcc64d1aff3",
                "composeflux.version": "v0.9.1-6-g66b906c",
            }
        },
...
]

When the reconciler polls for Git updates and finds changes, it computes the SHA256 of each stack and compares it with the running stack’s composeflux.stack-hash label. If there’s a mismatch, ComposeFlux runs docker compose up on that stack. This avoids unnecessary redeployments and reduces load on the Docker daemon.

NOTE: The hash-based detection only works for changes in the docker compose file itself, not for arbitrary files in the repo. Keep reading for a workaround.

Docker Compose Configs

This is where Docker Compose Configs become really useful. If you keep an app’s configuration in a separate file and mount it into the container, ComposeFlux’s hash-based detection won’t catch changes to that file:

# Example: https://github.com/veerendra2/wireguard-traefik-authelia
tree .
.
├── compose.yml
├── config  # <-- Changes here won't be detected by hash-based detection
│   ├── configuration.yml
│   └── users_database.yml
└── README.md

cat compose.yml
---
...
  authelia:
...
    volumes:
      - authelia_data:/opt
      - ./config:/config  # <-- Mounted config files, changes not detected
      - /etc/localtime:/etc/localtime:ro
      - /etc/timezone:/etc/timezone:ro
...

To make config changes detectable, you can use Docker Compose Configs instead of volume mounts:

configs:
  users_database.yml:
    name: users_database.yml
    content: |
      users:
        authelia:
          disabled: true
          displayname: "Test User"
          password: "DUMMY"
          email: authelia@authelia.com
name: authelia
services:
  authelia:
    configs:
      - mode: "0444"
        source: users_database.yml
        target: /config/users_database.yml
    container_name: authelia

This way, all configuration lives inside the docker compose file, and any change will be picked up by ComposeFlux’s hash-based detection.

Tip: You don’t have to keep everything in one file. You can split the configs and use Docker Compose’s include:

# compose.config.yml
configs:
  users_database.yml:
    name: users_database.yml
    content: |
      users:
        authelia:
          disabled: true
          displayname: "Test User"
          password: "DUMMY"
          email: authelia@authelia.com
# compose.yml
name: authelia
include:
  - compose.config.yml
services:
  authelia:
    configs:
      - source: users_database.yml
        target: /config/users_database.yml
        mode: "0444"

Automatic Image Updates

This feature was added recently. If you’ve used Watchtower (now archived) or any of its fork projects to automatically update Docker images, you might have run into a nasty issue — when Watchtower updates a container that was deployed with Docker Compose Configs, it recreates the container without applying those configs. The config-injected files simply disappear, and the container breaks. You then have to manually run docker compose up to fix it. There’s an open issue that describes this problem — I ran into the same thing myself.

Since I heavily use Docker Compose Configs (as described above), Watchtower wasn’t an option. So I added automatic image updates directly into ComposeFlux. It’s straightforward — a cron job checks the container registry for newer images, and if found, runs docker compose pull followed by docker compose up via the SDK. Because ComposeFlux manages the full compose lifecycle, configs are always applied correctly during updates.

Simple and Headless

This can be a drawback too, depending on how you look at it 🙃

A UI gives a nice feel and visibility — basic metrics, stack health, editing or creating stacks. But these can be covered by dedicated monitoring systems like Prometheus and Grafana (which I already use). And for editing and creating stacks — remember, the goal is GitOps? 😸

I prefer not to run a backend database on my homeservers. I like things lightweight (SQLite is fine!). Having a database gives state to stack management, but if all the necessary configuration is already in the docker compose file, why not just run docker compose up? You have the history in Git.


Also read: How I Manage My Homeservers with GitOps and Docker Compose — a showcase of my current workflow using ComposeFlux.