menzeljandClaude Opus 5 9247ff9621
CI / build-and-push (push) Blocked by required conditions
CI / check (push) In progress
Deploy stacks from a Git repository (0.58.0)
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>
2026-09-18 01:06:29 +02:00
2026-06-08 10:33:38 +00:00

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:

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 3060s 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 (~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

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:

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):

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.

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.
S
Description
No description provided
Readme
1.1 MiB
Languages
Python 55.4%
TypeScript 44.1%
CSS 0.3%
Dockerfile 0.2%