Ports published on 0.0.0.0 were linked at window.location.hostname. Opened
through a reverse proxy such as Nginx Proxy Manager that is the proxy's
domain, and domain:8080 ended up at the proxy instead of the container.
The backend now reads the host's address from the host's routing table
(${HOST_PROC_PATH}/1/net/route + fib_trie, the /proc mount the compose file
already has): the IPv4 address on the interface with the best default
route. There is no fallback to /proc/net, which in a bridged container is
the container's own namespace with an unreachable 172.x address.
The links use, in order: the new "Host address for port links" setting
(Settings -> General), the page's hostname when it is an IP or localhost,
the detected address, and the page's hostname as a last resort. Ports
bound to one specific address still link there; IPv6 hosts are bracketed.
The setting is validated before anything is written, so a bad value no
longer leaves the interval half-saved in the same request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1290 lines
71 KiB
Markdown
1290 lines
71 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.67.3 — nothing to do
|
||
|
||
**Port links work behind a reverse proxy.** The port chips on a stack linked to
|
||
`<the address in the browser>:<port>`. Opened through a proxy such as Nginx
|
||
Proxy Manager that is the proxy's domain, and `stackpilot.example.com:8080`
|
||
ended up at the proxy instead of the container. They now link to the Docker
|
||
host's own address: the backend reads it from the host's routing table (the
|
||
`/proc:/host_proc:ro` mount the compose file already has — the address on the
|
||
interface with the default route). Opened via an IP, the links keep using that
|
||
IP. When the detected address is the wrong one (several NICs, a VPN as default
|
||
route), set **Settings → General → Host address for port links**; it wins
|
||
everywhere. Ports bound to one specific address still link to that address.
|
||
|
||
## Upgrading to 0.67.2 — StackPilot and the terminal agree
|
||
|
||
StackPilot now runs `docker compose` the way it runs when typed in the stack's
|
||
folder. Before, it called `docker compose -p <folder> -f compose.yaml`, and the
|
||
two drifted apart:
|
||
|
||
- **Override files and `COMPOSE_FILE` apply.** A `compose.override.yaml` (or
|
||
`docker-compose.override.yml`, or `COMPOSE_FILE=` in the stack's `.env`) took
|
||
effect in a terminal and was dropped again by the next Start in StackPilot,
|
||
which recreated the containers without it.
|
||
- **The project name is compose's.** `COMPOSE_PROJECT_NAME` in `.env`, else the
|
||
compose file's `name:`, else the folder name. A stack whose file says
|
||
`name: immich` in a folder called `photos` showed as stopped while running,
|
||
and Start set up a second copy. *Nothing to do for existing stacks:* one that
|
||
StackPilot already runs under its folder name keeps that name for as long as
|
||
it has containers or volumes under it — switching would have brought it up
|
||
again next to the old one, on empty volumes.
|
||
- **Changes made elsewhere show up at once.** The dashboard tiles were cached
|
||
for 25 seconds and the update badge until the next registry check (an hour by
|
||
default); both now follow Docker's events, so a stack stopped or an image
|
||
pulled in a terminal is reflected within a second. After the live connection
|
||
drops (a restart, a laptop asleep, a proxy) the pages catch up on reconnect
|
||
instead of waiting for their slow poll.
|
||
- **The editor will not overwrite a change made on disk.** Saving after the
|
||
compose file or `.env` was edited in a terminal (or by a git sync) asks
|
||
whether to overwrite it or load the version on disk.
|
||
- **A stack whose folder is gone says so** ("folder missing") instead of passing
|
||
for an ordinary stopped stack.
|
||
|
||
## Upgrading to 0.60.0 — single sign-on
|
||
|
||
**Settings → Single sign-on (OIDC)** configures an identity provider — Authentik,
|
||
Keycloak, Authelia, Pocket ID, Zitadel, anything that publishes
|
||
`/.well-known/openid-configuration`. Fill in the issuer, client id and secret,
|
||
copy the redirect URI it shows you into your provider, and the login screen
|
||
grows a button with whatever label you chose.
|
||
|
||
Everything is configured from the UI. No environment variables, no restart to
|
||
fix a typo in a client id, and a *Test connection* button that actually fetches
|
||
the provider's metadata and reports which endpoints and how many signing keys it
|
||
publishes.
|
||
|
||
**The password form never goes away.** A provider outage must not lock you out
|
||
of your own Docker host, so local accounts keep working beside SSO.
|
||
|
||
**Accounts.** The first successful sign-in creates an account (switchable off),
|
||
matched on the provider's `sub` — the only identifier a provider promises is
|
||
stable — so renaming somebody upstream keeps them the same account. Somebody who
|
||
already had a local account is *linked*, not duplicated, and keeps their role.
|
||
Set an admin claim and value (e.g. `groups` = `stackpilot-admins`) and the
|
||
provider decides who is an admin, on every sign-in, in both directions. Disabling
|
||
an account in StackPilot outranks the provider.
|
||
|
||
**What is verified.** Authorization Code with PKCE, a single-use state, and a
|
||
nonce bound to the login. The ID token's signature is checked against the
|
||
provider's published keys, along with its issuer, audience and expiry — an
|
||
unsigned or mis-signed token is refused, because "the provider said so" is only
|
||
worth something if it really was the provider.
|
||
|
||
**No token ever appears in a URL.** The callback sets the same httpOnly refresh
|
||
cookie a password login sets and redirects to the app, which trades it for an
|
||
access token on boot exactly as it already did. Nothing lands in a proxy log or
|
||
the browser history.
|
||
|
||
Two fixes ride along: `verify_password` now returns false for an unparseable
|
||
stored hash instead of raising (a password attempt against an SSO account was a
|
||
500), and the bundled nginx forwards `X-Forwarded-Proto`, without which uvicorn
|
||
saw plain HTTP behind TLS — which made the derived redirect URI wrong and cost
|
||
the refresh cookie its `Secure` flag.
|
||
|
||
## Upgrading to 0.59.0 — CVE scanning
|
||
|
||
The Images page can tell you what is wrong with the images you are running.
|
||
**Scan for CVEs** scans every image a stack uses; each row then shows critical
|
||
and high counts, the total, and — the number that actually matters — how many
|
||
findings **have a fix available**. A base image with 300 unfixable low-severity
|
||
CVEs is not a task; three fixable ones are. Clicking a row opens the full list
|
||
with package, installed version, fixed version and a link to the advisory.
|
||
|
||
**The scanner is a container, not a dependency.** Trivy runs as a throwaway
|
||
container (the same trick backups use for volume contents), so a 100 MB security
|
||
tool and its weekly-changing vulnerability database stay out of StackPilot's own
|
||
image, and the scanner updates itself by pulling a newer tag. It gets the Docker
|
||
socket **read-only** to inspect images the daemon already has, and a named
|
||
volume (`stackpilot-trivy-cache`) so its ~50 MB database downloads once instead
|
||
of per scan. The first scan is therefore slow; the rest are not.
|
||
|
||
Set `SCANNER_IMAGE` to pin a version (default `aquasec/trivy:latest`).
|
||
|
||
**A scan that fails never looks clean.** If the scanner cannot run, the row
|
||
keeps the counts from the last good scan and shows the error — silence would
|
||
otherwise read as "no vulnerabilities", which is the one failure mode a security
|
||
feature must not have. Results also go stale honestly: pull an image and its
|
||
scan is marked *stale* rather than presenting yesterday's numbers for today's
|
||
image.
|
||
|
||
Reading results works with the read-only role and a read-only API token, so a
|
||
monitoring script can poll `GET /api/images/scans`. Running a scan is admin-only
|
||
— it spends real CPU and spawns a container.
|
||
|
||
## 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.
|
||
- **A stack is never stuck behind its own lock** (0.67.0) — the stack page says
|
||
what holds it and since when, and offers to stop it. A running backup is
|
||
asked to stop, throws its half-written archive away and starts the containers
|
||
it stopped; a lock left behind by a backup that is no longer running is
|
||
released on one click; anything else can be force-released. A restart clears
|
||
every lock, because after it nothing of ours is running.
|
||
- **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.
|
||
- **Single sign-on (OIDC)** — configured entirely from Settings, with a button on
|
||
the login screen, auto-created or linked accounts, and optional claim-based
|
||
admin mapping. Authorization Code + PKCE, verified ID tokens, and the session
|
||
handed back as the same httpOnly cookie a password login uses. The password
|
||
form stays, so a provider outage cannot lock you out.
|
||
- **CVE scanning** — Trivy runs as a throwaway container against the images your
|
||
stacks use; the Images page shows critical/high counts, how many findings are
|
||
fixable, and the full advisory list per image. A failed scan reports the
|
||
failure rather than reading as "clean", and a scan goes stale when the image
|
||
is pulled again.
|
||
- **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.
|
||
|
||
**How backup and restore stay reliable** (0.64.0):
|
||
- The stack's running containers are stopped whenever any data is captured —
|
||
also when the data is only folders inside the stack directory — and exactly
|
||
those are started again (`compose start`, not `up`, so services you stopped
|
||
stay stopped and undeployed compose edits are not applied). A stack that
|
||
cannot be stopped is not backed up. The stack is locked meanwhile, so a
|
||
deploy, update or scheduled backup cannot interleave (a scheduled backup
|
||
that finds the stack busy retries after 10 minutes).
|
||
- A backup can be stopped while it runs (0.67.0). It is a long operation
|
||
that holds the stack — up to twelve hours for a large volume — so the stack
|
||
page offers to cancel it: the helper container it streams through is
|
||
removed, the archive is thrown away, and the containers it stopped are
|
||
started again. A restore is not interruptible (it is halfway through
|
||
writing a stack's data back); its lock can only be force-released.
|
||
- Remote storage is kept out by what it *is*, not only by how it is declared:
|
||
NFS/CIFS volumes, bind folders on a mounted share (`/mnt/nas/…` via fstab or
|
||
autofs), bind-type volumes pointing at one, and shares mounted inside the
|
||
stack folder. Folders too big to measure in 20 s are not selected either.
|
||
- Volumes the stack uses without owning them (`external: true`) are backed up
|
||
too; anonymous volumes are listed but not selected.
|
||
- Restore reads the whole archive first and refuses a damaged one before
|
||
touching anything, stops the stack while replacing its data, unpacks the
|
||
stack folder next to the old one and then swaps it in. With *Overwrite*,
|
||
folders the backup left out (too large, deselected, remote) and other
|
||
filesystems mounted inside the stack folder are **kept**, not deleted.
|
||
- Restoring under a new name never writes into the original stack: folders
|
||
outside the stack directory and volumes not named after the stack are
|
||
skipped, and a volume owned by another stack or mounted by a running
|
||
container is never overwritten. Missing volumes are recreated with their
|
||
original driver and options.
|
||
- A backup that could not capture everything, or left the stack down, is
|
||
reported as incomplete — in the dialog and for scheduled backups, which
|
||
then notify and do not rotate out older backups. Retention only matches
|
||
the stack's own files (it used to take `app-db`'s backups for `app`).
|
||
- **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.
|
||
StackPilot adds `nolock` to NFSv2/3 mount options unless you set `lock` or
|
||
`local_lock=` yourself: with locking the kernel needs rpcbind on the Docker
|
||
host (nfs-common), and without it the mount fails with "connection refused".
|
||
Only StackPilot writes the backup files, so nothing needs locking (0.66.2).
|
||
- **SFTP host keys** (0.67.1): the server's host key is checked on every
|
||
connection. Paste it into the destination's *Host key* field (a key line or
|
||
its `SHA256:` fingerprint, e.g. from `ssh-keyscan HOST | ssh-keygen -lf -`),
|
||
or leave the field empty and the key seen on the first connection is pinned
|
||
— "Test" shows its fingerprint. A different key is refused before the
|
||
password is sent; clear the field after a server reinstall to trust the new
|
||
one. Changing the host or port forgets the pinned key.
|
||
- **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 stacks to back up to a destination **hourly,
|
||
daily, or weekly** under **Settings → Scheduled backups**. A background
|
||
scheduler runs due jobs every minute and records last/next run + status.
|
||
- **Local time** (0.66.0): each schedule has a time zone, preset to the
|
||
browser's (e.g. `Europe/Berlin`), and its time is wall-clock time there — a
|
||
03:00 backup stays at 03:00 across daylight saving changes. Schedules made
|
||
before were in UTC and stay so until edited. Backup file names carry the time
|
||
on that clock too (`backup-app-full-20261015-122154.tar.gz` for 12:21:54 in
|
||
Berlin); a backup made by hand uses the browser's time zone (0.67.1).
|
||
- **Several stacks per schedule** (0.65.0): tick any number of stacks, or
|
||
*All stacks* to include stacks added later without editing the schedule. They
|
||
are backed up one after another, each to its own file with its own retention
|
||
and status, so one failing stack does not cost the others their backup; the
|
||
schedule lists which stack had a problem. A stack that is busy (deploying,
|
||
updating) is retried after the others. Schedules from before keep working.
|
||
- **Retention**: keep the newest *N* backups per stack on the destination; older
|
||
ones are pruned automatically.
|
||
- **Run now** for an on-demand run (in the background; the row shows it running
|
||
and then the result), 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. The prune removes the images one by one in the background and shows
|
||
**live progress** above the table (x / y, the image being removed, failures),
|
||
then the space actually freed. A large `-a` no longer sits on a spinner until
|
||
the proxy times out.
|
||
|
||
```
|
||
POST /api/images/prune?all=… (waits for the result; admin)
|
||
POST /api/images/prune?all=…&background=true (202 at once, then poll ↓)
|
||
GET /api/images/prune-status (progress of the running / last prune)
|
||
```
|
||
|
||
### 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. The prune asks first and removes
|
||
only unused **anonymous** volumes unless *Also remove unused named volumes
|
||
(`-a`)* is ticked, which also takes the data of every stack that is down. It
|
||
runs in the background with the same **live progress** as the image prune and
|
||
reports the space freed (`POST /api/volumes/prune?all=…&background=true`,
|
||
`GET /api/volumes/prune-status`).
|
||
- **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. The prune asks first, runs in the background with
|
||
the same **live progress** as the image and volume prunes, and keeps any
|
||
network a *stopped* container is attached to (plain `docker network prune`
|
||
removes those, and the container then fails to start)
|
||
(`POST /api/networks/prune?background=true`, `GET /api/networks/prune-status`).
|
||
- **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.
|
||
|
||
None of these settings reach the stacks StackPilot deploys. Compose resolves
|
||
`${VAR}` from the calling environment before a stack's own `.env`, so until
|
||
0.66.3 a stack that uses `${SECRET_KEY}` (or `PORT`, `DATA_DIR`, …) ran with
|
||
StackPilot's value instead of its own — and with its own again when started from
|
||
a shell. Anything such a stack encrypted in between was keyed to the wrong one.
|
||
|
||
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. Since 0.67.0 they also cover
|
||
the way out of a lock — a backup that is asked to stop, a leftover backup lock
|
||
that is released on one click, a deploy lock that takes an explicit force, and
|
||
a restore that refuses to be interrupted at all — and
|
||
`tests/test_backup_restore.py` that a cancelled backup starts the stack again,
|
||
frees the lock and leaves no half-written archive behind. 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?all=&background= POST /api/volumes/generate-yaml
|
||
GET /api/volumes/prune-status
|
||
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 (202, runs in the background)
|
||
body: {"stack_ids": [...] | "all_stacks": true, "destination_id": …, …}
|
||
```
|
||
|
||
### Phase 9 endpoints
|
||
|
||
```
|
||
GET /api/networks | /{id} POST /api/networks
|
||
DELETE /api/networks/{id} POST /api/networks/prune?background=
|
||
GET /api/networks/prune-status
|
||
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)
|
||
```
|
||
|
||
### Single sign-on endpoints
|
||
|
||
```
|
||
GET /api/auth/oidc/status (public: whether to show the button, and its label)
|
||
GET /api/auth/oidc/login (public: redirects to the provider)
|
||
GET /api/auth/oidc/callback (public: the provider sends the browser back here)
|
||
GET /api/auth/oidc/config PUT … (admin; the client secret is never returned)
|
||
POST /api/auth/oidc/test (fetch the provider's metadata and report it)
|
||
```
|
||
|
||
### CVE scanning endpoints
|
||
|
||
```
|
||
GET /api/images/scans (cached results; read-only role may read)
|
||
GET /api/images/scan?image=… (one image, with its findings)
|
||
POST /api/images/scan ({"image": "…"}, admin — runs the scanner)
|
||
POST /api/images/scan-all (every image a stack uses, serial, admin)
|
||
GET /api/images/scan-status (progress of a running sweep)
|
||
```
|
||
|
||
### 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).
|
||
- Single sign-on verifies the ID token's signature against the provider's
|
||
published keys, plus issuer, audience, expiry and a per-login nonce; the state
|
||
is single-use. Accounts created through it hold no usable password hash.
|
||
- 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.
|