No description
Find a file
2026-02-26 01:00:25 +00:00
cmd/gitcompose feat: trigger reconcile on SIGHUP 2026-02-25 12:01:46 +01:00
example chore: test base64 update 2026-02-25 16:29:09 +01:00
internal perf: skip preprocessor if source file is unchanged 2026-02-25 16:25:49 +01:00
.gitignore docs: add example repo with nginx + whoami projects 2026-02-25 12:13:42 +01:00
DESIGN.md init 2026-02-25 11:57:50 +01:00
gitcompose.yaml feat(example): add nginx-static project with base64 preprocessor 2026-02-25 16:04:15 +01:00
go.mod init 2026-02-25 11:57:50 +01:00
go.sum init 2026-02-25 11:57:50 +01:00
README.md fix: run preprocessors in-memory at content_from read time 2026-02-25 16:23:29 +01:00
renovate.json Add renovate.json 2026-02-26 01:00:25 +00:00

gitcompose

A GitOps daemon for Docker Compose. It watches a git repository containing multiple Compose projects and reconciles running containers whenever changes are detected — via polling, webhooks, or both.

How it works

  1. gitcompose clones (or opens) a git repository on startup.
  2. It discovers every subdirectory that contains a docker-compose.yaml or docker-compose.yml as a project.
  3. On each poll tick or webhook push event it fetches the remote, detects which project directories changed, and runs docker compose up -d for each one.
  4. Optionally, a files.yaml alongside a project's compose file declares config files to be written to the host and bind-mounted into containers. A per-service content hash is injected as an environment variable (GITCOMPOSE_FILES_HASH) so that file changes trigger container restarts automatically.
  5. Preprocessor rules (e.g. sops -d) can decrypt secrets files before any of the above runs.

Repository layout (the repo gitcompose tracks)

infra-repo/
  projectA/
    docker-compose.yaml   ← managed by gitcompose
    files.yaml            ← optional: config file injection
  projectB/
    docker-compose.yaml
    .env.sops             ← encrypted env file, decrypted by a preprocessor rule

gitcompose auto-discovers any subdirectory containing a docker-compose.yaml / docker-compose.yml. No registration is required.

Installation

Build from source

Requires Go 1.21+.

git clone https://github.com/foosinn/gitcompose
cd gitcompose
go build -o gitcompose ./cmd/gitcompose

The result is a single static binary with no runtime dependencies beyond docker compose being available on $PATH.

From a release

Download the pre-built binary for your platform from the releases page and place it on your $PATH.

Configuration

Create a gitcompose.yaml (path is configurable via --config):

repo:
  url: git@github.com:your-org/infra.git   # remote URL or local path — see below
  branch: main
  ssh_key: /home/gitcompose/.ssh/id_ed25519 # omit for local paths or public repos
  poll_interval: 60s          # how often to poll; default 60s
  path_prefix: servers/prod   # optional: only manage projects under this subdirectory

webhook:
  enabled: true
  listen: :9000               # address for the webhook HTTP listener
  secret: ${WEBHOOK_SECRET}   # ${ENV_VAR} interpolation is supported

base_dir: /var/lib/gitcompose # where to store the clone, managed files, generated composes

preprocessors:
  - match: "**/*.sops.yaml"
    command: "sops -d {file}"
  - match: "**/.env.sops"
    command: "sops -d {file}"

All config fields

