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>
1047 lines
56 KiB
Markdown
1047 lines
56 KiB
Markdown
# StackPilot
|
||
|
||
A self-hosted Docker Compose manager for power users and homelab enthusiasts —
|
||
as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
||
|
||
> **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
|
||
> Life) + Phase 4 (Operations) + Phase 6 (Backup destinations) + Phase 7
|
||
> (Scheduled backups) + Phase 9 (Networks) + Phase 10 (iGPU passthrough) +
|
||
> Phase 11 (Network attach) + Phase 12 (File browser) + Phase 13 (Network &
|
||
> image management) + Phase 15 (Dashboard stack resource usage) +
|
||
> Phase 16 (Volumes page) + Phase 18 (Image prune) + Phase 19 (Compose validate & diff) +
|
||
> Phase 20 (Container management) + Phase 21 (Container terminal) + Phase 22
|
||
> (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
|
||
automation stops needing a password and an hourly login:
|
||
|
||
```bash
|
||
curl -H "Authorization: Bearer sp_…" https://stackpilot.example/api/stacks
|
||
curl -X POST -H "Authorization: Bearer sp_…" \
|
||
https://stackpilot.example/api/stacks/immich/update
|
||
```
|
||
|
||
Each token can be revoked on its own, without signing anyone's browser out.
|
||
|
||
**Scopes.** A token is either *read-only* or *full access*, and the choice is
|
||
independent of who created it — an admin can hand a monitoring script a token
|
||
that cannot change a thing. A token never outranks its owner either: demote the
|
||
account and its tokens drop to read-only with it, disable the account and they
|
||
stop working.
|
||
|
||
**A token cannot make itself permanent.** Creating tokens and creating user
|
||
accounts both now require a signed-in session, so a leaked CI credential cannot
|
||
quietly mint a second one that survives the first being revoked. This is the one
|
||
behaviour change for existing installs: if you were scripting user creation, it
|
||
needs a login rather than a token.
|
||
|
||
**Stored hashed.** Unlike registry passwords — which have to be handed back to a
|
||
registry — a token is only ever compared against, so only a SHA-256 of it is
|
||
kept. It is shown once, when you create it, and cannot be recovered; the UI
|
||
keeps a readable prefix (`sp_AbCdEfGh…`) so you can tell rows apart in the list
|
||
and in the audit log. Tokens record when they were last used, so a stale one is
|
||
easy to spot.
|
||
|
||
Optional expiry in days, `token.create` / `token.revoke` in the audit log, and
|
||
the whole section is admin-only. The live WebSocket streams (logs, terminal,
|
||
deploy console) still need a session token — a CI job has no use for them.
|
||
|
||
## Upgrading to 0.56.0 — private registries, and a silent bug fixed
|
||
|
||
**Update checks on private images were lying.** StackPilot asks the registry for
|
||
a tag's current digest itself, and it could only do that anonymously. A private
|
||
repository answers `401`, the check gave up, and the result was indistinguishable
|
||
from a network blip — so the Images page said nothing and a stack sitting on a
|
||
six-month-old image looked up to date. It now says
|
||
`ghcr.io needs credentials`, or `ghcr.io rejected the stored credentials` when
|
||
there are some and they are wrong.
|
||
|
||
**Settings → Private registries** takes a login per registry (Docker Hub,
|
||
ghcr.io, or your own), with a *Test* button that actually asks the registry. Add
|
||
one and the update check starts working for those images.
|
||
|
||
The same credentials also reach `docker compose pull`. StackPilot writes a
|
||
Docker CLI config into `${DATA_DIR}/docker/config.json` (mode 0600, regenerated
|
||
from the database on every change) and runs compose with `DOCKER_CONFIG` pointed
|
||
at it — so pulling a private image works without anyone running `docker login`
|
||
inside the container, and removing a registry in the UI actually revokes the
|
||
CLI's access instead of leaving a stale login behind.
|
||
|
||
Passwords are encrypted at rest with the same key as backup destinations, and
|
||
are never sent to the browser — not even masked. Editing a registry with the
|
||
password field left blank keeps the stored one. A registry whose password
|
||
cannot be decrypted (a changed `SECRET_KEY`) is skipped with a warning rather
|
||
than taking the others down with it.
|
||
|
||
Host spellings are normalized, which is the join that makes the whole thing
|
||
work: `docker.io`, `index.docker.io`, `https://index.docker.io/v1/` and
|
||
`registry-1.docker.io` are one registry, because a bare `nginx:alpine` resolves
|
||
to the last of those while nobody types it that way. Prefer an access token over
|
||
your account password — read scope is enough.
|
||
|
||
Nothing to do if you only use public images.
|
||
|
||
## Upgrading to 0.55.0 — nothing to do
|
||
|
||
**Images and Networks are grouped by stack too**, the same way Volumes were in
|
||
0.54.0: a heading per stack with its icon and name, the rows under it stripped
|
||
of the `<project>_` prefix, and anything unclaimed at the bottom. Compose names
|
||
a stack's own network `<project>_default`, so that column gets a lot quieter.
|
||
|
||
Two group kinds are new, because those two pages have cases volumes do not:
|
||
|
||
- **Shared by several stacks.** An image has no compose label — its owners are
|
||
worked out from the containers running it, so `postgres:16` can belong to four
|
||
stacks at once. Listing it under each would show the same image four times
|
||
with four sizes, and the page would add up to more disk than the host has. It
|
||
is listed once instead, and the *Used by* column names the stacks.
|
||
- **Docker built-ins.** The `bridge`, `host` and `none` networks belong to no
|
||
stack but are not leftovers either, so they sit in their own group below the
|
||
unassigned one rather than padding it.
|
||
|
||
Group headings also carry a count: volumes show unused and total size, images
|
||
total size and how many have an update waiting, networks how many are idle.
|
||
|
||
The grouping itself is now one shared function and one shared heading component
|
||
for all three pages, so a stack looks and sorts the same wherever it appears.
|
||
Nothing changed on the server — every one of these already knew its stack.
|
||
|
||
## Upgrading to 0.54.0 — nothing to do
|
||
|
||
**The Volumes page is grouped by stack.** Docker names a compose volume
|
||
`<project>_<name>`, so an alphabetical list already put a stack's volumes next
|
||
to each other — but you were left reading prefixes to work out whose was whose.
|
||
Each stack is now a heading, with its icon, its name, and how many volumes it
|
||
owns (plus how many are unused, and their total size once you hit *Compute
|
||
sizes*). The rows below it drop the prefix and show only the part that differs:
|
||
`pgdata`, not `immich_pgdata`.
|
||
|
||
Stacks are ordered by the name you gave them, and **volumes that belong to no
|
||
stack come last** — they are the ones you scroll past rather than look for.
|
||
|
||
One group is worth knowing about: volumes still labelled with a compose project
|
||
that is no longer a stack. They are not loose, so they do not land in the
|
||
unassigned group; they get their own heading marked **stack removed**. That is
|
||
where data left behind by a deleted stack collects, and it was previously
|
||
invisible in a flat list.
|
||
|
||
Nothing moved on the server and no endpoint changed — the owning stack was
|
||
already on every volume.
|
||
|
||
## Upgrading to 0.53.0 — nothing to do
|
||
|
||
A dark-mode fix. 0.52.0 put every app logo on a white tile so that black line
|
||
art would not disappear; in dark mode that made each icon look like a sticker
|
||
pasted onto the row.
|
||
|
||
The tile is now the same neutral surface the rest of the UI uses, and only art
|
||
that genuinely vanishes into it gets a backing plate. The browser measures each
|
||
image once — how light it is *and* how colourful — because luminance alone gets
|
||
it wrong: Plex is dark orange and Home Assistant a mid blue, and both read
|
||
perfectly well on either ground. A plate needs low contrast **and** art with
|
||
essentially no colour of its own.
|
||
|
||
Across 66 common logos that means six get a plate in dark mode (Vaultwarden,
|
||
Tailscale, Frigate, Heimdall, Miniflux, MinIO — all solid black) and two in
|
||
light mode (Ollama, Open-WebUI — solid white). The other ~90% sit bare on the
|
||
tile, which is what they were always meant to do. Measuring falls back to "no
|
||
plate" wherever it cannot run.
|
||
|
||
## Upgrading to 0.52.0 — nothing to do
|
||
|
||
The icons 0.51.0 introduced are the **real app logos** now. A stack called
|
||
`jellyfin` shows the Jellyfin logo, `vaultwarden` the Vaultwarden shield,
|
||
`postgres` the elephant — drawn from the selfh.st icon set, the same ~2900-app
|
||
catalog Homarr and Homepage use. All 83 bundled templates resolve to their own
|
||
logo.
|
||
|
||
**Your browser never talks to the icon CDN.** The backend downloads the catalog
|
||
on startup (and weekly after that), then each logo once, the first time any
|
||
stack needs it. Both land in `${DATA_DIR}/stack-icons/`, and logos are cached by
|
||
app rather than by stack, so ten Postgres stacks share one file. From then on
|
||
the whole thing works offline, and the browser fetches logos from StackPilot's
|
||
own authenticated endpoint like any other icon.
|
||
|
||
**An install with no outbound internet keeps working**, it just keeps the
|
||
built-in glyphs: every lookup returns "no logo" instead of failing, and the
|
||
name-derived glyph from 0.51.0 is still the backstop — for an unrecognised name
|
||
(`Mediaserver Wohnzimmer`), for an air-gapped box, and for the first seconds
|
||
after a fresh install while the catalog downloads.
|
||
|
||
Matching got a second source: when the **name** says nothing, the **compose
|
||
images** are asked. A stack called `medienserver` running
|
||
`lscr.io/linuxserver/jellyfin` gets the Jellyfin logo anyway. Matching is
|
||
deliberately cautious — a single short word never claims a logo, because a wrong
|
||
one is worse than a neutral glyph.
|
||
|
||
The picker now searches that catalog too, so you can correct a match or give a
|
||
stack any app's logo by hand. Automatic, built-in glyph and your own upload all
|
||
still work exactly as before.
|
||
|
||
## Upgrading to 0.51.0 — nothing to do
|
||
|
||
Stacks have icons now, and your existing ones already have theirs. The icon is
|
||
derived from the stack's name at render time — `jellyfin` gets a clapperboard,
|
||
`postgres` a database, `home-assistant` a house — so nothing is backfilled and
|
||
nothing needs configuring. A stack whose name matches no keyword gets a neutral
|
||
mark, and renaming a stack moves its icon with it.
|
||
|
||
**The status dot is gone.** In the stacks list and on the detail page the status
|
||
is carried by the icon instead: a soft glow in the status colour, pulsing while
|
||
an operation runs. The status badge next to it still spells the state out in
|
||
words, so nothing depends on seeing the colour.
|
||
|
||
To override an icon, click it on the stack detail page (or the one beside the
|
||
name field in the editor): keep it automatic, pick from the built-in catalog, or
|
||
upload your own PNG / JPEG / GIF / WebP / SVG up to 512 KiB. That is admin-only
|
||
and audited as `stack.icon`.
|
||
|
||
Two things change on disk, both handled on first start: the `stack` table gains
|
||
a nullable `icon` column (empty = automatic), and uploaded images are written to
|
||
`${DATA_DIR}/stack-icons/`. If you already back up the data volume, the icons
|
||
ride along with it.
|
||
|
||
## Upgrading to 0.50.0 — nothing to do
|
||
|
||
Two robustness fixes, no configuration changes.
|
||
|
||
- **A render error no longer blanks the window.** Any exception thrown while
|
||
rendering used to unmount the whole React tree: a white page with no
|
||
navigation and no clue what happened. An error boundary now shows what broke
|
||
with a way out, and clears itself when you navigate to another page.
|
||
- **The bundle is split.** It was one 841 kB file every visitor downloaded in
|
||
full; the entry chunk is now 377 kB (120 kB gzipped) with each page fetched on
|
||
first open. xterm.js, at 294 kB the single largest piece, loads only when
|
||
somebody actually opens a container terminal.
|
||
|
||
## Upgrading to 0.49.0 — nothing to do
|
||
|
||
The UI now refreshes when Docker changes instead of asking every few seconds.
|
||
One WebSocket (`/ws/events`) carries container, image, network and volume
|
||
events; the client drops the matching query caches and re-renders. The polling
|
||
intervals stay behind it as a safety net for a dropped socket, at 30–60s rather
|
||
than 5s.
|
||
|
||
Live CPU and memory keep their fast poll on purpose — usage drifts continuously
|
||
and Docker emits no event for it. That request is served from a 4-second
|
||
server-side cache, so it costs one sample per interval no matter how many tabs
|
||
are open.
|
||
|
||
The endpoint existed before this release but nothing used it, and it was not
|
||
usable as it stood: it forwarded every event including three `exec_*` per web
|
||
terminal session, never said *what* had changed, and leaked its reader thread on
|
||
every disconnect. All three are fixed.
|
||
|
||
## Upgrading to 0.48.0 — remote hosts are gone
|
||
|
||
The multi-host feature (the `stackpilot-agent` sidecar and everything that
|
||
proxied to it) has been removed. StackPilot now manages exactly one Docker
|
||
host: the one it runs on.
|
||
|
||
**If you never registered a remote host, nothing changes for you.** Otherwise:
|
||
|
||
- **Stop and remove your `stackpilot-agent` containers.** They will simply sit
|
||
there unused; nothing talks to them any more. The `stackpilot-agent` image is
|
||
no longer built or published.
|
||
- **Registered hosts are deleted from the database on first start**, together
|
||
with the bearer tokens they held. That is deliberate: leaving credentials for
|
||
a feature that no longer exists is worse than dropping them.
|
||
- **Backup schedules that targeted a remote stack will fail** with "stack not
|
||
found" until you delete them under Settings → Scheduled backups. Their
|
||
`agent_id` column is dropped where SQLite supports it.
|
||
- **Stacks that lived on a remote host are untouched on that host** — StackPilot
|
||
just no longer sees them. Their compose files are still in that host's stacks
|
||
directory and `docker compose` still works there, which is the point of the
|
||
file-is-the-truth model.
|
||
|
||
Gone with it: the host switcher on the Files page, the per-host sections on
|
||
Stacks / Networks / Images / Volumes / Dashboard, the host selector in the New
|
||
Stack editor and the template dialog, Settings → Remote hosts, and the
|
||
`/api/agents/*` and `/ws/agent-*` endpoints (57 API routes and 3 WebSocket
|
||
routes in total).
|
||
|
||
## Upgrading to 0.47.0 — nothing to do
|
||
|
||
Three pieces of state moved out of process memory and into the database, and
|
||
the live-stats endpoint got a cache. No configuration changes, no migration
|
||
steps; the new tables are created on first start.
|
||
|
||
- **Stacks can only run one compose operation at a time.** A second `start` /
|
||
`update` / `down` on a busy stack answers `409` instead of racing the first
|
||
one over the same containers. Auto-update skips a stack you are already
|
||
deploying and picks it up next cycle.
|
||
- **`GET /api/stacks/stats` is cached for four seconds.** It sampled every
|
||
running container on every call, and the dashboard and the stacks list both
|
||
poll it every five seconds — two open tabs on a 40-container host meant a
|
||
sustained ~16 daemon calls a second.
|
||
- **The update cache and the login rate limiter persist.** Both used to reset on
|
||
restart; the rate limiter also used to multiply by the worker count, so
|
||
neither behaved as documented with `--workers` set.
|
||
|
||
## Upgrading to 0.46.0 — everyone is signed out once
|
||
|
||
Sessions now hold their access token in memory and the refresh token in an
|
||
httpOnly cookie, so **the upgrade signs everybody out exactly once**. Sign back
|
||
in and it behaves as before, including staying signed in across restarts.
|
||
|
||
What changed and why:
|
||
|
||
- **Tokens can be revoked.** Each account carries a `token_version` that every
|
||
token is minted with and every request checks. Resetting a password, changing
|
||
a role or disabling an account now bumps it, which cuts off the tokens that
|
||
account already holds — previously a password reset was cosmetic and whoever
|
||
had the old refresh token kept full access for up to 30 days.
|
||
- **The refresh token left `localStorage`.** It is an httpOnly cookie
|
||
(`SameSite=Lax`, scoped to `/api/auth`), so a successful XSS can act inside the
|
||
open page but cannot walk off with 30 days of access. The cookie is marked
|
||
`Secure` only when the request arrived over HTTPS, so a plain-HTTP homelab
|
||
keeps working. Any refresh token left in `localStorage` by an older build is
|
||
deleted on first load.
|
||
- **"Sign out everywhere"** in the user menu revokes every token the account
|
||
holds, on every device. Plain "Sign out" only ends the session on that device.
|
||
- **WebSockets re-check the database.** Log streams and the container terminal
|
||
read the role from the live user instead of the token's claim, so a demotion
|
||
or a disabled account takes effect immediately — the terminal is
|
||
root-equivalent on the host.
|
||
|
||
Scripted clients that cannot hold a cookie can still get the refresh token in
|
||
the response body with `?in_body=true` on login and refresh.
|
||
|
||
## Upgrading to 0.44.0 / 0.45.0 — two defaults changed
|
||
|
||
0.44.0 closes a privilege-escalation hole and tightens two defaults. Both
|
||
changes can affect an existing install:
|
||
|
||
1. **The `user` role loses read access to secrets.** The file browser (page and
|
||
`/api/files/*`), the host-path picker, the audit log, `GET /api/stacks/{id}/export`,
|
||
a stack's `.env` and the template *detail* route (0.45.0 — "save stack as
|
||
template" snapshots the stack's real `.env`) are now admin-only. The template
|
||
*listing* stays open. Previously any logged-in account
|
||
could download `stackpilot.db`, every `.env` and every `.secrets/*` file —
|
||
and none of it was audit-logged. If you gave someone a `user` account so they
|
||
could look at stacks, they still can; they just no longer get the
|
||
credentials. Nothing changes for admins.
|
||
2. **`/` is no longer a default browse root.** The new default is
|
||
`/mnt,/media,/srv,/opt,/home`. A `/` entry makes the sandbox allow every
|
||
path, which is why it is gone — if you relied on it, set
|
||
`ALLOWED_BROWSE_ROOTS` explicitly in your `.env`. StackPilot's own `DATA_DIR`
|
||
is refused either way.
|
||
|
||
Two things also get fixed without any action on your part: the backend now runs
|
||
uvicorn with `--proxy-headers`, so the login rate limit works per client IP
|
||
instead of globally and the audit log records real IPs; and backup-destination
|
||
credentials are encrypted at rest, with existing rows migrated on first start.
|
||
That encryption is keyed off `SECRET_KEY`, which is now persisted to
|
||
`${DATA_DIR}/secret_key` when you have not set one — so restarts no longer log
|
||
everyone out. **If you have never set `SECRET_KEY`, do not delete that file**;
|
||
it is what your saved destination credentials are encrypted with.
|
||
|
||
## What works today (Phase 1)
|
||
|
||
- **File-first stacks** — every stack is a plain `compose.yaml` (+ optional `.env`)
|
||
on disk. The DB only stores metadata; nothing is locked in.
|
||
- **Auth** — JWT access/refresh tokens, bcrypt hashing, admin/user roles, and a
|
||
first-launch setup wizard that creates the initial admin account. The access
|
||
token is held in memory; the refresh token is an httpOnly cookie. Every token
|
||
carries the account's `token_version`, so a password reset, role change or
|
||
disable revokes the tokens that account already holds — on every device.
|
||
- **Stack lifecycle** — create, edit, clone, delete, and `up / down / start /
|
||
stop / restart / pull / update` via `docker compose`.
|
||
- **Live status** — running / partial / stopped / error / updating, computed from
|
||
Docker container labels. In the stack list the status is worn by the stack's
|
||
**icon**, as a halo in the status colour, instead of a separate dot.
|
||
- **Stack icons** — every stack gets the real logo of the app it runs, found
|
||
from its name or its compose images (~2900 apps, fetched once by the server
|
||
and cached), or a name-derived glyph when nothing matches — or whatever you
|
||
pick or upload yourself.
|
||
- **One operation per stack** — a lifecycle call takes a lock (a row, so it
|
||
holds across workers and across a restart) and a second one gets `409` while
|
||
it is held; auto-update skips a stack somebody is already deploying. Locks
|
||
carry an expiry, so a worker killed mid-deploy does not strand a stack.
|
||
- **Resilient UI** — a render error shows what broke and offers a way out
|
||
instead of blanking the window, and clears itself when you navigate away.
|
||
Routes are code-split, so the entry bundle is 377 kB rather than 841 kB and
|
||
the container terminal's xterm.js only loads when a terminal is opened.
|
||
- **Event-driven UI** — a single `/ws/events` connection carries Docker's own
|
||
container / image / network / volume events; the client drops the matching
|
||
caches so pages refresh the moment something changes, instead of every page
|
||
polling on a timer. The intervals remain as a slow fallback. Live CPU and
|
||
memory still poll, because usage drifts with no event to announce it.
|
||
- **Real-time logs** — streamed over WebSocket, color-coded per service.
|
||
- **Live deploy console** — deploying from the editor streams `compose up`
|
||
output (image pulls, container creation) over a WebSocket in real time instead
|
||
of a blind spinner; the deploy keeps running server-side if the modal is closed.
|
||
stream through to the browser. Compose runs with `--progress json`, so the
|
||
console shows a **real progress bar** (download bytes per layer, weighted by
|
||
layer size, then container create/start) plus a per-image bar; the raw output
|
||
is kept below it and updates one line per layer instead of scrolling past.
|
||
- **Inline update progress (0.42.0)** — hitting Update on a stack streams
|
||
`compose pull && up -d` over `/ws/update/{stack_id}` and folds it into a
|
||
**progress bar inside that stack's row** (same `--progress json` weighting as
|
||
the deploy console: "Pulling images · 3/7 layers · 88 MB / 190 MB"). Several
|
||
stacks can update at once — busy state and progress are tracked per stack, so
|
||
the rows advance independently. The REST `POST /api/stacks/{id}/update` stays
|
||
for non-interactive callers and as the fallback when no token is available.
|
||
- **Monaco editor** — YAML editing with an `.env` tab and a **`docker run` →
|
||
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
|
||
users — those need a signed-in session.
|
||
- **Private registries** — a login per registry (Settings → Private registries)
|
||
used both by StackPilot's own update checks and by `docker compose pull`,
|
||
which it reaches through a generated `DOCKER_CONFIG`. Passwords are encrypted
|
||
at rest and never leave the server. Without one, an image whose registry
|
||
demands auth now reports *needs credentials* instead of quietly looking up to
|
||
date.
|
||
- **Auto-discovery** — stacks created outside the UI (any folder under the stacks
|
||
dir containing a compose file) are picked up automatically.
|
||
- **Dark / light theme.**
|
||
|
||
### Phase 2 — Volumes & GPU
|
||
|
||
- **Volume Wizard** in the editor (right-hand helper panel): Bind / Named / NFS /
|
||
SMB-CIFS / tmpfs, with a sandboxed **host path browser** for bind mounts and a
|
||
live YAML preview. NFS/SMB `driver_opts` are generated for you.
|
||
- **GPU assignment** per service: auto-detects NVIDIA (`nvidia-smi`) and AMD/Intel
|
||
(`/dev/dri` + sysfs), injects the right YAML (NVIDIA `deploy.reservations`, or
|
||
`/dev/dri` passthrough + render/video groups + `LIBVA_DRIVER_NAME=iHD` for Intel).
|
||
- **Device passthrough**: lists host USB / serial-TTY / DRI nodes, add per service,
|
||
plus a guarded `privileged` toggle.
|
||
- All wizard edits are merged into the compose YAML **server-side** (robust,
|
||
validated) and returned to the editor for review before saving.
|
||
|
||
> GPU/device detection needs host visibility. The bundled compose bind-mounts
|
||
> `/dev:/dev:ro`; NVIDIA additionally requires the NVIDIA container runtime on the host.
|
||
|
||
### Phase 3 — Quality of Life
|
||
|
||
- **Env editor**: table mode with sensitive-value masking (`PASS`/`SECRET`/`KEY`/…
|
||
auto-detected) + raw mode, plus PUID/PGID/TZ quick-insert.
|
||
- **Image update checker**: background task compares the local manifest digest with
|
||
the registry (Docker Hub / ghcr / lscr / private v2 with token auth); update
|
||
badges on the Images page + an "updates available" banner on the dashboard.
|
||
Results are cached in the database, so a restart shows the badges immediately
|
||
instead of blanking them until the next sweep — and does not re-announce
|
||
updates it already notified about.
|
||
- **Port conflict detector**: pre-deploy check against host-bound ports
|
||
(`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog.
|
||
- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`.
|
||
- **Template library**: each template is a ready-to-run **stack folder** in git
|
||
(`backend/templates/<slug>/` — `compose.yaml` + `.env.example` + `template.json`,
|
||
plus any extra config the app needs, e.g. `prometheus.yml` or a `Caddyfile`).
|
||
83 bundled homelab apps across media, *arr automation, networking, reverse
|
||
proxies, VPN, SSO, monitoring, dashboards, files/backup, notes and wikis, home
|
||
automation, dev tooling, databases, local AI and more — searchable and
|
||
filterable by tag on the Templates page. "Pull" copies the whole folder into a
|
||
new stack (`.env.example` → `.env`) which you then edit and deploy. Save any
|
||
stack back as a custom template (stored under `${DATA_DIR}/templates/`). Add
|
||
your own by dropping a folder into the templates dir.
|
||
- **Healthcheck status** surfaced per container in the stack overview.
|
||
|
||
### Phase 4 — Operations
|
||
|
||
- **Self-update (0.32.0)**: the top-bar version badge checks the registry for a
|
||
newer StackPilot release on page load (`GET /api/system/update`, anonymous v2
|
||
token flow, 10 min cache) and shows an amber update pill. One click
|
||
(`POST /api/system/update`, admin) spawns a detached helper container that runs
|
||
`docker compose pull && up -d` on StackPilot's own compose project (resolved
|
||
from its container labels) — the helper survives the backend being recreated;
|
||
the UI polls `/api/health` and reloads when the new version answers. Installs
|
||
not managed by compose get a clear "update manually" error instead.
|
||
- **Backup & restore**: per-stack `.tar.gz` backups covering the whole stack —
|
||
the stack folder, every **bind-mounted data directory** (`./config`, `/mnt/appdata/…`)
|
||
and every named volume. Bind sources and volumes are read through a throwaway
|
||
helper container, i.e. by **host path**, so data that StackPilot itself cannot
|
||
see is captured too (that is the case whenever `STACKS_HOST_DIR` differs from
|
||
the container's `STACKS_DIR` — compose then creates the data directories at the
|
||
container path *on the host*, and a naive backup would only find the compose
|
||
file). The Backup dialog shows the full inventory with sizes and lets you pick
|
||
what goes in; **NFS/CIFS-backed volumes are unchecked by default** because they
|
||
live on a NAS and restoring one would overwrite the share. Restore puts bind
|
||
folders back at their host paths and preserves permissions, ownership and
|
||
symlinks (PUID/PGID-based images such as the *arr suite need this), with
|
||
optional rename and overwrite/conflict detection.
|
||
- **Notification webhooks**: ntfy, Discord, Slack, Gotify, or generic JSON, each
|
||
subscribed to chosen events (image update available, stack start/stop/error,
|
||
pull failed). Managed in **Settings → Notifications**; env `NOTIFY_WEBHOOKS`
|
||
still supported for generic endpoints.
|
||
- **Settings page**: tune the update-check interval, manage webhooks, and manage
|
||
users (create/disable/delete, promote/demote, with last-admin safeguards).
|
||
- **Audit log page** (admin): searchable, paginated view of all recorded actions.
|
||
- **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing.
|
||
|
||
### Phase 6 — Backup destinations
|
||
|
||
- **Off-box backups**: define **SFTP**, **S3-compatible** (MinIO, Backblaze B2,
|
||
AWS S3, …) or **NFS share** (0.33.0) destinations under **Settings → Backup
|
||
destinations** (with a Test button; secrets are masked in API responses).
|
||
NFS needs no privileges in the StackPilot container: the Docker daemon mounts
|
||
the export as a named volume (`stackpilot-nfs-dest-<id>`, recreated when the
|
||
config changes) and file I/O runs through throwaway helper containers.
|
||
- **Push & restore**: the stack Backup dialog can push straight to a destination
|
||
instead of downloading; the Restore dialog can browse a destination's backups
|
||
and restore (volumes included) directly from it. Backups can also be
|
||
deleted from the UI.
|
||
|
||
### Phase 7 — Scheduled backups
|
||
|
||
- **Recurring backups**: schedule a stack to back up to a destination **hourly,
|
||
daily, or weekly** (UTC) under **Settings → Scheduled backups**. A background
|
||
scheduler runs due jobs every minute and records last/next run + status.
|
||
- **Retention**: keep the newest *N* backups per stack on the destination; older
|
||
ones are pruned automatically.
|
||
- **Run now** for an on-demand run, plus a `backup_failed` notification event
|
||
wired into the webhook system.
|
||
|
||
### Phase 9 — Networks
|
||
|
||
- **Network management**: the Networks page lists Docker networks **grouped by
|
||
the stack that owns them** (driver, scope, subnet, attached containers /
|
||
in-use), with **create** (bridge / macvlan / ipvlan / overlay, optional
|
||
subnet+gateway, internal/attachable), **delete** (default networks protected;
|
||
in-use guarded by Docker), and **prune unused**. Docker's own `bridge` / `host`
|
||
/ `none` sit in a *built-ins* group at the bottom.
|
||
- **Stack delete**: local stacks can now be deleted from the UI (stack detail and
|
||
the stack card), with a confirm dialog and an optional "keep files on disk".
|
||
|
||
### Phase 10 — iGPU passthrough
|
||
|
||
- **Render/video group detection**: for a passed-through Intel/AMD iGPU, StackPilot
|
||
reads the host group ownership of the `/dev/dri` nodes (render node → `render`
|
||
GID, paired `card` node → `video` GID) and injects them as **numeric**
|
||
`group_add` entries (e.g. `group_add: ["991", "44"]`). Group names rarely
|
||
resolve inside images, so the numeric GID is what actually grants access. The
|
||
GPU selector shows the detected GIDs; removal cleans them (and `LIBVA_DRIVER_NAME`).
|
||
|
||
### Phase 11 — Network attach
|
||
|
||
- **Network attach/detach**: each network row on the Networks page expands to an
|
||
inspect view listing connected containers, with admin controls to disconnect a
|
||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||
/ `/disconnect`).
|
||
|
||
### Stack icons
|
||
|
||
- **The app's real logo, found from the name.** With nothing configured, the
|
||
stack's name — and failing that the images its compose file pulls — is matched
|
||
against the [selfh.st icon catalog](https://selfh.st/icons/) (~2900 apps, the
|
||
set Homarr and Homepage draw on). `jellyfin` → the Jellyfin logo,
|
||
`AdGuard Home` → the AdGuard logo, `medienserver` running
|
||
`lscr.io/linuxserver/jellyfin` → the Jellyfin logo. All 83 bundled templates
|
||
resolve. Matching is conservative on purpose: exact name, then the name with
|
||
punctuation rearranged, then the longest run of words inside it, then the
|
||
images — and a single short word never claims a logo, because a wrong logo is
|
||
worse than a neutral glyph.
|
||
- **Fetched once, by the server.** The catalog is downloaded on startup and
|
||
refreshed weekly; each logo is downloaded the first time a stack needs it.
|
||
Both live in `${DATA_DIR}/stack-icons/`, logos keyed by app rather than by
|
||
stack, so ten Postgres stacks share one file. Browsers never reach the CDN —
|
||
they read logos from the authenticated icon endpoint. With no outbound
|
||
internet nothing breaks; the built-in glyphs simply stay.
|
||
- **Glyph fallback.** A name no catalog knows still gets something better than a
|
||
box: ~700 keywords in 79 groups (English and German) map it to a built-in
|
||
glyph — `Mediaserver Wohnzimmer` → clapperboard, `backup nas` → archive. The
|
||
longest match wins, so `photoprism` beats a bare `photo`.
|
||
- **Nothing to migrate.** The derivation runs at render time, so stacks that
|
||
existed before this feature have icons immediately; the `stack.icon` column
|
||
stays empty until somebody makes an explicit choice. Renaming a stack moves
|
||
its automatic icon with it.
|
||
- **Pick or upload.** Clicking the icon on the stack detail page — or the one
|
||
next to the name field in the editor — opens a picker: keep it automatic,
|
||
search the app-logo catalog (it opens pre-searched for the stack's own name),
|
||
choose a built-in glyph, or upload a PNG / JPEG / GIF / WebP / SVG up to
|
||
512 KiB. Uploads live in `${DATA_DIR}/stack-icons/` and are
|
||
classified by their actual bytes, not by the filename or Content-Type the
|
||
browser claims. Cloning a stack copies its icon; deleting one removes it.
|
||
- **Legible in both themes.** A logo sits directly on the tile; only art that
|
||
would disappear into it — solid black in dark mode, solid white in light mode
|
||
— gets a backing plate. The browser measures each image's luminance and chroma
|
||
once to decide, so a dark *colourful* mark like Plex is left alone.
|
||
- **The status moved onto the icon.** In the stacks list and on the detail page
|
||
the status dot is gone: the icon carries a soft glow in the status colour
|
||
(green running, amber partial, red error, pulsing blue while an operation
|
||
runs). The status badge and tooltip still spell it out in words, so colour is
|
||
never the only carrier.
|
||
- Uploading and resetting an icon is admin-only and audited (`stack.icon`); the
|
||
read-only role sees icons but cannot change them.
|
||
|
||
### Phase 24 — Design System v2 (analytics-style UI)
|
||
|
||
- **New shell**: the sidebar is gone — a fixed 60px top bar carries a pill
|
||
navigation (active route = dark pill), the logo mark, a version badge, a
|
||
theme toggle and an avatar menu. Narrow
|
||
screens get an off-canvas drawer.
|
||
- **Design tokens** (`frontend/src/styles/tokens.css`): one CSS-variable set
|
||
for surfaces, borders, text tiers, brand colours, radii and type scales,
|
||
with class-based dark-mode overrides. Existing Tailwind aliases
|
||
(`bg`/`card`/`accent`) are remapped onto the tokens so all pages reskin
|
||
consistently. Typeface: Schibsted Grotesk (bundled, offline-friendly).
|
||
- **Stack Health funnel** — the dashboard centrepiece. Five stages
|
||
(`discovered → running → healthy → updated → auto-managed`) from
|
||
`GET /api/dashboard/funnel` (30 s server cache, `?refresh=true` to bust;
|
||
the last stage — API key `monitored` — counts stacks with an enabled
|
||
auto-update policy). SVG waterfall with alternating gradient /
|
||
diagonal-hatch bars, value chips and hover conversion/drop-off tooltips.
|
||
- **Summary widgets** from `GET /api/dashboard/summary`: compose-containers
|
||
card and an
|
||
"Insights" chip (healthy-rate %), a 30-day uptime line chart (sampled every
|
||
5 min into `DATA_DIR/uptime.jsonl`, charted as daily averages), and an ops
|
||
contribution grid from audit-log activity with the peak weekday.
|
||
A 30/7-day range selector slices both series client-side.
|
||
- **Explore prompt bar** under the funnel: typed queries or `/running`,
|
||
`/stopped`, `/attention` tags deep-link to the Stacks page, which now
|
||
honours `?q=` and `?filter=` (plus a new status-filter select).
|
||
|
||
### Phase 23 — Secrets & configs (compose file-based)
|
||
|
||
- A **Secrets** tab on the stack detail page manages per-stack Docker
|
||
**secrets** and **configs**: create one by name + content, list them (name,
|
||
kind, size — content is **never** returned by the API), and delete. Content is
|
||
write-only: once saved it is cleared from the form and cannot be read back.
|
||
- Files are stored inside the stack's own directory (`<stack_dir>/.secrets/<name>`
|
||
/ `.configs/<name>`, dir `0700` / file `0600`) and referenced from the compose
|
||
file with a **relative** `file:` path, so the Docker daemon reads them with no
|
||
`HOST_ROOT_PREFIX` dependency — exactly as if dropped next to `compose.yaml`.
|
||
- **Attach/detach** wires a stored secret/config into a chosen service: secrets
|
||
appear at `/run/secrets/<name>`, configs mount at a target path you specify. The
|
||
compose file is rewritten in place (top-level `secrets:`/`configs:` defs are
|
||
pruned when no service still uses them); **redeploy the stack to apply**.
|
||
- **Admin-only** (secrets are sensitive); every write/delete/attach/detach is
|
||
audited (`secret.*`). Names are validated against path
|
||
traversal (single component, no `..`, no leading dot); content capped at 1 MiB.
|
||
|
||
### Phase 22 — Auto-update (Watchtower-style)
|
||
|
||
- A per-stack **Auto-update** policy (on the stack Overview tab): when the
|
||
background image-update check finds a newer registry digest for one of the
|
||
stack's images, the stack is either **pulled + redeployed** or merely
|
||
**flagged** ("notify only"), with a **Check now** button for an on-demand run.
|
||
- Runs inside the existing image-update-check cycle (reuses the freshly-computed
|
||
digest cache, no extra registry calls). Only **running** stacks are
|
||
auto-redeployed — a stopped stack is never silently started ("skipped").
|
||
- New `stack_auto_updated` notification event. Last run + status (updated /
|
||
up-to-date / update-available / skipped / error) are shown inline.
|
||
|
||
### Phase 21 — Container terminal (web exec)
|
||
|
||
- An **interactive terminal** into any running, compose-managed container,
|
||
opened from the terminal button on its container card (stack Overview tab).
|
||
Streams an exec session over WebSocket into xterm.js — pick `/bin/sh`,
|
||
`/bin/bash`, or `/bin/ash`; full TTY with resize.
|
||
- **Admin-only** (exec is root-equivalent): a non-admin token is rejected at the
|
||
WebSocket handshake (`4403`). Only containers with the
|
||
`com.docker.compose.project` label can be reached.
|
||
|
||
### Phase 20 — Container management
|
||
|
||
- The stack **Overview** tab now renders each service as an expandable
|
||
**container card** instead of a static row. Expanding it fetches a curated
|
||
single-container inspect view (image, state + exit code, restart count,
|
||
started-at, networks, mounts, and environment) via
|
||
`GET /api/containers/{id}`.
|
||
- Admins get **per-container start / stop / restart** buttons directly on the
|
||
card (`POST /api/containers/{id}/{action}`), so a single misbehaving service
|
||
can be bounced without touching the rest of the stack.
|
||
- Only containers carrying the `com.docker.compose.project` label are exposed,
|
||
so this never becomes a generic "control any container on the host" backdoor.
|
||
|
||
### Phase 19 — Compose validate & diff
|
||
|
||
- The editor can **validate** a compose file (`docker compose config`) before
|
||
deploying and show a **diff against the currently deployed** definition, so
|
||
you can see exactly what a re-deploy will change.
|
||
|
||
### Phase 18 — Image prune
|
||
|
||
- **Prune images** (dangling, or all unused) from the Images page, on the local
|
||
host.
|
||
|
||
### Phase 16 — Volumes page
|
||
|
||
- **New Volumes page** (sidebar). Lists Docker volumes with driver, in-use
|
||
containers and mountpoint, **grouped by the stack that owns them** — each
|
||
group headed by the stack's icon and name with its volume count, unused count
|
||
and total size, and the rows under it stripped of the `<project>_` prefix.
|
||
Stacks sort by display name; volumes belonging to no stack come last. Volumes
|
||
still labelled with a stack that has been deleted form their own group, marked
|
||
*stack removed* — that is where forgotten data collects.
|
||
- Admin actions: delete a volume (with an in-use warning + force option) and
|
||
**Prune unused**; an *Only unused* filter.
|
||
- **Volume sizes** are loaded on demand via a *Compute sizes* button (runs
|
||
`docker system df`, which walks volume contents and can take a few seconds);
|
||
results are cached ~60s. Endpoint `GET /api/volumes/sizes`.
|
||
- The Volume **Wizard** in the stack editor (bind/named/NFS/SMB/tmpfs YAML
|
||
generation) is unchanged — the new page is for managing/cleaning up volumes.
|
||
|
||
### Phase 15 — Dashboard stack resource usage
|
||
|
||
- **The dashboard now lists stacks in a table** (status, services) with live
|
||
**CPU** and **memory** usage per stack, sampled from `docker stats` and
|
||
aggregated by compose project.
|
||
- When a stack has `deploy.resources.limits` assigned, the bar fills toward that
|
||
limit and shows usage vs the limit (e.g. `0.42 / 1 cores`, `310 MB / 512 MB`);
|
||
otherwise it shows absolute usage against the host total. Inline start/stop/
|
||
restart actions per row for admins. New endpoint `GET /api/stacks/stats`.
|
||
|
||
### Phase 13 — Network & image management
|
||
|
||
- **Network management**: list, inspect, create, delete, prune, and
|
||
connect/disconnect containers — including a *Prune unused* button, which
|
||
resolves the common "all predefined address pools have been fully subnetted"
|
||
deploy error without SSH.
|
||
- **Images**: list image tags **grouped by the stack that uses them** and run
|
||
on-demand update checks. An image has no compose label, so its owners come
|
||
from the containers running it — which means an image can have several, and
|
||
those are listed once under *Shared by several stacks* rather than repeated
|
||
under each.
|
||
|
||
### Phase 12 — File browser
|
||
|
||
- **Files page (sidebar)**: a full host filesystem browser with breadcrumb
|
||
navigation, clickable browse-root chips, an *Up* control, and a show/hide
|
||
hidden-files toggle. Listings show size, permissions and modified time.
|
||
- **View & edit**: clicking a text file opens it in a Monaco editor (with syntax
|
||
highlighting picked from the extension). Binary and oversized files are
|
||
detected and offered as a download instead. Admins can edit and **Save**.
|
||
- **Admin only**: the whole page, including listing, viewing and downloading.
|
||
Reads are not less sensitive than writes here — the browser reaches whatever
|
||
the backend container can see, which includes every stack's `.env` and
|
||
`.secrets/*`. Reading and downloading a file are audit-logged (`file.read`,
|
||
`file.download`); directory listing is not, because the page polls it.
|
||
- **Manage** (admin): create folders/files, rename, delete (recursive for
|
||
folders), upload files **or whole folders** (the directory tree is recreated
|
||
server-side), and download any file. **Copy/cut & paste** moves files and
|
||
folders between directories (clipboard bar + per-row copy/cut, with an
|
||
overwrite prompt on conflict). Every mutation is audit-logged.
|
||
- **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path
|
||
traversal and deleting a browse root are refused. StackPilot's own `DATA_DIR`
|
||
is refused regardless of the setting — it holds `stackpilot.db` with password
|
||
hashes and backup-destination credentials, none of which the API
|
||
itself ever hands out. Note that a single `/` entry in `ALLOWED_BROWSE_ROOTS`
|
||
switches the sandbox off entirely; it is no longer part of the default. To reach the real host
|
||
filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the
|
||
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
|
||
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`,
|
||
`move`, `upload` — with optional `rel_path` for folder uploads —, `download`,
|
||
`DELETE`).
|
||
|
||
## Architecture
|
||
|
||
```
|
||
frontend (React + Vite + Tailwind, served by nginx)
|
||
│ proxies /api and /ws
|
||
▼
|
||
backend (FastAPI + docker-py + SQLite)
|
||
│ docker-py + `docker compose` CLI
|
||
▼
|
||
Docker Engine (via /var/run/docker.sock — never exposed to the browser)
|
||
```
|
||
|
||
## Quick start
|
||
|
||
```bash
|
||
cd stackpilot
|
||
cp .env.example .env
|
||
# edit .env and set a strong SECRET_KEY: openssl rand -base64 48
|
||
docker compose up -d --build
|
||
```
|
||
|
||
Open <http://localhost:5009> and complete the first-launch setup wizard to create
|
||
your admin account.
|
||
|
||
### Configuration
|
||
|
||
All backend settings are environment variables (see `backend/config.py`). The
|
||
most important ones:
|
||
|
||
| Variable | Default | Purpose |
|
||
|-----------------|--------------------|-------------------------------------------|
|
||
| `SECRET_KEY` | _(auto, persisted)_| JWT + at-rest encryption key (see below) |
|
||
| `STACKS_DIR` | `/opt/stacks` | Where stack folders live (in-container) |
|
||
| `DATA_DIR` | `/data` | SQLite DB + app data |
|
||
| `CORS_ORIGINS` | localhost | Allowed API origins (comma separated) |
|
||
|
||
`SECRET_KEY` signs JWTs **and** derives the key that encrypts backup-destination
|
||
credentials in the database. Leave it unset and one is generated and written to
|
||
`${DATA_DIR}/secret_key` (mode 0600) on first start, so sessions and stored
|
||
credentials survive restarts — that file is then part of your backup. Setting it
|
||
explicitly always wins and nothing is written.
|
||
|
||
The host path for stacks is set via `STACKS_HOST_DIR` in `.env`, and it should
|
||
be **the same path as `STACKS_DIR`** (`/opt/stacks` by default). Compose runs
|
||
inside the backend container, so a stack's relative bind mounts (`./config`) are
|
||
resolved against the container path and the daemon creates those directories at
|
||
that path *on the host*. Point `STACKS_HOST_DIR` somewhere else and every stack's
|
||
data lives at `/opt/stacks/<stack>/…` on the host while StackPilot looks at a
|
||
different folder — the file browser and editor then show only the compose file.
|
||
Backups cover the data either way (they read bind sources by host path through a
|
||
helper container) and the Backup dialog warns when the two paths diverge.
|
||
|
||
## Local development
|
||
|
||
Backend:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m venv .venv && source .venv/bin/activate
|
||
pip install -r requirements.txt
|
||
SECRET_KEY=dev STACKS_DIR=../data/stacks DATA_DIR=../data uvicorn main:app --reload --port 5008
|
||
```
|
||
|
||
Frontend (proxies to the backend on :5008):
|
||
|
||
```bash
|
||
cd frontend
|
||
npm install
|
||
npm run dev # http://localhost:5173
|
||
```
|
||
|
||
### Tests & linting
|
||
|
||
Same three commands the CI runs — `build-and-push` only starts once they pass.
|
||
|
||
```bash
|
||
cd backend
|
||
pip install -r requirements-dev.txt
|
||
pytest # 758 tests, no Docker daemon needed
|
||
ruff check .
|
||
cd ../frontend && npx tsc --noEmit -p tsconfig.json && npm test
|
||
```
|
||
|
||
The frontend has a small vitest suite alongside it (`npm test`, jsdom). It
|
||
covers behaviour the typechecker cannot see — currently the error boundary:
|
||
that it renders the error instead of a blank page, offers a way out, and clears
|
||
on navigation so one broken page does not strand you.
|
||
|
||
The backend suite drives the app through `TestClient` **without** the lifespan, so it
|
||
never opens a Docker socket and never starts the background loops; `conftest.py`
|
||
points `DATA_DIR`/`STACKS_DIR` at a temp directory before anything is imported.
|
||
|
||
The load-bearing one is `tests/test_route_authorization.py`. Authorization lives
|
||
in the routers — each route independently picks `require_admin` or
|
||
`get_current_user`, and nothing checked that the choice was right, which is how
|
||
0.43.0 shipped a read-only role that could download the auth database. That file
|
||
states the policy once — *every route requires admin unless it is listed* — and
|
||
fails on any route that disagrees. Adding a route the `user` role may reach means
|
||
adding it to `USER_READABLE` with a note on why it cannot return a credential.
|
||
|
||
`tests/test_token_revocation.py` covers the revoke switch — that each of the
|
||
three authority changes kills the account's tokens, that a cosmetic re-save does
|
||
not, and that the refresh cookie is httpOnly and not marked `Secure` over plain
|
||
HTTP. `tests/test_schema_migration.py` builds a database with the *old* user
|
||
table and asserts the added column is backfilled rather than left NULL, which is
|
||
what would otherwise have signed out every user on every install.
|
||
|
||
`tests/test_docker_events.py` drives the event stream against a fake daemon: that
|
||
`exec_*` noise is dropped before it reaches the client, that the payload names
|
||
the resource so the client knows what to invalidate, and that the stream is
|
||
closed on disconnect — cancelling the executor future does not interrupt a
|
||
thread already inside a blocking read, so without that close every page load
|
||
leaked one.
|
||
|
||
`tests/test_stack_locking.py` and `tests/test_runtime_state.py` cover the state
|
||
that moved into the database: that a busy stack answers 409 without ever
|
||
reaching Docker, that an expired lock is taken over rather than stranding the
|
||
stack, that the stats cache serves repeat callers from one sweep, and that the
|
||
update cache and rate limiter survive a restart. One of them asserts that
|
||
`update_service` never imports the database — it is pure registry logic, and
|
||
persistence stays opt-in so the module remains testable without one.
|
||
|
||
`tests/test_bundled_templates.py` covers the 83 shipped templates: each must
|
||
parse, name an image per service, keep `.env.example` in sync with the variables
|
||
compose actually reads, ship every file it bind-mounts, and never come with a
|
||
working default password.
|
||
|
||
## API surface (Phase 1)
|
||
|
||
```
|
||
POST /api/auth/setup | login | refresh GET /api/auth/me | needs-setup
|
||
POST /api/auth/logout | logout-everywhere
|
||
GET /api/stacks POST /api/stacks
|
||
GET /api/stacks/{id} PUT /api/stacks/{id} DELETE /api/stacks/{id}
|
||
POST /api/stacks/{id}/{start|stop|restart|pull|update|down|clone}
|
||
GET /api/stacks/{id}/logs GET /api/stacks/{id}/export
|
||
POST /api/stacks/convert (docker run → compose)
|
||
GET /api/system/info | gpus | devices GET /api/audit
|
||
GET /api/system/update POST /api/system/update (self-update)
|
||
WS /ws/logs/{stack_id}[/{service}] WS /ws/events
|
||
WS /ws/deploy/{stack_id} WS /ws/update/{stack_id}
|
||
```
|
||
|
||
### Phase 2 endpoints
|
||
|
||
```
|
||
GET /api/volumes | /orphaned DELETE /api/volumes/{name}
|
||
POST /api/volumes/prune POST /api/volumes/generate-yaml
|
||
GET /api/host/paths?path=&show_hidden= (sandboxed browser)
|
||
POST /api/editor/services | add-volume | set-gpu | add-device | remove-device | set-privileged
|
||
```
|
||
|
||
### Phase 3 endpoints
|
||
|
||
```
|
||
GET /api/images | /updates POST /api/images/check
|
||
POST /api/ports/conflicts POST /api/editor/set-resources
|
||
GET /api/templates | /{id} POST /api/templates/{id}/instantiate
|
||
POST /api/templates | /from-stack DELETE /api/templates/custom/{slug}
|
||
```
|
||
|
||
### Phase 4 endpoints
|
||
|
||
```
|
||
GET /api/stacks/{id}/backup?include_volumes=&stop_first= POST /api/stacks/restore
|
||
GET /api/settings PUT /api/settings
|
||
GET /api/settings/webhooks POST /api/settings/webhooks
|
||
PUT /api/settings/webhooks/{id} DELETE /api/settings/webhooks/{id}
|
||
POST /api/settings/webhooks/{id}/test
|
||
GET /api/auth/users POST /api/auth/users
|
||
PATCH /api/auth/users/{id} DELETE /api/auth/users/{id}
|
||
```
|
||
|
||
### Phase 6 endpoints
|
||
|
||
```
|
||
GET /api/backups/destinations POST /api/backups/destinations
|
||
PUT /api/backups/destinations/{id} DELETE /api/backups/destinations/{id}
|
||
POST /api/backups/destinations/{id}/test GET /api/backups/destinations/{id}/backups
|
||
DELETE /api/backups/destinations/{id}/backups/{name}
|
||
POST /api/stacks/{id}/backup/push POST /api/stacks/restore-from
|
||
```
|
||
|
||
### Phase 7 endpoints
|
||
|
||
```
|
||
GET /api/backups/schedules POST /api/backups/schedules
|
||
PUT /api/backups/schedules/{id} DELETE /api/backups/schedules/{id}
|
||
POST /api/backups/schedules/{id}/run
|
||
```
|
||
|
||
### Phase 9 endpoints
|
||
|
||
```
|
||
GET /api/networks | /{id} POST /api/networks
|
||
DELETE /api/networks/{id} POST /api/networks/prune
|
||
DELETE /api/stacks/{id}?delete_files= (stack delete, now surfaced in the UI)
|
||
```
|
||
|
||
### Phase 11 endpoints
|
||
|
||
```
|
||
GET /api/networks/{id}/containers POST /api/networks/{id}/connect | /disconnect
|
||
POST /api/templates/{id}/instantiate (create a stack from a template)
|
||
```
|
||
|
||
### Phase 24 endpoints
|
||
|
||
```
|
||
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
|
||
|
||
```
|
||
GET /api/auth/tokens (admin; the token itself is never returned)
|
||
POST /api/auth/tokens ({"name","scope":"read"|"admin","expires_in_days"?})
|
||
DELETE /api/auth/tokens/{id} (revoke)
|
||
```
|
||
|
||
Authenticate with `Authorization: Bearer sp_…` on any REST endpoint. Managing
|
||
tokens and users is deliberately excluded — those need a session.
|
||
|
||
### Private registry endpoints
|
||
|
||
```
|
||
GET /api/registries (admin; passwords are never returned)
|
||
POST /api/registries ({"host","username","password","name"?})
|
||
PUT /api/registries/{id} (omit "password" to keep the stored one)
|
||
DELETE /api/registries/{id} (also drops the Docker CLI login)
|
||
POST /api/registries/test (verify credentials; omit "password" to test a saved one)
|
||
```
|
||
|
||
### Stack icon endpoints
|
||
|
||
```
|
||
GET /api/stacks/{id}/icon (the app logo or upload; token required)
|
||
POST /api/stacks/{id}/icon (multipart "file", admin, <= 512 KiB)
|
||
DELETE /api/stacks/{id}/icon (back to the automatic icon, admin)
|
||
PUT /api/stacks/{id} ({"icon": "logo:<slug>" | "lucide:<name>" | ""})
|
||
GET /api/stacks/icons/search?q= (the app-logo catalog)
|
||
GET /api/stacks/icons/logo/{slug} (one catalog logo, served from our cache)
|
||
```
|
||
|
||
## Security notes
|
||
|
||
- The Docker socket is only ever touched by the backend process; it is never
|
||
proxied to the browser.
|
||
- API tokens are stored as a SHA-256 hash and shown exactly once. SHA-256 rather
|
||
than bcrypt on purpose: a token is 256 bits of `secrets` output, so guessing
|
||
is not the threat bcrypt's cost would be defending against — and that cost
|
||
would land on every API request.
|
||
- Registry passwords and backup-destination credentials are encrypted at rest
|
||
(Fernet, key derived from `SECRET_KEY`). The generated Docker CLI config that
|
||
carries them for `compose pull` is written 0600 inside the data volume.
|
||
- Login is rate-limited (10/min/IP).
|
||
- Compose files are backed up to `*.bak` before every overwrite.
|
||
- Generated YAML never includes the obsolete `version:` field and uses Compose v2
|
||
(`docker compose`) syntax.
|