Skip to main content
  1. Posts/
  2. Self-Hosting/

GitOps for Homelab: Deploying Forgejo and Komodo From Scratch

·5872 words·28 mins· loading · loading · ·
Stilicho2011
Author
Stilicho2011
Writing about homelab, self-hosting, automation and open-source solutions
Table of Contents
Self-Hosting - This article is part of a series.
Part : This Article

Sooner or later, any home server running a dozen Docker containers turns into a pile of compose files sitting right on the host, edited by hand over SSH with nano or through VSCode Remote. It works - but there’s a problem: if something breaks after an edit, it’s unclear what actually changed or how to get back to a working state. And if you have a lot of stacks and made ten edits over the past month, reconstructing that history from memory is basically hopeless.

This article walks through solving that problem with the GitOps approach - using Forgejo (your own git server) and Komodo (a Docker stack management panel) as the example pairing. We’ll set everything up from scratch, going through every step and every config parameter - enough to not just copy commands, but actually understand what they do and why.

If you already have Komodo up and running - say, you recently moved to it from Portainer, which I covered separately in Moving from Portainer to Komodo: Full Installation and Setup - you can skip that part and jump straight to the Forgejo step below. And if what you actually want isn’t an abstract from-scratch tutorial but a look at how this exact setup runs on my own real infrastructure - real domains, existing stacks migrated into git, a webhook that’s already live in production - that’s a separate article: Forgejo + Komodo: Turning Docker Compose into GitOps.

Note

This article is aimed at people who haven’t worked with GitOps before, and/or haven’t used Forgejo and Komodo individually. Basic knowledge of Docker and Docker Compose is assumed.

What Is GitOps and Why Bother With It in a Homelab
#

The classic way of managing Docker stacks looks like this:

 flowchart LR

```
A[Edit docker-compose.yml on the server] --> B[docker compose up -d]
B --> C{Something broke?}
C -->|Yes| D[Try to remember what you changed]
C -->|No| E[It works, until you forget]
```

The problem is that the server itself is not a source of truth. The history of changes only lives in your head (or doesn’t live anywhere at all). If you edited a config at 11 PM after a long workday, and something breaks a week later, the odds of remembering the exact cause are close to zero.

GitOps flips this around: the git repository becomes the single source of truth. All the compose files live in it, and on the server there’s a mechanism that:

  1. Gets notified about a new commit (via webhook), or periodically checks the repository itself;
  2. Pulls the changes (git pull);
  3. Applies them - recreating the relevant containers (docker compose up -d).
 flowchart LR

```
A[Edit the compose file locally] --> B[git commit + push]
B --> C[The repository receives a webhook]
C --> D[Komodo does a Pull + Deploy]
D --> E[Containers updated]
```

An important nuance: the server itself never decides what should be deployed. It just syncs its state with what’s recorded in git. If the file in the repository hasn’t changed, nothing happens - even if you manually trigger the deploy procedure again.

What this gets you in practice:

  • Change history. git log shows who changed what and when, and git diff shows exactly what changed between any two versions.
  • One-step rollback. Made a mistake - git revert, push, and the old config version is deployed again, with no manual file editing on the server.
  • A single source of truth. No need to remember which host has the current version of a file - it’s always right there, in the repository.
  • Reproducibility. If the server ever needs to be rebuilt from scratch (a failure, moving to new hardware), the entire set of configs is already described in git - no need to recall what was configured and how.

For a homelab with a single server, the benefit isn’t as obvious as it is in production with a team of ten engineers - but the habit sticks fast, and being able to roll back a failed experiment with a single command is genuinely convenient, especially if, like many people running home infrastructure, you tinker with configs regularly.

What You’ll Need
#

  • A host with Docker and Docker Compose already installed (setting up Docker itself is a whole separate topic - we’re assuming it’s already there);
  • some free disk space - Forgejo and Komodo are lightweight services on their own, most of the space will go to MongoDB data and git repository history;
  • a browser for the initial setup through the web UI;
  • (optional, but recommended) an already-configured reverse proxy like Traefik, if you want to put services on their own domains instead of bare ports - the article includes an example with one.

