Not everything needs to run on Kubernetes.

stackd is a GitOps daemon for Docker Compose, built for people who don’t want cloud platform complexity just to keep a few self-hosted services running. It sits between your Git repo and your Docker host, watches for changes, pulls updates, and applies them automatically with docker compose up -d. The point is to make Compose feel operationally mature without turning it into Kubernetes.

The problem #

If you run homelab or small production-ish stacks, deployment usually means SSHing into a box, pulling the latest code, and restarting containers manually. That works until it doesn’t. Until you’re managing six stacks across two machines, until you forget which box has the current version of what, until you’re trying to remember why you changed something three weeks ago.

stackd turns that into a repeatable flow driven by Git. Your repo is the source of truth. When you push, things deploy. You don’t have to be there.

That’s the whole pitch. The rest is just implementation.

What it does #

stackd is a daemon. You run it in a container, give it access to your Docker socket, point it at one or more Git repositories, and it handles the rest:

  1. Polls each repo on a configurable interval
  2. On SHA change, runs docker compose up -d for each stack in the repo
  3. Surfaces state (container health, last sync, recent activity) in a live dashboard
  4. Optionally wraps deploys with infisical run -- so secrets come from Infisical, not .env files

No CRDs, no controllers, no cluster. Just Git + Docker Compose + a daemon that connects them.

Getting started #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
services:
  stackd:
    container_name: stackd
    image: ghcr.io/antnsn/stackd:latest
    environment:
      - SECRET_KEY=your-strong-random-value
      - DB_URL=sqlite:///data/stackd.db
      - PORT=8080
    volumes:
      - /path/to/stackd-data:/data
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - "8080:8080"
    restart: unless-stopped

Generate the key:

1
openssl rand -hex 32

SECRET_KEY is required. stackd uses it to encrypt SSH keys and tokens at rest. It won’t start without one.

After it’s running, open http://localhost:8080, go to Settings, add your first repository. On the next sync interval it clones the repo, finds your compose stacks, and applies them.

How the sync loop works #

Polling, not webhooks. That’s a deliberate choice. Webhooks require ingress, they require the daemon to be publicly reachable, they add failure modes. A 60-second poll is boring, reliable, and self-healing.

When a sync runs:

  • Pull the repo
  • Compare HEAD SHA against last known SHA
  • If changed: for each compose file in the stacks directory, docker compose up -d
  • Update state, emit an activity event

If a pull fails, stackd backs off exponentially: 2 min, 4, 8, capped at 8× the sync interval. After 10 consecutive failures it suspends the repo entirely. Manual sync from the dashboard resets backoff immediately.

Repo layout #

1
2
3
4
5
6
7
8
repo/
  stacks/
    postgres/
      docker-compose.yml
    grafana/
      docker-compose.yml
    jellyfin/
      docker-compose.yml

Each subdirectory is a stack with independent state. If one fails to apply, the others still run. You’re not blocked on a bad service file bringing down the whole sync.

Secrets without .env files #

The .env antipattern is everywhere. A file full of plaintext secrets, sitting on the filesystem, maybe gitignored if you remembered. Fine until it isn’t.

stackd integrates with Infisical for secrets injection. Configure a global machine token in Settings and docker compose up -d becomes infisical run -- docker compose up -d for any stack whose compose file uses ${} variable substitution. Compose reads them from the environment as normal; they just don’t live in a file.

For stacks that need their own project or environment, drop an infisical.toml in the stack directory:

1
2
3
4
5
6
7
8
9
[infisical]
address = "https://infisical.example.com"

[auth]
strategy = "token"

[project]
project_id = "your-project-uuid"
default_environment = "prod"

Per-stack config takes precedence over the global token. The dashboard shows which mode each stack is using.

Migrating is mechanical:

1
2
3
4
5
6
7
# before
environment:
  POSTGRES_PASSWORD: mysecretpassword

# after
environment:
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

Put the value in Infisical. Remove it from your files. Done.

The dashboard #

The dashboard is built around clarity, status, and fast operator decisions. What’s running, what broke, and why.

It shows:

  • All repos: sync status, current SHA, last error
  • All stacks: container health per service, Infisical mode, last apply timestamp
  • Real-time log streaming via SSE. Inspect a failure without leaving the browser.
  • Web shell: browser-based terminal into any running container via xterm.js

The log streaming and web shell are the parts I reach for most. When something fails at 2am you don’t want to be assembling SSH commands and docker logs flags. You want to click into the container and see what happened.

Who it’s for #

stackd is for homelabbers, small teams, and self-hosters who want the GitOps model without adopting a whole Kubernetes stack. If you’re running more than a couple of Compose stacks and your current deployment story involves manual SSH steps, it’s worth a look.

If you’re running 50 services across a production cluster with rolling deploys, health-check gating, and a platform team, you want Kubernetes and ArgoCD. stackd is not that. Scope is intentional.


The repo is at github.com/antnsn/stackd. AGPL-3.0, commercial licensing available.