GitOps without Komodo: Webhook-based Auto-Deployment for Docker Compose

GitOps without Komodo: Webhook-based Auto-Deployment for Docker Compose

If you’ve read my previous post on my 2025 homelab setup, you know I’ve been using Komodo as my GitOps tool for Docker Compose stacks. The concept is solid: Git as a single source of truth, Renovate for automated version updates, and Komodo deploying changes to the target system on every new commit. It mostly works – but Komodo has been driving me up the wall with one thing: file permissions. It simply doesn’t work consistently. Either the permissions on the host are off, or Komodo complains during deployment, or something in between. After the nth time dealing with it, I’d had enough and wanted something leaner.

The requirement is straightforward: when Renovate pushes a new commit to the repo (after I merge the pull request), the compose file on the server should be updated and the affected containers restarted. No Kubernetes, no additional framework. Just git pull and docker compose up.

The Basic Concept

The setup relies on three components working together:

  1. Gitea – my self-hosted Git server where the compose files live
  2. Renovate – checks the image tags in the compose files against the respective container registries every four hours via cronjob, and automatically opens pull requests when newer versions are available. I merge the PR, the commit lands on main.
  3. webhook – a single Go binary that exposes an HTTP endpoint on the target server. Gitea fires a webhook on every push, which triggers the deploy script.

The data flow looks like this:

Renovate detects new image tag
  → PR in Gitea
  → Merge
  → Gitea fires webhook
  → webhook binary calls deploy.sh
  → git pull + docker compose up -d

No polling, no daemon watching container registries, no framework with its own opinions about file permissions.

Why Not Just Watchtower or a Cron Script?

Before reaching for webhook, I briefly considered two other approaches.

Watchtower monitors running containers and pulls new images automatically. The problem: Watchtower reacts to new images in the registry, not to Git commits. When Renovate opens a PR and I merge it, Watchtower has no idea – it just pulls whenever it decides to check. That doesn’t fit a setup where Git is supposed to be the single source of truth. I also want to control the merge timing, not Watchtower.

A cron script with git fetch would be the other option – check for changes every few minutes and deploy if there are any. It works, but has a conceptual drawback: it’s polling. I already have Gitea infrastructure that can deliver push events, so I should use it. A webhook reacts within seconds; a cron job with a 5-minute interval adds unnecessary delay.

webhook is the cleanest approach: event-driven, no extra daemon, a single binary with no dependencies.

Repo Structure

I have one repository per server:

  • apps-stack for docker01 – running tools like Paperless-ngx, Immich, Vaultwarden, and similar
  • media-stack for media01 – all the *arr containers and everything else in the media stack

Each repo just contains the compose.yaml and any additional config files individual applications need. Nothing special.

Renovate in the Repos

Renovate runs self-hosted and checks the image tags in the compose files every four hours via cronjob. A minimal renovate.json in the repo root is all it takes:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:base"],
  "docker-compose": {
    "enabled": true
  }
}

Renovate automatically detects image tags in the compose file and opens PRs when a newer version is available. For software that uses semantic versioning (major.minor.patch), this can be fine-tuned – for example, automatically merging patch updates while reviewing minor updates manually.

I exclude Postgres containers from Renovate. Major upgrades between Postgres versions require a manual database dump and restore – not something I want a bot to trigger automatically.

Installing and Configuring webhook

webhook is available as a single binary, or as a package:

apt install webhook

The configuration lives at /etc/webhook/webhook.conf:

- id: deploy
  execute-command: /opt/scripts/deploy.sh
  command-working-directory: /opt/media-stack
  trigger-rule:
    match:
      type: payload-hmac-sha256
      secret: "{{getenv \"WEBHOOK_SECRET\"}}"
      parameter:
        source: header
        name: X-Gitea-Signature

A few notes on this:

Header name: Gitea sends the signature as X-Gitea-Signature – without a -256 suffix and without a sha256= prefix in the value. Sounds trivial, but it cost me a debugging session on the first try.

Secret as environment variable: The HMAC secret doesn’t belong in the config file in plaintext, especially if the file is versioned in the repo. So it’s read from the environment via getenv.

Generate the secret:

openssl rand -hex 32

Write it to a secrets file that only root can read:

# /etc/webhook/secrets.env
WEBHOOK_SECRET=your-generated-string
chmod 600 /etc/webhook/secrets.env

Systemd Unit

# /etc/systemd/system/webhook.service
[Unit]
Description=Webhook Receiver
After=network.target

[Service]
User=teqqy  # Replace with your own username
EnvironmentFile=/etc/webhook/secrets.env
ExecStart=/usr/bin/webhook -hooks /etc/webhook/webhook.conf -port 9000
Restart=on-failure

[Install]
WantedBy=multi-user.target

User=teqqy ensures the webhook daemon and the deploy script don’t run as root. That’s important to me – processes that only need to run git pull and docker compose up have no business running as root. Replace the username with your own; the user needs access to the repo directory and must be a member of the docker group.

EnvironmentFile loads secrets.env and makes WEBHOOK_SECRET available as an environment variable before the process starts – so the secret flows cleanly into the getenv call in the config. Restart=on-failure ensures the daemon automatically restarts after an unexpected crash without manual intervention.

systemctl enable --now webhook

Setting Up the Gitea Webhook

In Gitea under Repository → Settings → Webhooks → Add Webhook → Gitea:

  • Target URL: http://media01.example.com:9000/hooks/deploy
  • Secret: the same string you generated above
  • Trigger: Only Push events are needed

On every push, Gitea automatically computes an HMAC-SHA256 signature over the request body and sends it as the X-Gitea-Signature header. The webhook binary verifies the signature before executing the script – a simple Authorization header would be significantly less secure since it doesn’t protect the payload against tampering.

Port 9000 shouldn’t be exposed directly to the internet. In my setup, Gitea and the webhook daemon are on the same internal network and the port isn’t reachable from outside. Anyone who needs to expose the endpoint for an external Gitea instance or GitHub should at minimum put it behind a reverse proxy with IP restrictions.

The Deploy Script

#!/bin/bash
set -euo pipefail

REPO=/opt/media-stack
LOG=/opt/deploy-logs/deploy-$(date +%Y%m%d-%H%M%S).log

exec >> "$LOG" 2>&1

echo "=== Deploy $(date) ==="

cd "$REPO"
git pull origin main
docker compose up -d --remove-orphans
docker compose ps

echo "=== Done ==="

set -euo pipefail ensures the script immediately aborts on any error and returns a non-zero exit code. webhook logs that as well, so journalctl -u webhook is the first place to look when something goes wrong.

The log directory needs to be owned by the executing user:

mkdir -p /opt/deploy-logs
chown teqqy:teqqy /opt/deploy-logs

Pitfalls I Hit Along the Way

.git directory ownership: If the repo was ever cloned or pulled as a different user, Git refuses access with a warning about “dubious ownership”. Fix:

chown -R teqqy:teqqy /opt/media-stack/.git

safe.directory: In some setups this helps additionally:

git config --global --add safe.directory /opt/media-stack

/var/log permissions: The executing user doesn’t have write access there. Put the log directory somewhere the user owns – I use /opt/deploy-logs.

*arr containers and UID/GID: My *arr containers run as 99:100 (Unraid-compatible for NFS reasons). On the very first deployment, Docker creates bind mount directories with 1000:1000 if they don’t exist yet. One-time fix before the first start:

chown -R 99:100 /mnt/data/arr/

After that it’s a non-issue since the directories already exist.

Monitoring

I use Uptime Kuma to check whether containers are still running cleanly after a deployment. That’s enough for my use case: if a container stops responding after a failed update, Uptime Kuma alerts me. The deploy log and journalctl -u webhook then provide the details on what went wrong.

A dead man’s switch (e.g. via Healthchecks.io) would be the next sensible step if you also want to be notified when a deploy simply doesn’t happen – but for a media stack that’s not critical enough for me to add the overhead.

Conclusion

The setup has been running for a little while now and does exactly what it’s supposed to – without me having to debug anything in Komodo. The webhook binary is stable, the deployment flow is deterministic, and the total overhead is a handful of config files and a shell script.

For anyone with similar requirements who doesn’t want to run Kubernetes: this is, in my opinion, the most pragmatic approach. No magic, no frameworks, no overengineering. Just git pull and docker compose up.

If you have questions or feedback, reach me through the links in the menu bar.