Skip to content

Komodo (deploy control plane)

Komodo is a web UI and API for running Docker across several servers. Here it does one job: take the compose files from the infra repo and keep every server running exactly what git says, then shout on ntfy when that fails.

Part What it is
Core The web UI, API, scheduler and webhook listener. Stores everything in MongoDB.
MongoDB Core's database: servers, stacks, procedures, variables, users, update history.
Periphery The agent on each server. Clones/pulls the repo and runs docker compose when Core asks.

Core, MongoDB and the local Periphery

All three run in one compose project on control-host. Keep this file in the infra repo (hosts/control-host/komodo/compose.yaml) even though Komodo doesn't deploy it, so Renovate sees its images and there's a reviewed history of changes.

/opt/docker/komodo/compose.yaml
services:
  mongo:
    image: mongo:8.3.13        # databases can also be pinned as tag@sha256:<digest>
    command: --quiet --wiredTigerCacheSizeGB 0.25
    restart: unless-stopped
    stop_grace_period: 60s
    volumes:
      - ./mongo/data:/data/db
      - ./mongo/config:/data/configdb
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${KOMODO_DATABASE_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${KOMODO_DATABASE_PASSWORD}
    healthcheck:
      # ping needs no auth
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand({ping:1}).ok"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 30s

  core:
    # Keep core and EVERY periphery on the same version
    image: ghcr.io/moghtech/komodo-core:2.3.3
    init: true
    restart: unless-stopped
    depends_on:
      mongo:
        condition: service_healthy
    ports:
      - 9120:9120               # reverse proxy in front; don't expose this to the internet
    env_file: ./compose.env
    environment:
      KOMODO_DATABASE_ADDRESS: mongo:27017
    volumes:
      - ./core/keys:/config/keys
      - ./core/backups:/backups  # daily DB export lands here
      - ./certs/rootCA.crt:/usr/local/share/ca-certificates/rootCA.crt
    networks:
      - default
      - proxy
    healthcheck:
      # The image has no official HEALTHCHECK yet; this checks the UI answers
      test: ["CMD", "curl", "-f", "http://localhost:9120"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 20s

  periphery:
    # Outbound mode: connects to core, no listener to healthcheck. Komodo shows its state as a server.
    image: ghcr.io/moghtech/komodo-periphery:2.3.3
    init: true
    restart: unless-stopped
    depends_on:
      - core
    env_file: ./compose.env
    volumes:
      - ./core/keys:/config/keys
      - /var/run/docker.sock:/var/run/docker.sock
      - /proc:/proc
      # Stacks and repos are cloned here; host path and container path must be identical
      - ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}:${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}
      - ./certs/rootCA.crt:/usr/local/share/ca-certificates/rootCA.crt

networks:
  proxy:
    external: true
.env.example (saved as compose.env, mode 600; .env is a symlink to it)
# Database
KOMODO_DATABASE_USERNAME=komodo
KOMODO_DATABASE_PASSWORD=<LONG_RANDOM>
TZ=<TZ>

# Core
KOMODO_HOST=https://komodo.<YOUR_LOCAL_DOMAIN>   # used to build the webhook URLs shown in the UI
KOMODO_TITLE=Komodo
KOMODO_PERIPHERY_PUBLIC_KEY=file:/config/keys/periphery.pub
KOMODO_LOCAL_AUTH=true
KOMODO_INIT_ADMIN_USERNAME=<YOUR_USER>
KOMODO_INIT_ADMIN_PASSWORD=<ONLY_USED_ON_FIRST_START>
KOMODO_FIRST_SERVER_NAME=control-host
KOMODO_WEBHOOK_SECRET=<LONG_RANDOM>               # same value goes in the Forgejo webhook "Secret"
KOMODO_JWT_SECRET=<LONG_RANDOM>
KOMODO_DISABLE_USER_REGISTRATION=true

# Periphery (the container above)
PERIPHERY_CORE_ADDRESS=ws://core:9120
PERIPHERY_CONNECT_AS=control-host
PERIPHERY_CORE_PUBLIC_KEYS=file:/config/keys/core.pub
PERIPHERY_ROOT_DIRECTORY=/etc/komodo

The .env symlink exists so ${KOMODO_DATABASE_USERNAME} in the compose file is substituted without passing --env-file every time. Upstream's full list of variables is in their compose.env.

KOMODO_INIT_ADMIN_PASSWORD only seeds the admin account on the first start. Changing it later does nothing. Change the password in the UI, then delete the line so it doesn't linger in a file.

Hardening

  • Give the periphery container its own env file with only PERIPHERY_* values. With a shared compose.env, the agent (which has the Docker socket) also receives Core's database password and JWT secret.
  • chmod 600 on compose.env and on core/backups/ (the export contains users and API keys).
  • Put the UI behind SSO or at least a LAN-only proxy host, and turn on 2FA or OIDC.
  • In each periphery.config.toml, set disable_terminals = true and disable_container_terminals = true unless you really use the browser terminal. Periphery runs as root with the Docker socket, so a terminal in the UI is root on that host.

Komodo can't deploy itself

If Komodo redeployed its own stack, docker compose up would stop Core in the middle of the deploy that Core is running. So this compose is applied by hand:

# On control-host, after pulling main on your workstation and copying the file over
cd /opt/docker/komodo
cp compose.yaml compose.yaml.bak-$(date +%Y%m%d)
install -m 664 /tmp/komodo-compose.yaml compose.yaml
sudo docker compose config -q && sudo docker compose pull && sudo docker compose up -d
sudo docker compose ps        # mongo + core healthy, periphery Up

Expect a ~30 s UI/API outage while Core restarts. Deployed stacks keep running: they're plain compose projects and don't need Komodo to stay up. Rollback is copying the .bak-* back and running up -d again.

Renovate still opens PRs for these images (manual review). Merging one changes nothing on the host until you apply it.

Keep Core and Periphery in lockstep

Core and every Periphery must run the same version. Mixed versions show up as servers that won't connect or API calls that fail in odd ways. Upgrade in one window:

  1. Read the release notes.
  2. Merge the Renovate PR (core and periphery are grouped into one), apply the compose by hand as above.
  3. Upgrade the systemd Periphery on every other host (re-run the install script below, it updates the binary).
  4. Check Servers in the UI: all of them Ok on the new version.

MongoDB stays on its major version unless you choose to move it. Mongo majors are dashboard-approval PRs in Renovate.


Periphery on the other hosts

On server1, server2 and the workstation, Periphery runs as a systemd service, not a container. It needs no Docker image and survives anything that happens to Docker's containers. Install it with the upstream script, using an onboarding key created in Core's UI or API (it is shown only once):

curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py \
  | sudo python3 - \
    --core-address="https://komodo.<YOUR_LOCAL_DOMAIN>" \
    --connect-as="$(hostname)" \
    --onboarding-key="O-..."
sudo systemctl enable --now periphery

This installs /usr/local/bin/periphery, a periphery.service unit and /etc/komodo/periphery.config.toml. Re-running the script later upgrades the binary and keeps the config. The onboarding key isn't needed after the first connection.

The server then appears in Komodo. Its clones live under /etc/komodo/repos/ (shared linked repo) and /etc/komodo/stacks/ (per-stack clones).


Git access

Settings → Providers: add a git account for your Forgejo host (git.<YOUR_DOMAIN> or the LAN name) with the komodo bot user and its read-only token. Then create one Repo resource:

Field Value
Name homelab
Git provider / account your Forgejo host / komodo
Repo <YOUR_USER>/homelab
Branch main

Linked repo vs per-stack clones

A Stack can either clone the repo itself (repo = "<YOUR_USER>/homelab") or point at the Repo resource (linked_repo = "homelab").

Per-stack clone linked_repo
Clones on a host one full clone per stack under /etc/komodo/stacks/<stack>/ one clone per host at /etc/komodo/repos/homelab
Per push N pulls from Forgejo one pull per host (parallel deploys share a lock)
Relative bind mounts point inside that stack's clone point inside the shared clone

Use linked_repo for everything. The only stacks to leave on per-stack clones are ones that still keep state in a relative path like ./data; switching them would point the mount at an empty directory in the shared clone. Move that state to /mnt/appdata/<service> with an absolute path first, then switch.


Stack resources

One Stack per hosts/<host>/<stack>/ directory. The easiest way to add one is to copy an existing stack's config and change the server and path. The settings that matter:

Setting Value Why
Server the host the directory is named after
Linked repo homelab shared clone, see above
Run directory hosts/<host>/<stack> Compose runs here, so relative files (configs, Dockerfiles) resolve
File paths empty (defaults to compose.yaml)
Config files every non-compose file the stack reads, with Requires: Redeploy Without this, editing e.g. a settings.yaml or Dockerfile is not a "change" and nothing redeploys
Environment NAME=[[KOMODO_VARIABLE]] lines written to .env next to the compose at deploy time, see Secrets
Registry account an authenticated Docker Hub account avoids the anonymous pull limit, which is shared per public IP
Auto pull off a deploy only pulls when a tag actually changed; a config-only commit can't fail on a registry outage
Poll for updates on shows "update available" in the UI
Auto update off Renovate + git decide versions, not Komodo
Webhook enabled on
Send alerts on StackStateChange alerts for this stack

Komodo can export resources as TOML (the API's ExportAllResourcesToToml, also available in the UI), which is handy as a record of these settings and as a starting point for a Resource Sync later.


The "Redeploy Changed Stacks" procedure

Procedures are a list of stages that Komodo runs in order, triggered by a webhook, a schedule or a button.

Procedure (TOML export form)
[[procedure]]
name = "Redeploy Changed Stacks"
[procedure.config]
schedule_format = "Cron"
schedule = "0 */15 * * * ?"       # Komodo cron has SIX fields: second minute hour day month weekday
webhook_enabled = true
failure_alert = true

[[procedure.config.stage]]
name = "Deploy Changed"
executions = [
  { execution.type = "BatchDeployStackIfChanged", execution.params.pattern = "*" },
]
  • Webhook: the procedure's page shows its URL, https://komodo.<YOUR_LOCAL_DOMAIN>/listener/github/procedure/<ID>/main. Put it in a Forgejo push webhook (Forgejo). The /main at the end means pushes to other branches are ignored.
  • Schedule: every 15 minutes, so a failed deploy retries without anyone pushing again. BatchDeployStackIfChanged is idempotent: when nothing changed, nothing happens.
  • Pattern * means every stack. You could split by host or tag (pattern = "server1-*") if one host's failures shouldn't hold up another's.

Check the schedule actually parses

Schedules can be written in English (Every 15 minutes) or Cron. Komodo converts English to cron internally, and a Komodo upgrade can change how that conversion comes out. If the procedure page shows a schedule error (for example "single number steps like 0/15 are not allowed"), the schedule is silently not running. Switch the format to Cron with 0 */15 * * * ? and confirm a "next run" time appears.


Alerters to ntfy

Alerters send Komodo's alerts somewhere. Komodo has an ntfy endpoint type. Use several alerters so each kind of alert gets the right topic and priority:

Alerter Topic / priority Alert types
host-down alerts / 5 ServerUnreachable, ServerDisk
service-down alerts / 4 StackStateChange, ContainerStateChange
deploy-failed alerts / 4 ProcedureFailed, ActionFailed
updates updates / 3 StackAutoUpdated, StackImageUpdateAvailable, DeploymentAutoUpdated, DeploymentImageUpdateAvailable

Point them at ntfy over the internal Docker network (http://ntfy:80) so alerts still arrive when DNS or the reverse proxy is broken. Authenticate with a write-only ntfy token stored as a secret Variable (see Secrets).

The default alerter types are not enough

A failed procedure (a broken deploy) is ProcedureFailed, not a stack state change. If the alerter doesn't list it, a failed deploy is invisible and the stack just stays on the old commit. Use Test Alerter on each one.

Alerters support maintenance windows. If something pauses containers on purpose every night (a parity sync, a backup), put the affected alerter in maintenance for that window so it doesn't page you.


Backups of Komodo itself

New installs come with a Backup Core Database procedure (daily at 01:00) that writes a dated, gzipped export of the database into /backups, mounted from ./core/backups. It keeps the latest 14 by default.

Back up the whole /opt/docker/komodo directory with your host backup (it includes compose.env, the keys and those exports). Restore with the Komodo CLI: km database restore -y (latest export, or --restore-folder for a specific one). Restores don't clear the target database first, so restore into an empty Mongo.


Sources & further reading