For Forgejo’s database we’ll use the built-in SQLite - more than enough for home use, and it lets us skip setting up an external PostgreSQL right at the start. Komodo Core needs MongoDB - it’s the only officially supported database for Core - but it comes up with the same docker compose up -d, no separate setup required.

Step 1. Setting Up Forgejo - Your Own Git Server
#

Forgejo is a fork of Gitea, a lightweight self-hosted git server with an interface similar to GitHub: repositories, issues, pull requests, Actions (CI/CD), a built-in web file editor. For our purposes we only need the basic functionality - storing a repository and webhooks.

Create a project folder, e.g. /home/stilicho/docker/forgejo, and a docker-compose.yml file inside it:

services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:15
    container_name: forgejo
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=sqlite3
      - FORGEJO__webhook__ALLOWED_HOST_LIST=external,private
    restart: unless-stopped
    volumes:
      - ./forgejo-data:/data
    ports:
      - "3000:3000"
      - "2222:22"

Let’s go through each parameter:

  • USER_UID / USER_GID - which user Forgejo runs as inside the container, and consequently, which owner gets assigned to files created in the mounted ./forgejo-data volume. If your main user on the host has a different UID (check with the id command), it’s better to plug in that value - this avoids permission headaches during backups or manual file edits inside forgejo-data.
  • FORGEJO__database__DB_TYPE=sqlite3 - explicitly tells it to use SQLite. Without this variable, Forgejo’s interactive setup wizard will ask you to pick a database, but with it set, that step in the wizard is already pre-filled.
  • FORGEJO__webhook__ALLOWED_HOST_LIST=external,private - without this variable, Forgejo has SSRF protection enabled, and by default it only allows webhook requests to public IP addresses (external). If Komodo runs on the same local network as Forgejo (which is almost always the case in a home setup), the domain komodo.stilicho.ru will resolve to a local network address (192.168.x.x or similar) - and without private in the list, Forgejo will reject webhook delivery with an error like webhook can only call allowed HTTP servers ... deny 'komodo.stilicho.ru(192.168.x.x:443)', even if the webhook itself is configured perfectly correctly.
  • ./forgejo-data:/data - a bind mount where Forgejo stores literally everything: the SQLite database, repositories, configs, issue attachments. It’s the only folder you need to back up to have a complete copy of the instance.
  • Port 3000 - the web UI (HTTP).
  • Port 2222:22 - SSH access to git (git clone git@<host>:2222/...). We’re mapping it to a non-standard external port 2222 specifically to avoid conflicting with the host’s own SSH daemon, which is almost certainly already listening on 22.

Bring it up:

docker compose up -d

You can check that the container came up and isn’t crash-looping with docker compose logs -f forgejo - in the first few seconds after starting, Forgejo initializes the data structure in /data, which is normal.

Next, configure it through the web UI:

  1. Open http://<host-address>:3000 - you’ll land in the initial setup wizard. If you later want to put Forgejo on its own domain through Traefik (as shown below for Komodo), it’s done the same way - by adding labels to the compose file.
  2. Most fields in the wizard are already pre-filled (including the database type, thanks to the environment variable above). Pay attention to the Forgejo Base URL field - if you’re planning to access it through a domain, fill it in right away to avoid reconfiguring later (the repository clone links in the UI are built directly from this value).
  3. Scroll down to the Administrator Account Settings section - this is where you create the first user. They’ll automatically become the admin of the whole instance (in our example, the login is stilicho).
  4. Click Install Forgejo at the bottom of the page and wait for initialization to finish - usually takes a few seconds.
  5. Log in with the account you just created.

Create a test repository: the + icon in the top-right corner → New Repository. Fill in the form:

  • Repository Name - e.g. youtube (a repo where compose files for stacks will be stored);
  • Visibility - make sure to check Private if you’re planning to store .env files with secrets (passwords, tokens) in the repo;
  • it’s worth checking Initialize Repository too - this way you get a README.md right away, and the repo isn’t completely empty (an empty repo is slightly more annoying for the first git clone/push).
