Deploy stacks from a Git repository (0.58.0)
CI / check (push) Successful in 13m10s
CI / build-and-push (push) Successful in 3m50s

StackPilot's stacks were already plain folders on disk, which makes GitOps less
of an architectural change than it would be elsewhere: a sync is "make these
files match that repo, then compose up". Almost all of the design effort went
into the word "these", because getting it wrong destroys data.

A stack folder is not just the compose file. Compose creates bind-mount
directories in it — ./config, ./data — and those hold the live state of whatever
is running. So the obvious implementation, clone into the stack folder and
git reset --hard, is a data-loss bug waiting for its first `git clean`. Instead
the clone lives in a cache under ${DATA_DIR}/git/<stack> where reset and clean
are safe, and the configured subtree is copied across. No .git ends up in the
stack folder, so backups and the file browser are unaffected too.

Deletion is the other half. Making a folder "match" a repo naively means
removing what the repo does not have, which is exactly the application data
above. So each sync records the paths it wrote, and the next sync may delete
only those — a file the repository never provided cannot be touched by any code
path here. Tested directly: a database file and a hand-written .env survive a
sync that replaces the compose file and removes a file the repo dropped.

What the repo does provide is overwritten, hand edits included. That is the
point of GitOps rather than a wart, but it is a surprise if you attach a repo to
a stack you have been editing, so the connect form says it before the first sync
and the first sync is never automatic.

The webhook is the only route in StackPilot with no bearer token, because a Git
forge has none to present. It authenticates with an HMAC over the body —
X-Hub-Signature-256 for GitHub/Gitea/Forgejo, X-Gitlab-Token for GitLab, both
compared in constant time — and answers 404, not 403, to anything unsigned. A
403 would confirm that a given stack exists and is connected to a repository,
which an unauthenticated caller has not earned. The authorization matrix test
caught this route being public and made me write that reasoning down in it,
which is exactly what that test is for.

Credentials never reach a command line: ps is readable by every process on the
host, and this runs in a container next to everything else. The HTTPS token goes
to git through GIT_ASKPASS and the environment, the SSH key through a 0600 file
kept outside the working tree, and everything git prints is scrubbed of both —
plus any credential-carrying URL — before it is stored in last_error or shown.

Auto-deploy takes the same per-stack lock as every other lifecycle action, so a
webhook firing mid-deploy reports "files synced, stack busy" instead of racing a
second compose run at the same project.

The image needed git and openssh-client, which is the only reason this release
touches the Dockerfile.

26 tests against real repositories created with the real git binary, none of
them touching the network — mocking git would mostly test the mock. Verified end
to end as well: connect, sync, a push that changes one file and deletes another,
a wrongly signed webhook, a correctly signed one, and the live data still there
afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-18 01:06:29 +02:00
co-authored by Claude Opus 5
parent e650aa6833
commit 9247ff9621
14 changed files with 1763 additions and 5 deletions
+53
View File
@@ -13,6 +13,43 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete.
## Upgrading to 0.58.0 — deploy stacks from Git
A stack can now be backed by a Git repository. **Stack detail → Git**: point it
at a repo, pick a branch and optionally a subdirectory, and StackPilot keeps the
stack's files matching what the repo says — on demand, on a polling interval, or
on a push webhook.
**What a sync does, exactly.** It clones into a cache under
`${DATA_DIR}/git/<stack>`, copies the configured subtree into the stack folder,
and runs `compose up -d` when something actually changed (optional). The clone
deliberately does *not* live in the stack folder: compose creates bind-mount
directories like `./config` right there, full of live application data, and a
`git reset --hard` in that folder would take them with it.
**Only files the repository provides are ever deleted.** Each sync records the
paths it wrote; the next one removes those the repo no longer has, and nothing
else. A file the repository never provided cannot be touched — your `.env`,
your `config/`, your database — no matter what. What the repo *does* provide is
overwritten, including anything edited by hand here. That is the point of
GitOps, and the connect form says so before the first sync.
**Webhooks.** Every connected stack gets a payload URL and a secret. GitHub,
Gitea and Forgejo sign the body (`X-Hub-Signature-256`); GitLab sends
`X-Gitlab-Token`; both are accepted and compared in constant time. The endpoint
is the one route in StackPilot without a bearer token — a forge has no session
to present — so it answers **404 to anything unsigned**, including for stacks
that do not exist, and cannot be used to find out which stacks are connected.
**Private repositories** over HTTPS with an access token, or over SSH with a
private key. Both are encrypted at rest, never returned by the API, and never
reach a command line: the token goes to git through `GIT_ASKPASS`, the key
through a 0600 file outside the working tree. Anything git prints is scrubbed of
them before it is stored or shown.
The backend image now ships `git` and `openssh-client`; pulling 0.58.0 is all
that takes. Nothing changes for stacks you do not connect to a repository.
## Upgrading to 0.57.0 — API tokens
**Settings → API tokens** issues long-lived bearer tokens for scripts and CI, so
@@ -389,6 +426,11 @@ it is what your saved destination credentials are encrypted with.
compose** converter.
- **Dashboard** — system resource bar, stack grid with quick actions, and a
recent-activity audit feed.
- **GitOps** — a stack can be deployed from a Git repository (branch and
subdirectory selectable, HTTPS token or SSH key for private repos), synced
manually, on a poll interval or from a push webhook, with optional automatic
`compose up -d`. Only files the repository provides are ever replaced or
removed; live data in the stack folder is untouchable.
- **API tokens** — long-lived bearer tokens for scripts and CI, read-only or
full access, revocable one at a time, stored hashed and shown once. A token
can never do more than the account that owns it, and cannot create tokens or
@@ -944,6 +986,17 @@ GET /api/dashboard/funnel[?refresh=true] (stack-health funnel, 30s TTL cache)
GET /api/dashboard/summary (containers, uptime series, ops activity)
```
### GitOps endpoints
```
GET /api/stacks/{id}/git (admin; the token/key is never returned)
PUT /api/stacks/{id}/git (connect or reconfigure; does not sync)
DELETE /api/stacks/{id}/git (stop tracking; files are left as they are)
POST /api/stacks/{id}/git/sync (fetch, copy, deploy if changed)
GET /api/stacks/{id}/git/webhook-secret POST … (rotate)
POST /api/git/webhook/{id} (from the forge; HMAC-signed, 404 otherwise)
```
### API token endpoints
```