Field Default Description
repo.url (required) Repository to track — remote URL (git@…, https://…) or a local path (/path/to/repo, file:///path/to/repo)
repo.branch main Branch to track
repo.ssh_key (empty) Path to SSH private key; omit for local paths or public repos
repo.poll_interval 60s Polling interval (Go duration string)
repo.path_prefix (empty) Subdirectory within the repo to scope project discovery to; prefix is stripped from project names
webhook.enabled false Start the webhook HTTP listener
webhook.listen :9000 Address to listen on
webhook.secret (empty) HMAC secret for validating webhook payloads; leave empty to disable verification
base_dir /var/lib/gitcompose Root directory for clone, managed files, and generated composes
preprocessors [] List of preprocessor rules (see Preprocessors & secrets management)

Environment variables can be interpolated anywhere in the config using ${VAR_NAME} syntax.

Local repository

repo.url accepts a local filesystem path in addition to remote URLs. gitcompose will clone the local repo into <base_dir>/repo and pull from it on each poll tick — picking up any commits that have been added to the local repo since the last poll.

repo:
  url: /home/user/infra-repo   # absolute path
  branch: main
  poll_interval: 10s

file:// URLs work too:

repo:
  url: file:///home/user/infra-repo
  branch: main

ssh_key is not needed for local paths and will be ignored if set (a warning is logged).

This is useful for:

  • Development: point gitcompose at a local checkout and test changes without pushing to a remote.
  • Air-gapped hosts: where an external process (cron, another tool) manages the local repo and gitcompose only needs to react to new commits.

Single repo, multiple servers

repo.path_prefix scopes gitcompose to a subdirectory of the repository. This lets you manage multiple servers from a single repo by structuring it like:

infra-repo/
  servers/
    prod/
      nginx/
        docker-compose.yaml
      app/
        docker-compose.yaml
    staging/
      app/
        docker-compose.yaml

Then run one gitcompose instance per server, each with a different repo.path_prefix:

# prod server
repo:
  url: git@github.com:your-org/infra.git
  branch: main
  path_prefix: servers/prod       # only sees nginx/ and app/ as projects
# staging server
repo:
  url: git@github.com:your-org/infra.git
  branch: main
  path_prefix: servers/staging    # only sees its own app/ project

Project names (used for docker compose --project-name) are derived from the directory name relative to path_prefix, so both servers can have a project named app without conflict.

files.yaml

Place a files.yaml next to a project's docker-compose.yaml to have gitcompose write config files to the host and bind-mount them into containers.

files:
  - path: /etc/nginx/nginx.conf     # absolute path inside the container
    owner: root
    group: root
    mode: "0644"
    restart_containers: true        # restart services that mount this file when content changes
    services:
      - nginx                       # explicit list of Compose services to inject this file into
    content: |
      worker_processes 1;
      events { worker_connections 1024; }
      http {
        server { listen 80; }
      }

  - path: /app/config.json
    owner: app
    group: app
    mode: "0600"
    restart_containers: true
    services:
      - app
      - worker
    content_from: configs/app.json  # load content from a file in the repo (relative to project dir)

How restarts are triggered

gitcompose computes a SHA-256 hash of all managed files for each service and injects it as GITCOMPOSE_FILES_HASH into the generated compose file. When any file content changes the hash changes, and docker compose up -d recreates the affected containers.

The generated compose is written to <base_dir>/generated/<project>/docker-compose.yaml. The user's original docker-compose.yaml is never modified.

Starting gitcompose

gitcompose --config /etc/gitcompose/gitcompose.yaml

gitcompose logs to stdout. On startup it performs an immediate full reconciliation of all discovered projects, then enters the polling/webhook loop.

Flags

Flag Default Description
--config gitcompose.yaml Path to the config file

Permissions

gitcompose must run as root (or with CAP_CHOWN + CAP_DAC_OVERRIDE) if any files.yaml entry specifies a non-default owner/group. Without those capabilities, file writes still succeed but chown is skipped with a warning.

docker compose must be available on $PATH and the running user must have access to the Docker socket.

Running as a systemd service

# /etc/systemd/system/gitcompose.service
[Unit]
Description=gitcompose GitOps daemon
After=docker.service
Requires=docker.service

[Service]
ExecStart=/usr/local/bin/gitcompose --config /etc/gitcompose/gitcompose.yaml
Restart=on-failure
RestartSec=10s
Environment=WEBHOOK_SECRET=changeme

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now gitcompose
journalctl -fu gitcompose

Webhook setup

When webhook.enabled: true, gitcompose exposes:

  • POST /webhook — push event receiver (GitHub, Gitea, GitLab compatible)
  • GET /healthz — returns 200 ok

GitHub / Gitea

In your repository settings, add a webhook:

  • Payload URL: http://your-host:9000/webhook
  • Content type: application/json
  • Secret: the value of webhook.secret
  • Events: Just the push event

gitcompose validates the X-Hub-Signature-256 header.

GitLab

Add a webhook with:

  • URL: http://your-host:9000/webhook
  • Secret token: the value of webhook.secret
  • Trigger: Push events

gitcompose validates the X-Gitlab-Token header (plain-text comparison).

It is recommended to put a reverse proxy (nginx, Caddy) in front of the webhook listener for TLS termination.

Runtime directory layout

/var/lib/gitcompose/
  repo/                         ← git clone of the tracked repository (managed by gitcompose)
    projectA/
      docker-compose.yaml
      files.yaml
    projectB/
      docker-compose.yaml
  managed/
    projectA/
      etc/nginx/nginx.conf      ← written by gitcompose from files.yaml
    projectB/
  generated/
    projectA/
      docker-compose.yaml       ← merged compose passed to docker compose; never edit manually
    projectB/
      docker-compose.yaml

Preprocessors & secrets management

A preprocessor rule matches files in the repo by a doublestar glob pattern and pipes them through a shell command before reconciliation. The command's stdout replaces the file's content in the working copy — the repository on disk is not modified.

preprocessors:
  - match: "**/*.b64"
    command: "base64 -d {file}"
  - match: "**/*.sops.yaml"
    command: "sops -d {file}"
  - match: "**/secrets.age"
    command: "age --decrypt --identity /etc/age/key.txt {file}"

Rules are applied in order; multiple rules can match the same file. The command receives the file content on stdin and must write the result to stdout — the repository working copy is never modified.

How preprocessors and files.yaml interact

Preprocessing happens at the moment a content_from file is read, just before its content is written to the managed directory. The source file in the repo is never modified. The destination filename is always determined by the path: field in files.yaml, not by the source filename.

For example, storing an HTML file as base64 in the repo:

index.html.b64          (in repo, base64-encoded — never modified)
      │
      │  os.ReadFile → raw bytes piped to: base64 -d (via stdin)
      ▼
      │  files.yaml: content_from: index.html.b64
      ▼
<base_dir>/managed/<project>/usr/share/nginx/html/index.html   (written to host)
      │
      │  bind mount (injected into generated compose)
      ▼
/usr/share/nginx/html/index.html                               (inside container)

The source file is just a content carrier. The destination path is entirely controlled by the path: field in files.yaml.

Secrets with sops

Install sops and configure your key backend (AWS KMS, GCP KMS, age, PGP, etc.). Commit encrypted files to the repo and add a matching preprocessor rule:

preprocessors:
  - match: "**/files.yaml.sops"
    command: "sops -d {file}"
  - match: "**/.env.sops"
    command: "sops -d {file}"

The decrypted content is never written back to the repository clone.

Note

: the sops binary and any required credentials (environment variables, IAM role, key file) must be available to the gitcompose process.

Secrets with age

preprocessors:
  - match: "**/secrets.age"
    command: "age --decrypt --identity /etc/age/key.txt {file}"

License

MIT