Tip

At this point, it’s useful to immediately put one simple compose file in the repository - say, one that deploys a test nginx or traefik/whoami container. We’ll deploy it through Komodo in the next steps, to verify the whole chain on a live example instead of abstractly.

Step 2. Setting Up Komodo
#

Komodo is a panel for managing Docker stacks on one or several hosts, with support for git repositories as the source of compose files and automation through Procedures. It consists of three components: Core (the web UI and API, the “brain” of the system), a database (MongoDB), and Periphery (an agent that directly executes Docker commands on the host).

Set up a project in /home/stilicho/docker/komodo - following the same principle used for the rest of the stacks on the host: all compose projects live under one parent folder, /home/stilicho/docker, and Komodo will see all of them through this folder (more on this below, in the description of PERIPHERY_ROOT_DIRECTORY).

docker-compose.yml for Komodo:

services:
  komodo-core:
    image: ghcr.io/moghtech/komodo-core:latest
    container_name: komodo-core
    restart: unless-stopped
    environment:
      - KOMODO_DATABASE_ADDRESS=komodo-db:27017
      - KOMODO_DISABLE_CONFIRM_DIALOG=true
      - KOMODO_WEBHOOK_SECRET=CHANGE_ME_TO_A_LONG_RANDOM_STRING
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.komodo.rule=Host(`komodo.stilicho.ru`)"
      - "traefik.http.routers.komodo.entrypoints=websecure"
      - "traefik.http.routers.komodo.tls.certresolver=letsencrypt"
      - "traefik.http.services.komodo.loadbalancer.server.port=9120"
    networks:
      - komodo
      - proxy
    depends_on:
      - komodo-db

  komodo-db:
    image: mongo:7
    container_name: komodo-db
    restart: unless-stopped
    volumes:
      - ./komodo-db:/data/db
    networks:
      - komodo

  komodo-periphery:
    image: ghcr.io/moghtech/komodo-periphery:latest
    container_name: komodo-periphery
    restart: unless-stopped
    network_mode: host
    environment:
      - PERIPHERY_ROOT_DIRECTORY=/home/stilicho/docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /home/stilicho/docker:/home/stilicho/docker

networks:
  komodo:
  proxy:
    external: true

Let’s go through what’s happening here and why, line by line.

