Deployment — single Linux host
One panel container, one Docker Engine, one administrator. The panel never owns your stacks: Compose files on disk and Docker itself stay the sources of truth, so removing the panel never stops your services. For sign-in, stack or log problems, see troubleshooting.md.
For a fresh Ubuntu Server 24.04/26.04, see the
interactive installer. It uses deploy/compose.prod.yaml
with the official image. In the manual commands below, substitute that file
for deploy/compose.yaml when maintaining an installer deployment, and pass
--production to scripts/setup-caddy.sh.
Prerequisites
- Linux host with Docker Engine + Compose v2 and the stacks directory
(e.g.
/opt/stacks) already managed bydocker compose. - Docker to build or pull the image. No Bun install is needed on the host: the
image runs
hash-passwordfor you below. Bun 1.4.4+ is only needed to build the image (pinned in CI and the Dockerfile). - A unique admin username (
COMPOSE_PANEL_ADMIN_USER): common names (admin,root, …) are refused at startup unlessCOMPOSE_PANEL_DEV=true.
Deploy
Build or pull the image before generating the hash, so the image can generate it for you:
# 1. Copy the settings and build (or pull) the image
cp deploy/compose-panel.env.example deploy/compose-panel.env
docker compose -f deploy/compose.yaml build
# 2. Generate the admin password hash with the image itself
read -rsp "Compose Panel password: " COMPOSE_PANEL_PASSWORD; echo
export COMPOSE_PANEL_PASSWORD
docker run --rm --entrypoint bun -e COMPOSE_PANEL_PASSWORD \
compose-panel:local src/scripts/hash-password.ts
unset COMPOSE_PANEL_PASSWORD
# 3. Paste the hash, single-quoted, and a unique username into
# deploy/compose-panel.env, then lock the file down and start
chmod 600 deploy/compose-panel.env
docker compose -f deploy/compose.yaml up -d
curl -fsS http://127.0.0.1:5081/health/live # -> ok
The hash must be single-quoted in compose-panel.env: Docker Compose
applies variable interpolation to unquoted and double-quoted env_file values,
so a $ in the Argon2id hash would be expanded and the panel would reject the
password. On a machine that already has Bun you can instead run
COMPOSE_PANEL_PASSWORD='...' bun run hash-password.
Rules that must stay true:
- Host path == container path for stacks (
/opt/stacks:/opt/stacksplus matchingSTACKS_DIR) and the socket. Otherwisecompose lsConfigFiles no longer match and stacks reportunknown. stop_grace_period(35s) must exceedCOMPOSE_PANEL_SHUTDOWN_GRACE_MS(default 30s) soSIGTERMdrains running operations instead of killing them.- The panel binds 127.0.0.1 only (hard-coded). With host networking that
is the host loopback. Remote access goes through an SSH tunnel
(
ssh -L5081:127.0.0.1:5081 you@host) or through Caddy on the same host (next section). Never add aports:mapping: there is nothing to publish. - Sessions live in memory: every panel restart logs the admin out. No session persistence is needed or provided.
- Mounting
/var/run/docker.sockgives whoever can edit Compose files very high control over the host (seedocs/security.md). Trusted admin only. - Panel settings must stay in the
COMPOSE_PANEL_*namespace. Compose subprocesses get the process environment with that namespace stripped (ADR 0008), so panel secrets never reach a stack. Other variables (e.g.COMPOSE_PROJECT_NAME,DOCKER_HOST) pass through by design.
Upgrading from 0.1.0
0.2.0 replaces the single COMPOSE_PANEL_PUBLIC_ORIGIN with the list
COMPOSE_PANEL_PROXY_ORIGINS, and removes COMPOSE_PANEL_TRUST_PROXY (the
forwarded address is now trusted per matched origin automatically). Both old
settings are refused at startup with a migration message — the panel never
reinterprets security configuration silently (ADR 0012).
# 0.1.0
COMPOSE_PANEL_PUBLIC_ORIGIN=https://panel.example.com
COMPOSE_PANEL_TRUST_PROXY=true
# 0.2.0 — reverse proxy only
COMPOSE_PANEL_PROXY_ORIGINS=https://panel.example.com
# 0.2.0 — HTTPS and the SSH tunnel together
COMPOSE_PANEL_PROXY_ORIGINS=https://panel.example.com
COMPOSE_PANEL_ALLOW_LOOPBACK=true
A single origin behaves as before. The change is that the SSH tunnel and HTTPS no longer exclude each other, and several origins can be listed.
Behind Caddy (HTTPS)
To use the panel from a browser without a tunnel, put Caddy in front of it on
the same host (ADR 0010). The panel stays
bound to 127.0.0.1, so Caddy must reach the host loopback: run it on the host
itself, or in a container with network_mode: host.
Version note. The list setting below (
COMPOSE_PANEL_PROXY_ORIGINS) is the 0.2.0 contract (ADR 0012). On 0.1.0 the equivalent is the singleCOMPOSE_PANEL_PUBLIC_ORIGIN=https://…; see Upgrading from 0.1.0.
-
In
deploy/compose-panel.env, list the origins. This setting allows HTTPS access and makes Secure cookies and Caddy'sX-Forwarded-Forapply on those hosts:COMPOSE_PANEL_PROXY_ORIGINS=https://panel.example.comEach entry must be
https(outsideCOMPOSE_PANEL_DEV=true), with no credentials, path, query or fragment, no empty entry and no duplicates. Several origins are comma-separated:# A public domain and a private overlay name, for example COMPOSE_PANEL_PROXY_ORIGINS=https://dockge.example.com,https://<machine>.<tailnet>.ts.netWith no origin listed, the SSH tunnel is the only path. To keep using the tunnel and the proxy, opt in explicitly:
COMPOSE_PANEL_ALLOW_LOOPBACK=trueUpgrading from 0.1.0:
COMPOSE_PANEL_PUBLIC_ORIGINis refused at startup — rename it toCOMPOSE_PANEL_PROXY_ORIGINS, and remove anyCOMPOSE_PANEL_TRUST_PROXY(forwarded headers now follow the matched origin automatically). See ADR 0012. -
Add a site to Caddy. Caddy obtains the certificate, redirects HTTP to HTTPS and streams the live output without extra settings:
panel.example.com { reverse_proxy 127.0.0.1:5081 }With caddy-docker-proxy (labels), the Caddy container must use
network_mode: hostfor127.0.0.1to mean the host. Then add to the panel service indeploy/compose.yaml:labels: caddy: panel.example.com caddy.reverse_proxy: 127.0.0.1:5081A Caddy container on a bridge network cannot reach the panel. That is deliberate: the bind is not configurable.
-
Restart the panel and check that it says
Reverse proxy mode: origins https://panel.example.com; ...at startup. Sign in through the public URL. WithCOMPOSE_PANEL_ALLOW_LOOPBACK=true, the SSH tunnel (ssh -L5081:127.0.0.1:5081 you@host) keeps working alongside it: each path is validated on its own — a form built for the proxy origin is refused when posted through the tunnel and the reverse — and only the HTTPS path sets Secure cookies.With several proxy origins, Caddy serves each name and forwards to the same panel; no second panel is needed:
dockge.example.com { reverse_proxy 127.0.0.1:5081 } # Replace this placeholder with the exact HTTPS URL returned by # `tailscale serve status`: <machine>.<tailnet>.ts.net { reverse_proxy 127.0.0.1:5081 }
Add a second barrier. The panel controls Docker, i.e. the host, and one password protects it. In order of preference:
-
keep it off the public internet: Tailscale or WireGuard, and a Caddy site bound to that interface;
-
restrict sources in Caddy:
panel.example.com { # Replace TEST-NET-3 with your actual trusted client CIDR. @outside not remote_ip 203.0.113.0/24 respond @outside 403 reverse_proxy 127.0.0.1:5081 } -
or add Caddy
basic_authin front of the panel's own sign-in; -
and in every case, ban repeated failures at the host from the activity log (see Host-level ban).
Only Caddy is tested (bun run test:proxy:wsl, or
bash scripts/proxy-e2e/run.sh on Linux: a real Caddy with TLS in front of the
working tree, with a fake docker that never touches a daemon). Other proxies
need explicit SSE flushing and correct X-Forwarded-For handling, and are not
covered.
Review what is configured. Once signed in, the read-only Access & security
page (/access) lists the loopback path and every proxy origin with an honest
status (ADR 0013): what the panel verified
itself, what is configured but unverified (a proxy origin's network restriction),
and what only you can check (whether a tunnel is open, whether a firewall or VPN
really restricts access). It never marks an access secure from its configuration
alone.
Private access (Tailscale Serve)
If the panel does not need to be reachable from the internet, expose it only on a private network. Tailscale Serve publishes a local service over HTTPS to the members of your tailnet, with no public port and no Caddy:
# On the host, after `tailscale up` (with MagicDNS and HTTPS enabled):
tailscale serve --bg --https=443 http://127.0.0.1:5081
tailscale serve status # prints https://<host>.<tailnet>.ts.net
Then list that ts.net origin and keep the SSH tunnel as a fallback (0.2.0):
# Replace this placeholder with the exact HTTPS URL returned by
# `tailscale serve status`:
COMPOSE_PANEL_PROXY_ORIGINS=https://<machine>.<tailnet>.ts.net
COMPOSE_PANEL_ALLOW_LOOPBACK=true
Use tailscale serve, never tailscale funnel: Funnel would publish the panel
to the public internet. Tailscale is only an access path here — the panel keeps
its own password, so it stays optional and is never a dependency. A Caddy site
bound to the private interface, or an IP allow-list in front of a public Caddy
site, are equally valid alternatives; use whichever your network already has.
Behind Cloudflare Access + Tunnel (identity proxy)
Cloudflare Tunnel
publishes the panel without opening an inbound port, and
Cloudflare Access
checks an identity before the request reaches it. The panel is not modified and
does not know Cloudflare: cloudflared is just another reverse proxy on the
same host, reaching the loopback listener, so the rules above apply unchanged.
-
Install
cloudflaredon the host and point the tunnel's origin at the panel. With a locally-managed tunnel:# ~/.cloudflared/config.yml tunnel: <tunnel-id> ingress: - hostname: panel-cf.example.com service: http://127.0.0.1:5081 - service: http_status:404 -
List the tunnel hostname as a proxy origin, exactly as for Caddy. If it sits next to an existing Caddy site and the SSH tunnel:
COMPOSE_PANEL_PROXY_ORIGINS=https://dockge.example.com,https://panel-cf.example.com COMPOSE_PANEL_ALLOW_LOOPBACK=trueThe panel keeps its own sign-in, sessions and CSRF; facing the same origin with two proxies (for example Caddy and the tunnel) needs no extra setting.
-
Create and verify the Access policy before publishing the route. A Cloudflare Tunnel route with no protecting Access application is reachable publicly, and protecting the hostname does nothing if another network path to the panel bypasses Access. Enable Protect with Access on the tunnel so
cloudflaredvalidates the access token itself, restrict the policy to the identities that may use the panel, and keep the SSH tunnel as a fallback for when Cloudflare is unreachable.
Notes on the trust chain:
- The panel authorizes on its own password and CSRF token only. It never treats a request as authorized because it carries a Cloudflare or identity header.
- Only the last
X-Forwarded-Forentry is read — the one the local proxy (cloudflared) appended. Vendor identity headers such asCF-Connecting-IPare ignored. Do not read them in the panel's name elsewhere. - Placing Cloudflare in front of Caddy (instead of tunnelling straight to
the panel) makes the last
X-Forwarded-Forentry Caddy's connecting address, i.e. a Cloudflare edge address, not the visitor. If you need the real client address in the audit log, terminate at the panel with one trusted proxy.
This path is documented, not covered by the end-to-end test (only Caddy is tested). Verify it on your own host before relying on it.
Optional Caddy site management
Stacks that serve HTTP on the host loopback can get a public domain without
leaving the panel (ADR 0030): a Domains
section on the stack page attaches app.example.com to a service's
loopback-bound port, publishes one managed file per domain, and reloads Caddy.
The panel never edits your main Caddyfile and never touches sites it did
not create. DNS and HTTPS are not checked by the panel: after attaching, open
the application link and verify reachability yourself.
The integration is opt-in and in two parts: a preview that needs nothing, and real attach/detach through a small privileged helper on the host. Without the helper, Domains stays a preview (the managed file is shown, nothing is written or reloaded).
Setup with the script
Supported layout: Caddy installed on the host as the systemd service caddy
running /etc/caddy/Caddyfile (the official package), and the panel deployed
with deploy/compose.yaml. A Caddy that runs in a Docker container is not
supported by the helper: the script refuses it instead of half-installing.
From the Compose Panel checkout on the host:
sudo bash scripts/setup-caddy.sh --check # read-only: checks and the planned changes
sudo bash scripts/setup-caddy.sh --install # applies them after you type "yes"
--check changes nothing: it verifies the host (Linux, systemd, root, host
Caddy service and its Caddyfile, the caddy user, administration API not
disabled, the panel container and its /run/compose-panel mount) and lists
what --install would do. Any failed check stops both modes before a single
change. --install then, each step idempotent (a second run changes nothing):
The container must belong to project compose-panel, service panel, with
this checkout's deploy working directory and only deploy/compose.yaml in
its Compose labels. One-off containers, extra Compose/interpolation files and
an implicit deploy/.env are refused. The configured image reference must
match the running container's reference and still exist locally.
- creates
/etc/caddy/compose-panel.downed bycaddy; - compiles the helper from the panel image that is running, offline
(
docker run --network none … bun build --compile): same version as the panel, host architecture, no Bun on the host and no extra download; installs it to/usr/local/bin/compose-panel-caddy-helper; - installs and enables the systemd socket and service units;
- appends
import /etc/caddy/compose-panel.d/*.caddyto theCaddyfileafter a timestamped backup, validates as thecaddyuser, thensystemctl reload caddy; either failure restores the backup. An existing import of that directory with another pattern is refused, never rewritten. It also runscaddy validateonce inside the helper's sandbox and warns if that fails (see "Systemd sandbox" below); - adds
COMPOSE_PANEL_CADDY_ENABLED=trueandCOMPOSE_PANEL_CADDY_SOCKET_PATHtodeploy/compose-panel.envafter a backup (ignored by Git; it contains the same secrets as the original). Other values already set there are refused, not overwritten; - recreates the panel and checks it is healthy, mounts the socket directory
and reaches the helper from inside the container. If the panel does not
come back healthy, or Compose exits unsuccessfully, the previous
compose-panel.envis restored and recreation is attempted again. A failed rollback is reported explicitly.
Recreation never builds or pulls, and only targets the panel service. To keep the running version even if About → Check has pulled a newer image, setup temporarily points the configured local tag at the running image ID, then restores the previously cached tag, also on failure or a handled signal. This preserves the container's tag reference and Compose labels for browser updates. Do not run image pulls, updates or another setup concurrently. If another writer changes the tag, setup refuses to overwrite that change. A crash or SIGKILL cannot run cleanup and can leave the tag pointing at the previous image; check Docker's actual state before retrying.
--yes skips the confirmation prompt, for automation. Run --install again
after each panel image update (including About → Update): it recompiles
the helper from the new image so both sides keep the same protocol.
deploy/compose.yaml always mounts /run/compose-panel read-only, so the
script never edits that versioned file; on a host without the helper it is an
empty directory.
Manual setup
The script performs exactly these steps; they stay documented for other layouts and for review.
-
Prepare Caddy (host operation, once). The helper owns exactly one directory; your main
Caddyfileimports it:mkdir -p /etc/caddy/compose-panel.d chown caddy:caddy /etc/caddy/compose-panel.d chmod 755 /etc/caddy/compose-panel.d# /etc/caddy/Caddyfile (host paths, forward slashes) import /etc/caddy/compose-panel.d/*.caddycaddy validate --config /etc/caddy/Caddyfile --adapter caddyfile systemctl reload caddyKeep Caddy's administration API enabled (the default):
caddy reloaduses it, and the helper refuses to work without it. -
Build and install the helper (host operation, once per panel version). The panel image already contains Bun and the sources, so the host needs no Bun, no Node, no extra dependency — the output is a single self-contained binary:
out=$(mktemp -d) docker run --rm --network none --entrypoint bun -v "$out:/out" \ "$(docker inspect -f '{{.Image}}' compose-panel)" \ build --compile src/scripts/caddy-helper.ts --outfile /out/compose-panel-caddy-helper install -m 755 "$out/compose-panel-caddy-helper" /usr/local/bin/compose-panel-caddy-helper cp deploy/caddy/caddy-helper.socket "deploy/caddy/caddy-helper@.service" /etc/systemd/system/ systemctl daemon-reload systemctl enable --now caddy-helper.socketThe helper runs as the
caddyuser, one process per request, with no resident daemon. It only ever writes/etc/caddy/compose-panel.dand only runscaddy validate|adapt|reloadwith fixed arguments — no shell, no other command. -
Expose the socket to the panel.
deploy/compose.yamlalready mounts the socket's directory (/run/compose-panel, read-only, same path on both sides), not the socket file: restartingcaddy-helper.socketrecreates the socket, and a file bind mount would keep pointing at the removed one until the panel restarts. The socket unit creates that directory as root-only (0750), so no other host user can reach the helper; if the directory already existed (for example created by an earlierdocker compose up), set it withchmod 750 /run/compose-panel. Then, indeploy/compose-panel.env, set:COMPOSE_PANEL_CADDY_ENABLED=true COMPOSE_PANEL_CADDY_SOCKET_PATH=/run/compose-panel/caddy-helper.sockThen
docker compose -f deploy/compose.yaml up -d --no-deps panel. A socket path without the flag, or a relative path, is refused at startup.
Behavior once enabled
- The stack page lists the domains attached to that stack, each with an
application link (
https://…, new tab, no opener access). - Attach domain checks the service against the effective Compose config
and the port against the TCP
127.0.0.1bindings of its running containers (exactly what the managed file will dial). The helper then refuses a domain already routed anywhere in the current Caddy configuration (exactly or by a*.wildcard, for example in the mainCaddyfile), publishes the managed file without ever overwriting one, validates the whole Caddy configuration, confirms the route is present, and reloads Caddy — all serialized behind a global Caddy lock, under the usual per-stack lock. A failed reload restores the previous file and reloads that; if the restore cannot be confirmed the outcome is reported as unknown, never as success. One attach can take up to about two minutes in the worst case (lock wait plus everycaddycommand at its timeout); the panel waits at least 150 s for it. - Detach asks for an explicit confirmation, then removes the managed file and reloads, with the same rollback.
- Files the panel does not recognize, or managed files changed outside the
panel, are refused rather than overwritten. A missing
importin the mainCaddyfilerefuses the attach before anything is published. - Stacks with a
stack.managed-bylabel show their routing read-only: the label stays informational and attaching from the panel is refused as a workflow rule, not a security boundary.
Other tools that change Caddy on the same host (for example an external deployment pipeline that owns its own routes) must take the same lock around their Caddy step, or their reload can land between a panel publish and its validation. Install the wrapper once and prefix that step with it:
install -m 755 deploy/caddy/with-caddy-lock.sh /usr/local/bin/with-caddy-lock
with-caddy-lock caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
Run it on the host (not inside a container: the lock records a host PID), as
root or caddy. It waits up to CADDY_LOCK_WAIT_SECONDS (default 60) and
exits 75 without running anything if the lock stays held. Keep those tools'
files outside /etc/caddy/compose-panel.d: the panel refuses to attach a
domain they already route, and never touches their files.
Systemd sandbox. The helper service keeps ProtectSystem=strict: everything
but /etc/caddy/compose-panel.d is read-only to it, including Caddy's data
directory (/var/lib/caddy). With public ACME certificates caddy validate
writes nothing. With an internal CA (tls internal, local_certs), caddy validate reads the CA from Caddy's storage and only writes it when it does
not exist yet — so it works once Caddy itself has run with that
configuration, and fails (attach refused, nothing published) otherwise. Do
not loosen the sandbox preventively; if the check below shows a write is
really needed, add a targeted ReadWritePaths= for that exact path only.
Removal without impact: detach every domain (or delete the managed files by
hand and systemctl reload caddy), set COMPOSE_PANEL_CADDY_ENABLED=false
and remove COMPOSE_PANEL_CADDY_SOCKET_PATH, recreate the panel, and stop
the socket (systemctl disable --now caddy-helper.socket). The import line
can stay in the Caddyfile: it matches nothing once the directory is empty. Stacks keep running and stay manageable by the
docker compose CLI throughout.
Manual verification (release gate, on the target host with its real Caddy):
scripts/setup-caddy.sh --checkreports every check and the planned changes without changing anything;--installapplies them, and a second--installadds nothing (no duplicate import or setting, no newCaddyfileorcompose-panel.envbackup).- On a disposable deployment, pull a newer panel tag without installing it, then run setup: the container and helper keep the previous image ID, the newer tag is restored, and About can still check for updates.
- From a second checkout, setup refuses before any changes. Simulate a failed Compose recreation on the disposable deployment: the env file is restored and rollback success or failure is reported accurately.
- Attach a domain to a loopback-bound service: the managed file appears
with the exact ownership header,
caddy adaptcontains the route, and the application answers through Caddy. - Re-attach the same mapping: reported unchanged, no reload.
- Attach with the
importline removed from the mainCaddyfile: refused with the actionable message, nothing published. - Attach a port the service does not publish on loopback, a service outside the stack, and a domain file created by hand: all refused.
- Break the reload (for example stop Caddy's admin listener in a test config): the file is rolled back and the failure — never a success — is reported. Restore the listener afterwards.
- Detach asks for confirmation, then the file is gone and Caddy reloaded.
- Two attaches at once (two stacks): both succeed, serialized, one reload each.
- Attach a domain already served by the main
Caddyfileor by another tool: refused, nothing published, its routing untouched. - While
with-caddy-lock sleep 20runs, start an attach: it completes only after the wrapper exits. - Sandbox, as the service runs:
systemd-run --pipe --wait -p User=caddy -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes -p ReadWritePaths=/etc/caddy/compose-panel.d caddy validate --config /etc/caddy/Caddyfile --adapter caddyfilesucceeds (repeat withadapt), and a first attach of a brand-new domain reloads successfully. systemctl restart caddy-helper.socket, then the stack page still lists the domains (directory mount survives the socket recreation).
Backups and rollback
Two layers, both plain files:
- Per-save backups made by the panel itself under
<root>/.compose-panel/backups/<stack>/(previouscompose.yaml/.envbefore each write). To roll a stack back, copy the backup over the live file, thendocker compose up -dfrom that directory. The panel keeps theCOMPOSE_PANEL_BACKUP_RETAIN(default 10) most recent backups of each file and deletes older ones after a save. "Delete all backups" on a stack's edit page removes them at once, for example after rotating a secret in.env(backups of.envcontain its secrets). Only files the panel created are counted or deleted (ADR 0017). - Host-level stacks backup before upgrades or risky edits:
tar -czf /root/stacks-backup-$(date +%F).tar.gz -C /opt stacks. Restore withtar -xzf, then re-read state in the panel.
Updating from the browser
With the official image (ghcr.io/lekoala/compose-panel), About
(/about) updates the panel itself (ADR 0018):
- Check & download update pulls the image tag the panel runs from.
Nothing restarts. Use
image: ghcr.io/lekoala/compose-panel:latestindeploy/compose.yamlto follow releases: a version tag such as0.3.0stays on that release version rather than following new releases. - For a newer image, the panel asks GitHub for the release of that exact version and the repository's versioned release registry (two bounded requests, no token). The image is installable only when the release is published (not a draft, not a prerelease), its digest matches the latest registry record and the downloaded image, and 72 hours have passed since both publication and digest registration. A replacement digest is recorded in Git and waits again; an identical retry preserves the date. Otherwise About says why, or when the release becomes installable. There is no bypass in the panel: to update sooner, use the host commands below.
- Install update… asks for a confirmation and checks everything again. It is refused while a stack action or save is running.
- The panel starts a one-shot container,
compose-panel-updater, from the image it runs now, with no network, the Docker socket and the deployment directory (read-only). The updater checks that the tag still resolves to the approved image with the release digest and that the Compose file still matches, tags the current image ascompose-panel:rollback, runsdocker compose up -d --no-deps --no-build --pull never panel(nothing is downloaded or built), then waits until the new container is healthy. Managed stacks are not touched. - The page reloads by itself when the panel is back. Sign in again (sessions
live in memory). About shows the result, the previous and target images,
the updater output and the rollback commands, read from the finished
updater container. It stays in Docker until the next update
(
docker logs compose-panel-updaterworks from the host too).
Requirements, all read from Docker (no setting): the panel runs in a container
started by docker compose, from a pulled tag of the official image (not a
local build, not pinned by digest), with the socket bind-mounted at
/var/run/docker.sock, and all its Compose and env files inside the
deployment directory. If one is missing, About says which and the update is
done from the host as below. The panel reaches api.github.com only on Check
and Install. The updater applies the deployment files as they are on disk, like
a manual compose up. It does not depend on the panel process: if the panel is
stopped or restarted during an update, the updater keeps going, and the next
panel start shows its outcome. The 72-hour delay is a panel policy only: the
downloaded image stays usable from the host CLI.
Rollback to the exact image the panel ran before the last update (shown on
About, with the project's own -p/-f options filled in):
docker image tag compose-panel:rollback ghcr.io/lekoala/compose-panel:latest
cd /path/to/compose-panel/deploy
docker compose -p compose-panel -f /path/to/compose-panel/deploy/compose.yaml up -d --no-deps --no-build --pull never panel
Nothing is downloaded and no file is edited. The next Check downloads the newer
image again. docker image prune -a removes compose-panel:rollback once no
container uses it.
Upgrade and rollback
Run these commands on the host, from the repository checkout, never from inside the panel: recreating the panel container stops the process that would run them. This is also the recovery path when an update from the browser fails.
- Back up (layer 2).
- Built locally:
git pull(or check out the new tag), thendocker compose -f deploy/compose.yaml build. Published image: setimage: ghcr.io/lekoala/compose-panel:<version>indeploy/compose.yaml, thendocker compose -f deploy/compose.yaml pull panel. docker compose -f deploy/compose.yaml up -d --no-deps panel, then run the smoke checks below.
Rollback: check out the previous tag/commit and rebuild, or put the previous
version tag back in image: and pull, then up -d --no-deps panel again.
Stack files are untouched in either direction, and managed stacks keep running
while the panel restarts.
Removal without impact: docker compose -f deploy/compose.yaml down
removes only the panel container. Running services, networks and volumes are
untouched — verify with docker ps afterwards. Stacks remain fully manageable
by the docker compose CLI.
Activity log
The panel writes an append-only audit log of sign-ins, saves and operation
starts as NDJSON, one file per day under
<root>/.compose-panel/audit/ (inside the already-mounted stacks directory, so
no new volume is needed). The most recent COMPOSE_PANEL_AUDIT_RETAIN_FILES
files (default 31) are kept. A read-only view is available at /audit once
signed in. The log never contains passwords, hashes or .env values.
Useful variables: COMPOSE_PANEL_AUDIT_ENABLED (default true),
COMPOSE_PANEL_AUDIT_DIR (default the stacks path above),
COMPOSE_PANEL_AUDIT_ROTATION (day or month),
COMPOSE_PANEL_AUDIT_RETAIN_FILES, COMPOSE_PANEL_AUDIT_MAX_FILE_BYTES,
COMPOSE_PANEL_AUDIT_MAX_READ_LINES.
The activity log, the start time of an action and container logs are all shown
in one display time zone. Set COMPOSE_PANEL_TIMEZONE to an IANA name (e.g.
Europe/Brussels) to fix it for every reader; leave it unset to use each
reader's browser zone. Timestamps are always stored and emitted in UTC: this
only changes presentation, and an invalid zone is refused at startup.
Every failed login is a line like:
{"ts":"2026-10-10T09:00:00.000Z","event":"login","result":"failure","actor":"203.0.113.7","username":"admin account","detail":"over limit, delayed"}
Host-level ban (fail2ban / CrowdSec)
The panel throttles logins itself (a bounded delay, never a lockout), so a real
source ban belongs at the host, on the SSH daemon and on this audit file. This
is only meaningful behind Caddy (see Behind Caddy):
then actor is the client address, taken from the last X-Forwarded-For
entry, the one Caddy added. Through an SSH tunnel actor is direct. A
minimal fail2ban jail (NDJSON parsed with a regex filter), banning the HTTPS
ports Caddy listens on:
# /etc/fail2ban/filter.d/compose-panel.conf
[Definition]
failregex = "result":"failure".*"actor":"<HOST>"
# /etc/fail2ban/jail.d/compose-panel.conf
[compose-panel]
enabled = true
port = http,https
filter = compose-panel
logpath = /opt/stacks/.compose-panel/audit/*.log
maxretry = 10
findtime = 600
bantime = 3600
Forwarded addresses are trusted only on a request whose Host matches a
COMPOSE_PANEL_PROXY_ORIGINS entry; through the loopback (SSH) path
X-Forwarded-For is ignored and actor is direct. Note the trust boundary
(ADR 0012): a client able to reach the
loopback listener can present a proxy-origin Host and forge the header, so this
is a best-effort attribution, not an authenticated proxy identity.
Manual verification (release gate)
Audit hardening additions (run only with disposable fixtures):
- Inspect the image for
NODE_ENV=production. Exercise a thrown handler throughtests/http-server.test.ts: status 500, no-store and no exception details. - Run
bun run test:docker-lab:wsl(or the Linux lab script): real toolbox reads must accept the regular manifest and refuse directories, symlinks and oversized manifests; the panel writes no manifest to temporary storage. The metadata fixture also verifies realps/imagesagainst an explicitly selected env file and with default.envdiscovery disabled. - As an unprivileged Linux user, try a save whose original owner/group cannot
be reproduced. Confirm the original stays intact, no
.cp-tmp-*remains, and the editor reports failure. Runtests/fs-safe.test.tson Linux for the isolated regression; pair-save rollback remains covered by editor tests. - On a test repository/registry, publish and rerun the same digest: there is one registry record and its date stays unchanged. Retry with a replacement: a second record preserves the first and About waits another 72 hours. Edit only the release digest: the new panel must refuse the registry mismatch. Verify an HTTP response without Content-Length is canceled at the byte cap. Local tests use fake GitHub; no production release is edited during tests.
The new registry starts empty. The next successful publication records its
image. Older versions without a record can still be installed from the host;
the new self-updater refuses them. Commit releases.json to master before
running the new publication workflow. If default-branch protection refuses the
publication job's registry commit, resolve that policy through the repository's
normal review process; the workflow fails rather than publishing an unrecorded
release. No history is seeded with an unverified or backdated digest.
Automated CI builds the image, scans it and runs unit tests, but the
following need a real Linux host with Docker and are done by hand before any
release. Verified 2026-10-10 against image compose-panel:local
(linux/amd64, Bun 1.4.3, Compose v5.6.0, Docker Desktop Linux daemon):
- Image builds (
docker build -t compose-panel:local .). - Login smoke test: login page → 303 login → stack list shows the stack.
- Read: detail page shows the Compose file and the real project state.
- Validate + save: edited service saved (303), file on disk updated.
Invalid drafts returning 400 is covered by unit tests (
editor+appsuites), not re-proven against the live daemon. up: pulls the image, creates network/container, SSE reportssucceededexit 0; detail shows the real container state afterwards.down: stack returns tonot-created, no leftover containers.- Without a socket mount,
upfails honestly (failed, exit 1, Docker's error in the output, stateunknown) — no invented success. - Interruption/restart:
docker restartinvalidates the old session (/→ 302, re-login required), health staysok, files intact. - Removal: deleting the panel container leaves the daemon healthy and stack files untouched.
- Reverse proxy mode behind Caddy 2.8.4 with TLS (2026-10-10, WSL Ubuntu
24.04, fake
docker):scripts/proxy-e2e/run.shpasses 14/14 — Secure cookies, foreign Origin and Host refused, forgedX-Forwarded-Forignored, live SSE for actions and logs, UTF-8, sign-out closes open streams. - Multiple access origins (ADR 0012):
scripts/proxy-e2e/run.shwith two TLS origins and the coexisting loopback path passes 20/20 (2026-10-11, WSL Ubuntu 24.04, fakedocker) — both origins accepted with Secure cookies, loopback non-Secure, a cross-path Origin refused. - Same check on the target host with its real Caddy and a real stack.
- Self-update logic (ADR 0018) on the disposable
lab daemon (
bun run test:docker-lab:wsl, 2026-10-12, Docker 29.9.0): real Compose labels are read; a stale target or a digest the image does not carry is refused with nothing changed; with a registry inside the lab, the approved image is installed and verified healthy, a second service keeps its container,compose-panel:rollbackholds the previous image and the documented rollback brings it back. Pulling multi-platformalpineby tag records the index digest frombuildx imagetools inspectinRepoDigests(amd64, containerd image store of the lab daemon). - Targeted operations, filtered logs and pull policy (ADR 0019,
ADR 0020,
ADR 0021,
ADR 0022) on the disposable
lab daemon (
bun run test:docker-lab:wsl, 2026-10-12, Docker 29.8.1):startbrings stopped containers back, scopedrestart --no-depsleaves a stopped dependent alone, scopedup --no-depsdoes not start dependencies,?service=&tail=reads through the--separator,config --servicesdiscovery and refusals work, andpullsucceeds on a stack mixing registry, build-only and image+build services (the local ones are skipped, then built byup). - Real updater container on the disposable lab daemon (2026-10-12,
Docker 29.9.0, amd64): the panel image built in the lab, deployed by
Compose on
PORT=5199(healthy: the image health check followsPORT), a newer image pushed to a lab registry, thencompose-panel-updaterstarted with the arguments the panel builds: exit 0, new container on the target image and healthy,compose-panel:rollbackon the previous image, updater labels read back. - Self-update end to end with the published image on a disposable host,
on amd64 and arm64, and with the classic image store as well as the
containerd one: deploy
ghcr.io/lekoala/compose-panel:latestat the previous version, publish the new one (the GitHub Release carries the digest line), Check: the release shows a waiting period, then (72 hours later, or with a release old enough) Install; the panel comes back on the new version,compose-panel-updaterexited 0, managed stacks untouched, the rollback commands from About bring the previous image back. Then make it fail (for example changeimage:indeploy/compose.yaml) and confirm "Refused, nothing changed" with the panel still running.
Audit correction verification
The checklist above refers to the previously built image. Source corrections after that image require separate verification; no rebuild or deployment was performed as part of the correction work.
- Isolated Linux child-process fixtures: a mutation runner timeout and a
provider log abort both terminate a child ignoring
SIGTERM; split UTF-8 remains intact. Verified in WSL Ubuntu 24.04 with Bun 1.4.0; CI uses pinned Bun 1.4.2, which remains the supported minimum. - HTTP/provider fixtures: refuse conflicting new project names, drain an accepted save, bound a stuck save and refuse overlapping log follows.
- Real config-only Compose verification (WSL, Compose 5.5.1): process
environment overrides
.env;.envoverrides YAMLname:; YAML name overrides the directory fallback. The collision refusal uses fixture discovery and real config resolution; no daemon command was issued. - Before releasing the rebuilt image, use a disposable Linux daemon to
create a project from one directory and request
upfrom another directory with the samename:(and then withCOMPOSE_PROJECT_NAMEin.env). Expect 409 and unchanged original containers. A unique name must use the resolved-pidentity and still work from the ordinary Compose CLI. - Follow real logs containing partial lines and non-ASCII text; confirm exact text, stop the last reader, then follow again after process termination.
- Send
SIGTERMwhile a save is validating and while an operation is running; confirm new requests are refused, accepted work drains within the shared deadline and no containers already started are stopped by panel shutdown.
Never use a production daemon for these release checks. Unknown outcomes after the shared deadline or a crash still require inspecting files and Docker state.