Quadlet-native web UI for Podman — manage systemd containers, pods, and GPUs from your browser, with unit files on disk as the single source of truth.
A rookery is where a pod of seals gathers.
Quadlets — containers defined by .container/.pod/.network systemd unit
files — are the canonical way to run Podman on Fedora/RHEL. But the polished
management UIs (Portainer, Arcane, Dockhand) speak the Docker API and can't
see them, and cockpit-podman can start Quadlets but not create or edit them.
If you run containers "the right way", your UI is SSH and a text editor.
Rookery is a thin, honest layer over systemd and Podman. It reads and writes
the unit files that already define your system, validates them with the
host's own Quadlet generator, and drives them through systemctl.
Agentless by default, and no hidden workload state. Remote hosts work
over plain ssh; an optional lightweight rookery-agent is available where
persistent connectivity beats ssh. Stop using Rookery tomorrow and you lose
nothing about how your containers run — the Quadlet files on disk are the
workload state. Rookery keeps only local admin metadata, such as accounts
and UI-managed settings, in rookery.db.
| Quadlet create/edit | Rootless multi-user | GPU | Multi-host | Server web UI | |
|---|---|---|---|---|---|
| cockpit-podman | ✗ (run only) | partial | ✗ | ✗ | ✓ |
| Portainer / Arcane | ✗ | ✗ | ✗ | ✓ (agents) | ✓ |
| Podman Desktop | ✓ | n/a | partial | ✗ | ✗ (desktop app) |
| Rookery | ✓ | ✓ | ✓ | ✓ (ssh or agent) | ✓ |
- Dashboard — every Quadlet unit grouped by state (failed / running / stopped), host metrics, restart-loop and exit-code surfacing.
- Browser editor — syntax highlighting, diff preview before every save,
validation with the host's own
podman-system-generator --dryrun, then atomic write →daemon-reload→ optional restart. SELinux hints for unlabeled bind mounts on enforcing hosts. - Importer — turn
podman runcommands, compose files, or already-running containers into Quadlet units; anything the converter has to guess about becomes an explicit warning on the draft. - Full lifecycle — create, start, stop, restart, enable, disable, delete;
every Quadlet kind (
.container,.pod,.network,.volume,.kube,.image,.build) with starter templates. - Pod composition — pod cards roll up member state (per-member dots,
up/failed counts), member containers link to their pod, and the pod page
lists every unit that declares
Pod=. - Live logs —
journalctlstreamed to the browser, follow mode. - Rootless multi-user — rootless Rookery manages your own
~/.config/containers/systemd/; rootful auto-discovers every user with a Quadlet tree (or take control with-users alice,bob/-users none) and manages their sessions viasystemctl --user --machine. - Git history & rollback — with
-git, every save/delete/rollback is a commit; per-revision diffs and one-click restore (re-validated before writing). It's plain git in the unit directory — fully usable without Rookery. - GPU panel — NVIDIA (
nvidia-smi), AMD (amdgpu sysfs) and Intel inventory, per-unit attachment badges, and an editor helper that inserts CDI / VAAPI / ROCm device lines. No other web UI does this. - Agentless multi-host —
-remotes nas=root@nas.localadds another box's Quadlet tree to the same dashboard: list, edit, validate (with the remote host's generator), lifecycle, and logs over plain ssh. Use explicit grouped aliases such aspi.root=pi-root,pi.user=pi-userto show rootful and rootless scopes for one host under the same fleet node. Nothing to install on the target beyond sshd and Podman. SSH nodes can also be added and removed at runtime from the Fleet page — no restart needed. - Agent connector — for hosts where persistent connectivity beats ssh,
-agents pi=http://10.87.0.5:7666(with a shared-agent-token) connects arookery-agentthat serves every scope on its host — system plus each rootless user — with full read/write parity: units, lifecycle, logs, live stats, resources, host metrics, and GPUs. Quadlet files on the agent host remain the source of truth. - Live resources — the Networks, Volumes, and Images pages list the real podman objects in every scope's store (local rootful, local rootless, and agent hosts), each flagged managed (a Quadlet unit owns it) or unmanaged, and used or unused; click any for an inspect overlay with "used by", and delete unmanaged leftovers from the browser.
- Node-scoped management — a node picker in the top bar scopes the dashboard, containers, pods, images, volumes, networks, sidebar counts, and actions (prune, update all) to one node; "All nodes" shows the whole fleet, and every Fleet row has a one-click "manage" shortcut. Nodes carry labels, groups, custom display names, and colors.
- Image-update checks — compare every unit's tag against the digest its registry serves (docker.io, ghcr.io, quay.io, …), flag drift, one-click pull + restart per unit or "update all" — on remote hosts too (podman over ssh). Digest-pinned images are correctly reported as unable to drift, the dangling images updates leave behind get a one-click prune with reclaimable size shown, and "prune unused" removes every unreferenced image on every node (or just the selected one).
- Failure alerts —
-alerts ntfy://ntfy.sh/topic(ortelegram://BOT_TOKEN@CHAT_ID, or any JSON webhook) notifies when a unit enters or recovers fromfailed, with exit code and restart count. - Accounts & roles — a first-run wizard creates the admin account
(PBKDF2-hashed in local SQLite metadata); admins can add more
admins or viewer accounts that get a read-only dashboard. Sessions are
HttpOnly cookies with a sliding idle timeout (
-session-ttl, default 24h). - Read-only share links — one click mints a 7-day link for a dashboard view without a login: enforced GET-only on the server, no secrets, no actions. Changing any password revokes all links.
- Secrets — list
podman secrets with the units that reference them, create and delete from the browser (delete refuses while referenced); the editor insertsSecret=lines from a picker. Values are write-only. - Mobile-responsive operations UI — a dense React console with desktop sidebar navigation, mobile bottom tabs, and a "restart it from the couch" triage path.
- One static binary — amd64 + arm64; the web UI is built with Vite and embedded into the binary. At runtime, Rookery shells out to the host's systemd, Podman, journal, git, and ssh tools as features require.
git clone https://github.com/rookerylabs/rookery && cd rookery
make build # builds the Vite UI, then go build ./cmd/rookery
./rookery # → http://127.0.0.1:7665Run it rootless to manage your own ~/.config/containers/systemd/, or
rootful to manage /etc/containers/systemd/ (add -users alice to also
manage alice's rootless units).
On a fresh install, Rookery creates the initial
adminaccount at startup. If no password is provided, it prints a temporary password to the process output/container logs. Sign in withadmin, then complete the first-login setup screen to set the admin email and replace the temporary password. (ROOKERY_PASSWORD/-password-filecan provide the initial password; the first login will still ask for the admin email.) Put TLS in front of Rookery before exposing it beyond127.0.0.1— the built-in server speaks plain HTTP.
| Flag | Env | Default | Meaning |
|---|---|---|---|
-listen |
ROOKERY_LISTEN |
127.0.0.1:7665 |
listen address ("ROOK" on a phone keypad) |
-users |
ROOKERY_USERS |
auto-discover | rootless users to manage (rootful only); none disables |
-password-file |
ROOKERY_PASSWORD_FILE |
— | legacy single-admin password (or ROOKERY_PASSWORD); the wizard is nicer |
-disable-password-login |
ROOKERY_DISABLE_PASSWORD_LOGIN |
false |
disable local username/password login; requires OIDC |
-data-dir |
ROOKERY_DATA_DIR |
/etc/rookery (rootful) |
where rookery.db lives |
-session-ttl |
ROOKERY_SESSION_TTL |
24h |
idle timeout for login sessions (sliding) |
-share-ttl |
ROOKERY_SHARE_TTL |
168h |
lifetime of read-only share links |
-audit-retention |
ROOKERY_AUDIT_RETENTION |
0 |
prune audit events older than this on startup; 0 keeps everything |
-git |
ROOKERY_GIT=1 |
auto-detect | track unit dirs in git: commit on save, history, rollback |
-remotes |
ROOKERY_REMOTES |
— | remote hosts over ssh, alias=user@host,...; node.root=target,node.user=target groups rootful/rootless targets under one fleet node |
-agents |
ROOKERY_AGENTS |
— | rookery-agents to manage, alias=url,... (e.g. pi=http://10.87.0.5:7666); one agent serves every scope on its host |
-agent-token |
ROOKERY_AGENT_TOKEN |
— | shared bearer token presented to rookery-agents |
-alerts |
ROOKERY_ALERTS |
— | failure alerts: ntfy://host/topic, telegram://TOKEN@CHAT, webhook URL |
-alert-interval |
ROOKERY_ALERT_INTERVAL |
30s |
failure-alert polling interval |
-alert-cooldown |
ROOKERY_ALERT_COOLDOWN |
0 |
minimum time between repeated failure alerts for the same unit |
-oidc-issuer |
ROOKERY_OIDC_ISSUER |
— | OIDC issuer URL for SSO |
-oidc-client-id |
ROOKERY_OIDC_CLIENT_ID |
— | OIDC client ID |
-oidc-client-secret |
ROOKERY_OIDC_CLIENT_SECRET |
— | OIDC client secret |
-oidc-redirect-url |
ROOKERY_OIDC_REDIRECT_URL |
derived | public callback URL; usually https://host/api/oidc/callback |
-oidc-name |
ROOKERY_OIDC_NAME |
SSO |
label on the login button |
-oidc-admins |
ROOKERY_OIDC_ADMINS |
— | comma-separated OIDC sub, email, or preferred_username values that get admin |
-oidc-admin-groups |
ROOKERY_OIDC_ADMIN_GROUPS |
— | comma-separated OIDC groups values that get admin |
-oidc-default-role |
ROOKERY_OIDC_DEFAULT_ROLE |
viewer |
role for other OIDC users: viewer or admin |
Register Rookery as an OIDC confidential web application with callback:
https://your-rookery.example/api/oidc/callback
Then configure it with flags or environment variables:
ROOKERY_OIDC_ISSUER=https://idp.example/application/o/rookery/
ROOKERY_OIDC_CLIENT_ID=rookery
ROOKERY_OIDC_CLIENT_SECRET=...
ROOKERY_OIDC_REDIRECT_URL=https://your-rookery.example/api/oidc/callback
ROOKERY_OIDC_ADMIN_GROUPS=rookery-adminsOIDC can run alongside local accounts and the legacy single-password mode.
When OIDC is the only configured credential source, the first-run local
account wizard is disabled and /api/* is protected by SSO. OIDC users are
viewers by default; grant admin either with ROOKERY_OIDC_ADMINS matching
sub, email, or preferred_username, or with
ROOKERY_OIDC_ADMIN_GROUPS matching the token's groups claim. Set
ROOKERY_OIDC_DEFAULT_ROLE=admin only if the identity provider already
limits access to trusted admins.
For SSO-only deployments, set:
ROOKERY_DISABLE_PASSWORD_LOGIN=trueThat hides the username/password form and rejects /api/login; Rookery will
refuse to start in this mode unless OIDC is configured.
Can I exec into containers or browse volumes? No. Rookery deliberately
keeps workload mutations flowing through Quadlet files and systemd. It edits,
validates, starts, stops, restarts, and reads journal logs; it does not provide
an interactive container shell or volume file manager. Use podman exec or SSH
on the host when you need break-glass inspection.
packaging/rookery.service is a plain systemd unit for the binary — the simplest install.
Or run the image (amd64 + arm64, published from CI):
packaging/rookery.container runs
ghcr.io/rookerylabs/rookery as a Quadlet, dogfooding Rookery on itself. The
container needs the host mounts listed there (unit dirs, /run/systemd,
journal, Podman socket, and the host's quadlet generator for validation).
Rookery is pre-alpha. The v1-shaped feature set is present, but the next milestone is proving install, migration, upgrade, and recovery paths on real hosts before a public alpha announcement. Use docs/DOGFOOD.md for the homelab validation checklist and docs/RELEASE.md for the release process.
- Build with
make build, or use the published image with packaging/rookery.container. - Run rootless for a single user's Quadlets, or rootful with
packaging/rookery.service to manage
/etc/containers/systemd/and selected users' rootless trees. - Keep Rookery bound to
127.0.0.1unless it is behind a reverse proxy with TLS. The built-in HTTP server does not terminate TLS. - Persist
ROOKERY_DATA_DIR(/etc/rookeryby default when rootful). It containsrookery.db, which stores local accounts, password hashes, settings, and other durable admin metadata. - If using the container deployment, keep the host mounts in
packaging/rookery.container aligned with the
host: unit directories,
/run/systemd, journal, Podman socket, data dir, and the host's Quadlet generator when available.
- Older builds used
users.jsonfor local accounts. Current builds migrate that file into siblingrookery.dbwhen the database has no users. - Back up
ROOKERY_DATA_DIRbefore upgrading. The workload definitions themselves remain the Quadlet files under/etc/containers/systemd/and each managed user's~/.config/containers/systemd/. - Environment variables and command-line flags override UI-saved settings. UI setting changes may require restarting Rookery to affect process-level configuration such as listen address, OIDC, remotes, or alert sinks.
- Back up
ROOKERY_DATA_DIRfor accounts, password hashes, and UI-managed settings. Login sessions are in-memory, and share links are stateless tokens revoked by changing account credential material. - Back up the Quadlet directories separately. If
-gitis enabled, those directories are plain git repositories; Rookery's history is still usable with normal git commands. - Losing Rookery metadata does not delete or stop workloads. You can still
use
systemctl,journalctl, Podman, and the Quadlet files directly.
- Rookery is intended to sit behind your own TLS/reverse-proxy layer when exposed beyond localhost.
- Remote hosts connect over ssh (target needs sshd, Podman, systemd, and
compatible permissions) or through the optional
rookery-agent(bearer token over HTTP; put it on a private network such as a tailnet). - SELinux bind-mount hints and podman secret management are local-host only.
- Containerized Rookery depends on host namespace and socket mounts for full lifecycle control; missing mounts degrade specific features rather than changing the underlying Quadlet files.
- Importers cover common
podman run, compose, and existing-container cases and emit warnings for guessed or unsupported fields. Review generated units before saving them.
browser ──HTTP/SSE──▶ rookery (one static Go binary)
├─ unit files ~/.config/containers/systemd/, /etc/containers/systemd/
├─ metadata rookery.db (accounts + Rookery settings)
├─ validation podman-system-generator --dryrun (the host's own)
├─ lifecycle systemctl [--user [--machine user@.host]]
├─ logs journalctl -o json (-f)
├─ host info Podman native REST socket (read-only) + /proc
├─ updates registry v2 digest HEAD + podman pull
└─ remote hosts ssh user@host -- <the same commands over there>
Design rules (from the PRD):
- Files on disk are authoritative. Rookery never hides state in its own database. SQLite stores Rookery's local admin metadata, not the workload definitions or systemd state.
- Validate with the host's generator, never a vendored parser, so Rookery always agrees with the Podman version actually installed.
- Mutations go through systemd, exactly as they would over SSH.
- Degrade gracefully: if systemd or Podman is unreachable, files on disk are still listed and editable, with the error surfaced.
| Method & path | Purpose |
|---|---|
GET /api/audit |
recent admin mutation audit events |
GET /api/backup |
tar.gz export of Rookery metadata and managed Quadlet files |
GET /api/units |
all units with live state |
GET /api/units/{scope}/{name} |
unit + file content |
PUT /api/units/{scope}/{name} |
validate → write → daemon-reload ({"content", "restart"}) |
DELETE /api/units/{scope}/{name} |
stop, remove file, daemon-reload |
POST /api/units/{scope}/{name}/action |
{"action": "start|stop|restart|enable|disable"} |
GET /api/units/{scope}/{name}/logs?follow=1 |
journal stream (SSE) |
POST /api/validate |
dry-run a unit body without saving |
POST /api/convert |
{"kind": "run|compose|container", "input": ...} → draft units |
GET /api/import/containers |
existing containers eligible for import |
GET /api/units/{scope}/{name}/history |
git commits for the unit |
GET /api/units/{scope}/{name}/history/{commit} |
content at a commit |
POST /api/units/{scope}/{name}/rollback |
{"commit": ...} — validate + restore |
GET /api/updates |
digest drift for every container unit's image |
POST /api/updates/apply |
pull + restart a list of unit refs, or {"allDrifted": true} |
POST /api/units/{scope}/{name}/update |
pull new image + restart |
POST /api/units/bulk-action |
start/stop/restart a set of units with per-unit results |
GET /api/stats |
one-shot per-container CPU/mem sample across scopes |
GET /api/gpus |
GPU inventory: local, ssh remotes, and agent hosts |
GET /api/host |
metrics, Podman info, scopes |
GET /api/resources |
live podman networks/volumes/images in every scope, with managed/used flags |
GET /api/resources/inspect |
display fields + "used by" for one resource |
DELETE /api/resources |
remove a network/volume/image from a scope's store |
GET /api/license |
edition, planned 3-node Enterprise Free allowance, managed-node count, remaining/over-limit nodes, unlimited user/SSO allowances |
GET /api/nodes |
managed-node inventory grouped from local, remote, and agent scopes |
POST /api/nodes, DELETE /api/nodes/{id} |
add/remove ssh nodes at runtime (persisted in rookery.db) |
GET /api/groups |
label-derived node groups for fleet organization |
PATCH /api/nodes/{id}/labels |
save Rookery-owned node labels for fleet organization |
PATCH /api/nodes/{id}/appearance |
per-node display name and color |
GET /api/policies |
read-only fleet policy findings from Quadlet files |
POST /api/policies/waivers, DELETE /api/policies/waivers/{key} |
waive or unwaive policy findings with Rookery metadata |
GET/POST /api/secrets, DELETE /api/secrets/{name} |
podman secrets (write-only values) |
GET /api/images/stale / POST /api/images/prune |
dangling-image count/size, prune; ?all=true prunes every unused image on every node, &node=<id> limits to one |
GET/POST /api/tokens, DELETE /api/tokens/{name} |
API bearer tokens (value shown once) |
POST /api/restore |
restore a /api/backup archive (validated, dry-run supported) |
GET /metrics |
Prometheus text exposition: unit states, drift, node reachability |
POST /api/share |
mint a 7-day read-only share token |
GET/POST /api/setup |
first-run wizard: create the initial admin (one-shot) |
GET/POST /api/users, DELETE /api/users/{name}, POST /api/users/{name}/password |
account management (admin) |
GET /api/oidc/login / GET /api/oidc/callback |
OIDC authorization-code login |
POST /api/login / POST /api/logout / GET /api/auth |
session auth (sliding idle timeout) |
{scope} is system, a username, a remote-host alias from -remotes
(for grouped remotes, the full node.scope alias), or an agent scope label.
Remote-scope limits: SELinux hints, podman secrets, container import, and stale-image (dangling) prune are local-host only — the UI labels them as such. Everything else — list, edit, validate (remote generator), lifecycle, logs, git history/rollback (when the remote dir is already a repo; Rookery never git-inits another host), GPU panel, live resources, update checks and pulls, unused-image prune — works over ssh or through the agent.
make check # gofmt + go vet + go test
make build # static binary with version stamp
make cross # linux amd64 + arm64Go ≥ 1.25, Node.js 22 for the embedded web build. Status: pre-alpha — the PRD's v1 scope (lifecycle, importer, git, GPU, multi-host, update checks) is implemented and under active dogfooding.
Shipped through v1.x: full Quadlet lifecycle, importer, git history, GPU panel, agentless multi-host with remote git/updates/GPU parity, the optional rookery-agent connector (multi-scope, full parity), rootless auto-discovery, pod composition view, live resources with managed/used flags, node-scoped fleet management, image-update checks with stale and unused-image pruning, failure alerts (ntfy/Telegram/webhook), read-only share links, API tokens, Prometheus metrics, backup/restore, and podman-secrets management.
Deliberately not built: podlet integration (the native converter covers
the common cases and warns about the rest; a binary dependency for edge
cases isn't worth it — open an issue if you hit a real gap).
Multi-admin accounts with a viewer role shipped with the first-run wizard; OIDC / external SSO is available for deployments that already have an identity provider. Rookery's planned Enterprise Free model does not cap local users or SSO identities; the commercial boundary is managed-node scale and fleet governance.
- v2: systemd credentials alongside podman secrets, pod-level log interleaving.