komodo-core
#

  • KOMODO_DATABASE_ADDRESS=komodo-db:27017 - the address of MongoDB inside the komodo Docker network. Note that the hostname komodo-db is the service name from this same compose file - Docker’s built-in DNS resolves it automatically within the shared network, you don’t need to configure anything separately.
  • KOMODO_DISABLE_CONFIRM_DIALOG=true - disables the confirmation popup before every action (deploy, restart, etc.) in the web UI. Convenient for home use, where you’re the only one at the controls anyway; on a team where several people can trigger actions, you might actually want to leave that confirmation dialog on.
  • KOMODO_WEBHOOK_SECRET - the secret Komodo uses to verify the authenticity of incoming webhook requests from the git provider (via the X-Hub-Signature-256 signature). This is one shared secret for the entire Core instance, not a separate value per Repo/Stack/Procedure - when setting up a webhook in Forgejo for any resource, you’ll be entering this exact value. You can generate a random string with, for example, openssl rand -hex 32.
  • traefik.enable=true and the following labels - instead of exposing port 9120 directly, we hand routing over to Traefik. The line Host(\komodo.stilicho.ru`)is the rule for which domain Traefik should route to this specific container;certresolver=letsencryptis which certificate resolver to use (the name has to match what's already configured in your Traefik);loadbalancer.server.port=9120` is which internal container port to send requests to (this is the port Komodo Core itself listens on, it doesn’t change).
  • networks: komodo, proxy - Core needs to be in both the komodo network (to reach komodo-db) and the proxy network (this is the external network your Traefik lives in - it’s how Traefik gets access to the Komodo container for routing).
Warning

The name proxy in the networks: proxy: external: true block has to exactly match the name of the docker network your Traefik is already running in. If yours is named differently (e.g. traefik-proxy or web), make sure to fix this name in both places - otherwise Traefik simply won’t see the Komodo container and routing won’t work, and docker compose up will error out about a non-existent external network.

komodo-db
#

Nothing special here - a plain MongoDB 7 container with data on a bind mount, ./komodo-db:/data/db, so the database physically sits next to the rest of the project’s files instead of hiding in /var/lib/docker/volumes under an auto-generated name. We don’t add this service to the proxy network separately - nothing outside needs it except komodo-core.

komodo-periphery
#

This is the most important part to understand.

  • network_mode: host - Periphery runs in the host’s own network namespace, not in an isolated Docker network. This is a deliberate choice: the agent needs to be able to interact with Docker the same way you would over SSH yourself, and host mode removes the extra complexity of port-forwarding between the agent and Core.
  • PERIPHERY_ROOT_DIRECTORY=/home/stilicho/docker - the key variable. It points the Periphery agent at the parent folder that holds the directories of every other stack (not just Komodo’s own). This is exactly what lets Komodo see and manage compose projects that already exist on the host, not just ones created specifically for it. Essentially, it’s the “root” Komodo builds all other paths from when working with Stack resources.
  • /var/run/docker.sock:/var/run/docker.sock - the host’s Docker daemon socket, mounted inside the Periphery container. Without it, the agent physically has no way to deploy, stop, or view the logs of any containers - every docker compose ... command Periphery ultimately runs talks directly to this socket.
  • /home/stilicho/docker:/home/stilicho/docker - a second bind mount, giving the container direct access to the files on disk (compose files, .env, any auxiliary stack files).
Note

If, like here, your Periphery runs in network_mode: host, the bind mount /home/stilicho/docker:/home/stilicho/docker has to point to the exact same path as PERIPHERY_ROOT_DIRECTORY - meaning the path inside the container and the path on the host match letter for letter. The reason is that Periphery ultimately runs docker compose commands referencing paths on the host filesystem directly (through the socket), not paths inside its own container - if the paths diverge, the agent will “see” files inside its own container but won’t be able to match them to their real location on the host, and the deploy will fail with a “file not found” error or something similar.

Warning

If the host already has folders with working stacks - nothing bad happens, nothing breaks. PERIPHERY_ROOT_DIRECTORY is a scope of visibility, not a command to do something: it lets Periphery see compose projects inside the specified folder (Komodo can offer them up in the UI), but on its own it doesn’t start, stop, or change anything. Existing stacks keep running exactly as they were, until you explicitly create a Stack resource for them in Komodo and hit Deploy.

The one thing worth remembering: if you decide to bring an already-existing stack under git management (link it to a Repo resource and set up Pull), Komodo will base the next deploy on what’s in the repository - not on whatever you might have tweaked by hand on disk after the last commit. Unpushed local edits will be lost when the container is recreated in this case. So for stacks moved under GitOps, the rule is simple: from that point on, edit the config only through git, never by hand on the host.

Bring it up:

docker compose up -d

The first startup can take a bit longer - MongoDB initializes its data structure, and Core waits until the database becomes available (depends_on handles this, but it only guarantees container startup order, not that the database itself is ready to accept connections - if Core doesn’t connect on the first try, it should reconnect on its own within a few seconds; if not, docker compose restart komodo-core).

Open https://komodo.stilicho.ru (or http://<host-address>:9120, if you decided to skip Traefik for now and left the port exposed directly), and create the first admin user in the form that appears on first visit.

Tip

If your infrastructure already has its own OIDC provider (like Authentik), Komodo can log in through it - handy when you need to grant access to the panel to more than just yourself. It’s configured through separate environment variables for komodo-core (provider address, client ID/secret), and it’s not required for the first run - you can come back to it later, once the basic setup is already working.

Step 3. Linking Komodo to the Repository in Forgejo
#

For Komodo to be able to clone a private repository (and a public one too, by the way - Forgejo requires authorization even for reading over HTTP by default, unless you explicitly open up anonymous access), you need an access token.

Creating a Token in Forgejo
#

  1. Log in as your user (stilicho) in Forgejo → profile icon in the top-right corner → Settings.

  2. In the left menu, pick Applications.

  3. In the Manage Access Tokens section, fill in Token Name (e.g. komodo-readonly).

  4. In the Repository and Organization Access block - a new feature specifically in v15 - pick one of three options:

    • All - the token gets access to all repositories on the account (public, private, restricted);
    • Public only - access is limited to public repositories;
    • Specific repositories - access only to explicitly selected repositories.

    For our purposes, Specific repositories makes the most sense - select only youtube from the list that appears. A token that gets compromised or accidentally leaked somewhere won’t grant access to anything beyond this one repository.

  5. When you pick Specific repositories, only four permissions are available: read:repository, write:repository, read:issue, write:issue (Forgejo simply doesn’t show the other scopes for a scoped token - there’s nothing for them to check against if the token isn’t tied to a specific repository). For cloning and pulling from Komodo’s side, read:repository is enough; only add write:repository if you’re also planning to push to this repository using the same token (say, for automated commits from CI).

  6. Click Generate Token.

  7. The token is shown exactly once, right after generation. Copy it immediately - if you close the page or refresh it, you won’t be able to view the token again, you’ll have to generate a new one.

Registering the Token in Komodo
#

Komodo doesn’t tie a token to a specific repository directly - instead, you first register an account with the git provider (a “domain + login + token” combo), and that account is then used when creating any number of Repo resources.

  1. In the Komodo UI, go to Settings (the gear icon) → the Providers section.
  2. In the Git Providers block, click Add Provider (or +, depending on the UI version). Fill in:
    • Domain - your Forgejo domain without the protocol and without http(s)://, e.g. forgejo.stilicho.ru (or <host-address>:3000, if you’re working without a domain);
    • HTTPS - a toggle, on by default; if Forgejo isn’t behind Traefik yet and is only reachable over http://, make sure to turn this toggle off - otherwise Komodo will try to clone over https:// and get a connection error;
    • inside the provider, add an account (Add Account): Username - your Forgejo login (stilicho), Token - the token copied in the previous step.
  3. Save - the provider and its linked account will show up in the list.
Note

On Komodo’s side, the token is tied to the provider’s domain as a whole, not to a specific Repo resource - add Forgejo as a provider and the stilicho account once, and afterward, when creating new Repo resources for other repositories on the same Forgejo, you just pick this account from a list instead of pasting the token into every single form again. On Forgejo’s side, though, as we just saw, a v15 token can be scoped to a specific repository - these are two independent levels of restriction, and they don’t interfere with each other.

Creating a Repo Resource
#

  1. Resources → Repos → Create Repo.
  2. Give the resource a clear name (e.g. youtube-stacks).
  3. In the Repo field, enter only owner/repo, without the protocol or domain - in our case, stilicho/youtube. Komodo will fill in the domain itself from the Git provider you select in the next step; if you also enter the full URL here (https://forgejo.stilicho.ru/stilicho/youtube.git), the domain gets duplicated and cloning fails with remote: Not found - Komodo will literally concatenate <provider domain>/<whatever you typed>.
  4. In the Git Account field, pick the stilicho account from the dropdown, on the provider added in the previous step - no need to re-enter the token itself here, Komodo substitutes it automatically when cloning.
  5. Leave the Branch as main (or master, depending on what Forgejo named the default branch when the repo was created - you can see this on the repo’s page).
  6. Save.

You can verify that cloning succeeded on the Repo resource’s own page - there’s a history of operations performed (Pull, Clone) with logs, including the exact git clone command Komodo ran. It’s worth eyeballing it at least once - it makes it clearer what actually got substituted in place of the domain and token. If authorization failed, the log will show a 401/403 error - double-check that the token was copied without extra whitespace and that its permissions include reading the repository. If instead you get something like remote: Not found / repository ... not found with what looks like a correct token, the domain in the address has almost certainly been duplicated because of a full URL in the Repo field (see the warning above).

Tip

If the host already has several running stacks (like in the example above, where PERIPHERY_ROOT_DIRECTORY points at the shared /home/stilicho/docker folder), it’s more convenient not to set up a separate repository per stack, but to keep one shared monorepo - with a subfolder for each stack inside it. That way all the compose files are visible to Komodo through a single Repo resource, and you don’t end up with dozens of separate entries.

.env files in a repository like this can be committed as-is, but make absolutely sure the repository is private - otherwise secrets (database passwords, tokens) become accessible to anyone with a link to the repo.

Creating a Stack
#

  1. Resources → Stacks → Create Stack.
  2. Give the stack a name (e.g. traefik, if it’s the reverse-proxy stack, or nginx-test for the test example).
  3. Server - which host (Periphery agent) to deploy the stack on. If you only have one host so far, there’s not much to choose from, but once you add a second host through a separate Periphery agent, this field determines exactly where the deploy goes.
  4. Select Repo - here you either pick the previously created Repo resource, or (if you didn’t set up a separate Repo resource) configure the source right on the spot: Git ProviderAccountRepo (same format - owner/repo, e.g. stilicho/youtube) → Branch. Both paths are equivalent, it’s just that a separate Repo resource is more convenient to reuse in a Procedure, as shown below.
  5. Run Directory - the path to the subfolder with the compose file inside the repository, e.g. traefik, relative to the repo root.
  6. File Paths - the name of the compose file itself, which gets plugged into docker compose -f, relative to Run Directory. If left blank, Komodo defaults to compose.yaml - if your file is named differently (e.g. the more commonly used docker-compose.yml), you must specify it explicitly in this field, otherwise the deploy will fail at the validation step with an error like Missing files: compose.yaml, even though the file is right there, just under a different name.
  7. Save and click Deploy - Komodo should bring up the container(s) from your compose file.
Note

Right below that, there’s a Clone Path field - by default Komodo clones the repository into $root_directory/stacks/<stack name> (in our case, that would be /home/stilicho/docker/stacks/traefik), and in 99% of cases you don’t need to touch this field. It’s just useful to know what it means - you’ll see this exact path in the Clone Repo step’s log if you ever need to go in by hand and check something.

Warning

Run Directory and File Paths are two independent fields, and both errors look very similar in the log (ERROR: A file doesn't exist after writing stack / Missing files: ...), but they’re fixed differently:

  • if the repository cloned fine but the expected subfolder seems missing inside it - check Run Directory;
  • if the subfolder is correct, but it says specifically compose.yaml is missing - check File Paths: your file is most likely named docker-compose.yml or something else, and you need to spell out that name explicitly instead of relying on the default.

You can check the result two ways: look at the deploy logs right on the Stack resource’s page in Komodo (every step is visible there - Clone Repo, Latest Commit, Validate Files, Deploy Compose - and exactly which one, if any, went wrong), or go onto the host and run docker ps - the container should show up in the list with a name matching the services from the compose file.

If the container started up - the chain repository → Komodo → Docker already works manually. All that’s left is to remove the last manual step - clicking the Deploy button.

Step 4. Auto-Deploy on Push
#

This is where the core idea of GitOps kicks in: instead of going into Komodo and clicking Deploy every time, we want that to happen automatically whenever something is pushed to the repository.

A Procedure in Komodo
#

A Procedure is a sequence of one or more steps (Pull, Deploy, sending a notification, etc.) that can be run with a single command or triggered by something, like a webhook.

  1. Resources → Procedures → Create Procedure.
  2. Give the procedure a name, e.g. deploy-on-push.
  3. Add steps in order (the Add Step button, or Add Stage depending on the UI version - steps inside a Procedure run strictly top to bottom):
    • Pull Repo - point it at the same Repo resource used in the Stack. This step pulls the latest commit from the main branch into the local working copy on the host.
    • Deploy Stack (if there’s just one stack) or Batch Deploy Stack If Changed (if there are several stacks in the repository) - applies whatever the previous step pulled.
  4. Save the Procedure.
Tip

The “If Changed” option is useful if your monorepo holds several stacks at once: Komodo compares the contents of each stack’s folder before and after the Pull, and only redeploys the ones whose files actually changed in the latest commit, instead of recreating all the containers at once on every little change to the repo.

If there’s only one stack in the repository, a Procedure isn’t even necessary: every Stack resource already has a ready-made Webhooks section on its own page, with a URL like .../listener/github/stack/<id>/deploy - you can hook a webhook straight into that, no intermediate Procedure needed. The Deploy operation for a stack already includes pulling the latest state from git, so a separate Pull step isn’t needed for it. A Procedure with an explicit Pull + Batch Deploy makes sense specifically when a single push to a monorepo needs to touch several different stacks at once.

The Webhook in Forgejo
#

Komodo’s webhook URL format is fixed:

https://<HOST>/listener/<AUTH_TYPE>/<RESOURCE_TYPE>/<ID_OR_NAME>/<EXECUTION>

For Forgejo/Gitea, AUTH_TYPE is always github (Komodo checks the X-Hub-Signature-256 signature, the same format GitHub uses, and Forgejo knows how to send webhooks in exactly that format). For a resource of type Procedure, EXECUTION isn’t a command like “deploy” - it’s the branch name that a push to should trigger the procedure (or __ANY__, to react to a push to any branch); for a Stack, it’s an actual command - /deploy or /refresh.

  1. Open the deploy-on-push Procedure page (or the specific Stack’s page, if you decided to go without a Procedure) in Komodo - the Webhooks section already has a ready-made URL, you won’t have to build one by hand. For a Procedure, it’ll look like this:

    https://komodo.stilicho.ru/listener/github/procedure/deploy-on-push/main
  2. In Forgejo, go to the relevant repository → Settings → Webhooks → Add Webhook → Forgejo (Forgejo supports its own native webhook format, separate from the universal Gitea-compatible one - pick Forgejo specifically if it’s in the list; if it’s not, Gitea works too - the event format is compatible with both).

  3. Fill in the form:

    • Target URL - paste the URL you assembled in step 1;
    • HTTP Method - POST;
    • POST Content Type - application/json;
    • Secret - by default, paste the value of KOMODO_WEBHOOK_SECRET that you set in Komodo’s compose file back in step 2 (this is the global secret, shared across every webhook for every resource on the instance). Every resource has a Webhook Secret field in its own Webhooks section, where you can optionally set a separate secret just for it instead of the global one - but for a single-user home setup, there’s usually no need for that;
    • Trigger On - pick the Push Events event (you can leave just this one checked - other events like issues or pull requests aren’t needed for our purposes).
  4. Save the webhook. Forgejo will immediately offer to send a Test Delivery - worth doing: if Komodo responds with 200 OK, the secret and URL are configured correctly, and you can move on to testing with a real commit.

Warning

If, in response to a Test Delivery, you see a red cross and text like webhook can only call allowed HTTP servers ... deny '<domain>(<IP>:443)' - this isn’t a problem with the secret or the URL, it’s Forgejo’s SSRF protection: by default it blocks webhooks to local-network addresses. Make sure the FORGEJO__webhook__ALLOWED_HOST_LIST variable of the Forgejo container (see the compose file in Step 1) includes private - without it, any webhook to a domain that resolves to 192.168.x.x/10.x.x.x will be rejected with exactly this error, no matter how many times you double-check the secret and URL.

Note

If your Procedure’s name has spaces in it, or a different case than its ID, it’s safer to put the resource’s numeric ID in the URL instead of its name - you can copy it from the resource’s page in Komodo. A name can change over time (you might want to rename the procedure), but the ID never does, so the link in Forgejo’s webhook settings won’t “detach” from the resource if you rename it.

Testing It
#

All the git commands below run on your own computer, in a local copy of the repository - not on the server where Forgejo and Komodo live. The server only receives the push and reacts to it through the webhook.

If you don’t have the repository locally yet, clone it (same URL you used when creating the Repo resource in Komodo; on the first request, git will ask for a login and password - use the same token you registered in Komodo as the password, or set up a separate personal token with write access for yourself):

git clone http://<host-address>:3000/stilicho/youtube.git
cd youtube

Now change something in the relevant compose file - say, the image version - and push:

git add .
git commit -m "bump nginx version"
git push
Tip

You can also edit a file right in Forgejo’s web UI (open the file → the Edit pencil icon → Commit Changes at the bottom of the page) - then you don’t need a local clone at all, the commit and push happen server-side automatically. For quick edits, this is even more convenient than dealing with a git client on your own machine.

After the git push, events unfold like this:

 sequenceDiagram

```
participant You
participant Forgejo
participant Komodo
participant Docker

You->>Forgejo: git push
Forgejo->>Komodo: webhook (push event)
Komodo->>Komodo: Pull Repo
Komodo->>Docker: Deploy Stack
Docker-->>Komodo: container updated
```

Within a few seconds, a new run should show up under Procedures → deploy-on-push in Komodo with a Success status - opening it up, you can see a detailed log of both steps: exactly what Pull Repo fetched and what Deploy Stack printed while recreating the container. The container gets recreated with the new config without a single manual action on your part.

If the run doesn’t fire automatically, the first thing to check is the webhook delivery log on Forgejo’s side (Settings → Webhooks → → Recent Deliveries): it shows the status code Komodo responded with, which usually points straight at the cause (wrong secret - 401, wrong URL - 404 or a connection timeout).

Checking the Rollback
#

Just for fun, it’s worth trying the reverse operation right away too - back in the same local repo copy (cd youtube, if you’re not already there):

git revert HEAD
git push

git revert HEAD creates a new commit that undoes the changes from the last commit (in our case, that exact image version bump), without rewriting history - unlike git reset, the old commit stays in the log, a reverting commit just gets added on top of it. This matters: if someone (or you yourself) manages to push another commit between your edit and the rollback, HEAD will no longer point at the edit you actually wanted to undo - in that situation it’s more precise to revert a specific commit by hash: git revert <hash> instead of git revert HEAD.

After the git push, the same webhook fires again, Komodo pulls the reverted commit and restores the previous version of the stack - the container gets recreated with the old image version. And this is the main practical benefit of the whole approach: a mistake gets fixed just as easily as it was introduced, with two terminal commands, without needing to log into the server and figure out what’s actually deployed there right now.

What’s Next
#

The setup shown here is the minimal one: a single host, MongoDB and SQLite inside the same containers, one admin user. When you’re ready to grow, there’s plenty of room:

  • move Forgejo’s and Komodo’s databases out to an external PostgreSQL/MongoDB - useful if the host already has a shared database serving several services;
  • connect several hosts through separate Periphery agents - Core stays a single instance, while Periphery gets installed on each additional host and points at its own PERIPHERY_ROOT_DIRECTORY;
  • add CI through Forgejo Actions - for example, automatically checking compose file syntax (docker compose config) before a commit even lands on the main branch;
  • set up deploy notifications - Komodo can send notifications to Discord, Slack, Telegram, or an arbitrary webhook when a Procedure finishes;
  • as the number of repositories grows, move to Specific repositories tokens (like the Forgejo v15 ones in this article) everywhere you previously had an “access to everything” token - that way compromising one token doesn’t immediately open up access to all of your repositories.

But for a first introduction to GitOps in a homelab, what’s already here is enough: a repository as the source of truth, automatic deployment on push, and a one-command rollback.

Self-Hosting - This article is part of a series.
Part : This Article

Related