podman-compose only recreates containers when the compose file's own hash changes. A push that only changed code built a new image, but the running containers kept the old one. Deploys now build first and pass --force-recreate when a container's image ID no longer matches its tag. Compose calls also pass -p with the app's name. Without it, compose named the project after the compose file's directory, so every git app (stacks/<app>/repo/compose.yaml) was the project "repo". That broke the terminal and monitoring checks, and --remove-orphans could remove another git app's containers. On its next deploy or restart, an app's containers from the old project are removed and recreated under the new name. An app whose containers use named volumes keeps the old project (recorded as APP_COMPOSE_PROJECT), since its volumes are named after it; for those apps panelctl skips --remove-orphans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf |
||
|---|---|---|
| frontend | ||
| nix | ||
| .gitignore | ||
| API.md | ||
| flake.lock | ||
| flake.nix | ||
| panel-api.py | ||
| panelctl.sh | ||
| README.md | ||
panelctl quickstart
Minimal container management panel for rootless Podman + Caddy.
- Deployments run in the background with a live log, a history per app (the last 50, with their output), cancel, redeploy and one-click rollback to an earlier commit or compose file.
- Auto deploy: a push to the app's branch deploys it. The panel adds the webhook to Forgejo itself; GitHub and CI use the shown URL and secret.
- Domains point at a service's container port (
web:8080); the panel picks a free local port and publishes it, so compose files need noports:. - Live logs, CPU / memory graphs per container and a web terminal
(
podman exec) in the browser.
Installing on NixOS
This repository is a flake that provides the panel as a package
(packages.<system>.default) and a NixOS module (nixosModules.default).
Add it to your system flake:
{
inputs.panel = {
url = "git+https://git.reudy.net/reudy-net/panel";
inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { nixpkgs, panel, ... }: {
nixosConfigurations.vps = nixpkgs.lib.nixosSystem {
modules = [
panel.nixosModules.default
{
services.reudy-panel = {
enable = true;
domain = "panel.example.com"; # Caddy virtual host
autheliaAddress = "127.0.0.1:9091"; # forward_auth in front of it
};
}
];
};
};
}
The module sets up the panel-api service, the directories below, the
panelroutes group shared with Caddy, the Caddy import of the generated routes
and a path unit that reloads Caddy when they change. With Forgejo enabled on
the same host, its URLs are passed to the panel automatically
(services.reudy-panel.forgejo.enable). See nix/module.nix for all options.
To deploy a new panel version, bump the input and rebuild:
nix flake update panel
sudo nixos-rebuild switch --flake .#vps
Checks (package build and a module evaluation) run with nix flake check.
Base directory
/var/lib/containers
Generated structure
/var/lib/containers/
├── stacks/<app>/compose.yaml # Compose file per app
├── stacks/<app>/.panel-*.yaml # Generated overrides: published route ports, env vars
├── volumes/<app>/data # Persistent volumes
├── routes/routes.caddy # Single aggregate Caddy routes file
├── backups/<app>-<timestamp>.zip # Volume backups
├── state/apps/<app>.env # App manifest
└── state/panel/ # Panel state: panel.db (deployment history),
# deployments/<app>/<id>.log, webhook secrets, keys
All app routes are written to a single routes/routes.caddy file that Caddy imports.
Quick workflow
# Routes are "domain|upstream[|path]" entries, comma-separated.
# Create a new app (single route, protected by Authelia)
panelctl init whoami "whoami.reudy.net|127.0.0.1:18080" true
# Create with multiple routes (different ports, optional path)
panelctl init myapp "app.reudy.net|127.0.0.1:18081,api.reudy.net|127.0.0.1:18082|/api/*" true
# Create with wildcard domain (requires DNS challenge in Caddy)
panelctl init wild "*.reudy.net|127.0.0.1:18083" false
# Change routes later (Caddy reloads automatically)
panelctl set-routes whoami "whoami.reudy.net|127.0.0.1:18080,who.reudy.net|127.0.0.1:18080"
# Deploy (compose up + caddy reload)
panelctl deploy whoami
# Check container status
panelctl status whoami
# View logs
panelctl logs whoami --tail 50
# Restart containers
panelctl restart whoami
# Stop containers
panelctl stop whoami
# Validate compose file
panelctl validate-compose whoami
# Backup volumes to zip
panelctl backup whoami
# List backups
panelctl list-backups whoami
# Restore from backup
panelctl restore whoami whoami-20260101-120000.zip
# List all apps
panelctl list
# Show app manifest
panelctl show whoami
# Remove app (keeps volumes)
panelctl remove whoami --keep-volumes
# Remove app and all data
panelctl remove whoami
Notes
- The default compose file uses
traefik/whoamifor smoke testing — edit before production use. - App names must be lowercase slugs (
[a-z0-9-]). - Wildcard domains (
*.example.com) require DNS challenge in Caddy (provider-specific). - Backups stop containers for consistency, then restart if they were running.
- If deploy reports
XDG_RUNTIME_DIRmissing, enable lingering:sudo loginctl enable-linger reudy
Web UI & API
- Nix runs
panel-apias a systemd service on127.0.0.1:9911. - Caddy proxies
https://panel.reudy.net→ panel-api with Authelia forward_auth. - Open
https://panel.reudy.netfor the web UI. - API docs: API.md
Web UI features
- Live status: one
/statuspoll every few seconds (faster while something is running, paused when the tab is hidden) updates cards in place, so open tabs, unsaved edits and scroll positions are never lost. The header shows when the panel last synced and warns when the Authelia session has expired. - Per-app status (running / partial / stopped), container list, and a busy indicator that is shared between browsers while an operation runs.
- New-app dialog: starter container, pasted compose file or git repository; suggests the next free port and a domain based on the app name.
- Compose editor with unsaved-changes tracking, Ctrl+S, save & deploy, validate.
- Logs with follow mode, routes editor with validation, file browser with drag-and-drop upload, backups with restore (and optional redeploy).
- Git source tab: deployed commit, "check for updates", and sync & deploy.
- Activity drawer with the output of every operation (e.g. why a deploy failed).
- Keyboard:
/search,Nnew app,Esccloses menus. Deep links like#/whoami/logsopen an app on a specific tab.
Git-backed apps
Apps created from a repository (Forgejo, GitHub or any git host, over https or
ssh) are cloned to stacks/<app>/repo.
Forgejo. panel.nix points the panel at the local Forgejo
(PANEL_FORGEJO_URL, PANEL_FORGEJO_API_URL, PANEL_FORGEJO_SSH_URL, taken
from forgejo.nix). In the panel's Settings you can connect a Forgejo
access token (read access to repositories and user). With it, the new-app
dialog lists your repositories and branches, and private ones are cloned over
https with the token. Without it, public repositories are listed and private
ones are cloned over ssh with the deploy key. Commit and compare links point at
Forgejo. The token is stored in state/panel/forgejo-token (mode 0600).
SSH / deploy key. The panel generates an ed25519 key pair in
state/panel/ssh/ the first time it is needed. Its public half is shown in
Settings (and next to ssh URLs); add it as a read-only deploy key to a
repository — or to your Forgejo account for access to all repositories — to
clone ssh://git@git.reudy.net:14922/owner/repo.git style URLs.
Sync fetches the configured branch and
hard-resets the checkout to it before redeploying, so the repository is the
source of truth: compose edits made in the panel are discarded on the next sync
(the UI warns about this). An access token for a private repository is stored in
the clone's .git/config; use a read-only token.
Environment variables
Each app can have environment variables (the Environment tab, or when
creating the app; .env text can be pasted in). They are stored in
state/env/<app>.env as KEY=VALUE lines (mode 0600) — outside the repository
and stack directory, so git syncs never touch them — and panelctl passes them
to every compose command:
- They are always available for
${VAR}interpolation in the compose file. The UI points out variables the compose file uses without a default that aren't set. - With Pass to every container (the default),
deploy/restartalso generatestacks/<app>/.panel-env.yaml, a compose override that lists the keys under every service'senvironment:. Compose reads the values from its own environment, so they are never quoted into YAML, and they take precedence over values set in the compose file.
Values must be single-line. Names that would change how podman/compose run
(PATH, HOME, XDG_*, DOCKER_*, COMPOSE_*, PODMAN_*, …) are rejected.
Changes apply on the next deploy. Backups do not include variables.
Concurrency
panel-api handles requests concurrently, so a long deploy never blocks status
or logs. Mutating operations are serialised per app — a second operation on a
busy app gets HTTP 409 — and panelctl takes a flock on the shared routes
file while rewriting it.