Anurag Gupta

All posts

ConOps: I wanted Argo CD without the Kubernetes

2026-02-06

I spend my workdays inside Kubernetes, and the thing I miss the second I leave it is Argo CD. Push to Git, the cluster converges, drift gets corrected, you go do something else. Then I'd get home to my own servers, which run plain Docker Compose, and the deploy process was me SSHing in to run docker compose up by hand like it was 2014. Same person, two eras, depending on which building I was in.

That split annoyed me enough that over a few weeks in January and February I built ConOps: point it at a Git repo with a compose file in it and it clones, deploys, polls for new commits, and reconciles. One Go binary. No cluster. The Argo CD feeling on a box that has never heard of Kubernetes.

The reconciliation loop

The whole thing is one goroutine in an infinite loop on a configurable tick (default 30 seconds). I resisted the urge to make it clever. A loop you can read top to bottom beats an event system you have to debug at midnight.

func (c *Controller) Run(ctx context.Context) error {
    ticker := time.NewTicker(c.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return nil
        case <-ticker.C:
            if err := c.reconcile(ctx); err != nil {
                c.logger.Error("reconcile failed", "err", err)
            }
        }
    }
}

Each reconcile call does four things:

  1. git pull --rebase on the local clone (via go-git, in-process)
  2. Parse the compose file into a normalized service map
  3. Hash each service's config and compare against the stored hashes from the last apply
  4. For any mismatch: stop the old container, remove it, recreate from the compose definition

State lives in SQLite, embedded via modernc.org/sqlite, which is pure Go with no CGO. I picked that specifically so the whole thing stays a single static binary you can scp onto a box and run. Every reconciliation writes a row with the commit SHA, the per-service config hashes, and the outcome, so you get a deployment history for free instead of grepping logs later.

Config hashing for drift detection

The single hardest problem in this whole project was answering one question: is this container different from what Git says it should be? Sounds trivial. It isn't. You can docker inspect a running container, but the output is full of runtime-generated fields (container ID, created timestamp, network endpoint IDs) that change on every docker run. My first version compared the full inspect output and reported drift on literally every iteration. It "redeployed" a perfectly healthy stack every 30 seconds and I watched it do it for a good ten minutes before it clicked.

The fix is to hash only the fields that actually describe desired state:

type ServiceConfig struct {
    ImageDigest string            `json:"image_digest"`
    Env         map[string]string `json:"env"`
    Ports       []string          `json:"ports"`
    Volumes     []string          `json:"volumes"`
    Labels      map[string]string `json:"labels"`
    Networks    []string          `json:"networks"`
    Resources   Resources         `json:"resources"`
}

func (s *ServiceConfig) Hash() string {
    // Sort map keys for deterministic serialization
    data, _ := json.Marshal(s)
    h := sha256.Sum256(data)
    return hex.EncodeToString(h[:])
}

The image field hashes the digest (sha256:abc123...), never the tag (latest), because tags lie. ConOps resolves the tag to a digest through the Docker registry API (HEAD /v2/{name}/manifests/{tag} with Accept: application/vnd.docker.distribution.manifest.v2+json). The nice consequence: a docker push to the same tag now triggers a redeploy on the next loop, which is exactly what you want and what tag-based comparison would have missed.

Labels get filtered to drop the Docker-injected ones (com.docker.compose.project, com.docker.compose.service, and friends) before hashing. Skip that filter and every container looks drifted, because Docker stamps its own metadata on at creation time. That was phantom drift bug number two.

The final hash gets stored right back on the container as a label: conops.config-hash=abc123. Next loop, the controller reads the label off the running container and compares it to the hash of the current compose definition. Match, leave it alone. Mismatch, recreate. The container carries its own answer, so there's no separate state file to fall out of sync.

Handling human interference

Here's the part I like. Someone SSHs into the box and runs docker stop myapp. Next loop, the Docker API returns no running container for that service name, and ConOps brings it back from the compose definition. The box heals itself.

Someone runs docker update --env FOO=bar myapp? The config hash no longer matches, so the container gets killed and recreated from Git state. The loop never asks why things diverged, only whether they did. That indifference is the whole point, and it's the same principle underneath every Kubernetes controller: desired state is declarative, actual state is observed, and the controller drags actual toward desired until they agree. Turns out you don't need a cluster to get that. You need a loop and something to diff against.

In-process Git via go-git

Git operations go through go-git instead of shelling out to the git binary. I went back and forth on this one:

func (r *Repo) Pull(ctx context.Context) (bool, error) {
    w, err := r.repo.Worktree()
    if err != nil {
        return false, err
    }

    beforeHead, _ := r.repo.Head()
    err = w.PullContext(ctx, &git.PullOptions{
        RemoteName:    "origin",
        ReferenceName: r.branch,
        SingleBranch:  true,
        Auth:          r.auth,
        Force:         true,
    })
    if errors.Is(err, git.NoErrAlreadyUpToDate) {
        return false, nil
    }
    afterHead, _ := r.repo.Head()
    return beforeHead.Hash() != afterHead.Hash(), err
}

The upside is no dependency on whatever git the host does or doesn't have installed, which keeps the single-binary promise intact. The cost is real though: go-git doesn't do everything, sparse checkout for one, so I just clone the whole repo. For a compose repo that's a few hundred KB, so I stopped worrying about it. If you're pointing this at a monorepo, that trade goes the other way and you should know it going in.

SSH auth uses ssh.NewPublicKeysFromFile, HTTPS uses http.BasicAuth with a token, and credentials are passed once at startup via flags or env vars. Nothing clever, on purpose.

Embedded web UI

The UI is vanilla HTML and JS baked into the binary with embed.FS. No build step, no npm, nothing to serve separately. It ships inside the same file as everything else:

//go:embed ui/dist/*
var uiFS embed.FS

func (s *Server) setupRoutes() {
    stripped, _ := fs.Sub(uiFS, "ui/dist")
    s.mux.Handle("/", http.FileServer(http.FS(stripped)))
    s.mux.HandleFunc("/api/services", s.handleServices)
    s.mux.HandleFunc("/api/history", s.handleHistory)
    s.mux.HandleFunc("/api/logs/", s.handleLogs)
}

/api/services returns the live state of each service: name, status (synced/drifted/error), current image, config hash, uptime. /api/history returns the deployment timeline from SQLite. /api/logs/{service} streams container logs through the Docker API's ContainerLogs endpoint with follow=true.

The frontend polls /api/services every 5 seconds and repaints a status grid. Watching a red "drifted" badge flip to green a few seconds after the loop fixes things is stupidly satisfying, and I'm not going to pretend it isn't the reason I built the UI at all. There's a manual sync button too, for when you've just pushed and can't stand waiting the full 30 seconds.

It found a small crowd of homelab people and picked up a few dozen stars, and they run it against setups I'd never have thought to test. That's how I found out about a private-registry bug within a week of telling anyone it existed. Which is the honest argument for shipping the thing that scratches your own itch: someone else's itch is slightly different, and they'll find the corner you rounded off.