Skip to content

GitOps with Forgejo, Komodo and Renovate

Every container on every server is defined by a compose.yaml in one git repository. Git is the source of truth: I edit a file, push to main, and only the stacks whose files changed get redeployed. Nobody runs docker compose up on a server by hand (with one exception, Komodo itself).

This section describes how that is put together, why each piece is there, and how to build it again from nothing.


The pieces

Component What it does Runs where
Forgejo Self-hosted git server. Holds the infra repo, sends a webhook on every push, hosts Renovate's PRs and the issue tracker. control-host, as a Komodo stack
Komodo Core + MongoDB Control plane: knows every server and stack, runs the deploy procedure, sends alerts. control-host, compose applied by hand
Komodo Periphery Agent on each server. Pulls the repo and runs docker compose when Core tells it to. container on control-host, systemd binary on server1, server2, workstation
Renovate Watches every pinned image tag and opens a PR when a new version exists. Safe updates merge themselves. control-host, as a Komodo stack
ntfy Push notifications: failed deploys, down services, new PRs, finished deploys. control-host

Related pages: Secrets, Repo layout, Rebuild from scratch, Gotchas.


Architecture

flowchart LR
    ws["workstation<br/>(edit + git push)"]
    subgraph ch["control-host"]
        fj["Forgejo<br/>infra repo"]
        core["Komodo Core"]
        db[("MongoDB")]
        pch["Periphery<br/>(container)"]
        ren["Renovate"]
        ntfy["ntfy"]
    end
    subgraph s1["server1"]
        p1["Periphery<br/>(systemd)"]
    end
    subgraph s2["server2"]
        p2["Periphery<br/>(systemd)"]
    end

    ws -- "push main" --> fj
    ren -- "PRs, automerge" --> fj
    fj -- "push webhook" --> core
    core --- db
    core -- "deploy if changed" --> pch & p1 & p2
    pch & p1 & p2 -- "git pull" --> fj
    core -- "alerts" --> ntfy
    fj -- "push / PR hooks" --> ntfy

Periphery runs in outbound mode: each agent opens the connection to Core, so the servers don't need an inbound port for Komodo.


How a change reaches a server

sequenceDiagram
    autonumber
    participant Me as workstation
    participant FJ as Forgejo
    participant KC as Komodo Core
    participant P as Periphery (target host)
    participant N as ntfy
    Me->>FJ: git push (main)
    FJ->>N: "pushed to main" (updates topic)
    FJ->>KC: POST /listener/github/procedure/.../main
    KC->>KC: Procedure "Redeploy Changed Stacks"<br/>BatchDeployStackIfChanged("*")
    KC->>P: deploy stacks whose files changed
    P->>FJ: git pull (one shared clone per host)
    P->>P: write .env, docker compose up -d
    KC->>N: deployed / failed (updates or alerts)
  1. Edit hosts/<host>/<stack>/compose.yaml (or a config file the stack tracks) and push to main.
  2. Forgejo calls Komodo's webhook for the procedure Redeploy Changed Stacks.
  3. The procedure runs BatchDeployStackIfChanged over every stack. Komodo compares each stack's files with what it last deployed and only redeploys the ones that differ. Unchanged stacks are not touched.
  4. The same procedure also runs every 15 minutes on a schedule. If a deploy failed for a passing reason (a registry rate limit, a DNS blip, a dependency that came up slowly), the next run retries it with no push needed.
  5. Failures go to the ntfy alerts topic. Deploys and PRs go to updates.

Rollback is git revert <commit> and push. The same flow deploys the old version.

Why a procedure and not per-stack webhooks

Komodo can also give every stack its own webhook. With 20+ stacks in one repo that means 20+ webhooks firing on every push. One procedure webhook plus "deploy if changed" does the same with one hook, and the schedule turns it into a retry loop for free.


Image updates

Images are pinned to exact versions (image: ghcr.io/foo/bar:1.4.2, never :latest). Renovate opens a PR per update. Patch and minor updates merge themselves in the daytime. Majors, databases and anything that can lock you out wait for review. Merging is just another push to main, so the deploy flow above takes over. Details: Renovate.

Komodo's own image polling stays on (poll_for_updates) only to show "update available" in the UI. Komodo never updates an image by itself (auto_update = false), because then git would no longer describe what is running.


What lives outside this flow

Thing How it is managed Why
Komodo Core / MongoDB / control-host Periphery Compose in git, copied to the host and applied by hand Komodo can't redeploy the thing running the deploy. See Komodo.
Host-level config (unattended-upgrades, smartd, disk-space checks, notify script) Files in hosts/_fleet/, installed with an idempotent script over SSH Not containers. See Repo layout.
Secrets Komodo secret Variables, never git See Secrets.
The workstation's own stacks Run by hand, ignored by Renovate A desktop shouldn't redeploy itself while you're using it.

Sources & further reading