Compose Panel docs

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 by docker compose.
  • Docker to build or pull the image. No Bun install is needed on the host: the image runs hash-password for 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 unless COMPOSE_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/stacks plus matching STACKS_DIR) and the socket. Otherwise compose ls ConfigFiles no longer match and stacks report unknown.
  • stop_grace_period (35s) must exceed COMPOSE_PANEL_SHUTDOWN_GRACE_MS (default 30s) so SIGTERM drains 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 a ports: 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.sock gives whoever can edit Compose files very high control over the host (see docs/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 single COMPOSE_PANEL_PUBLIC_ORIGIN=https://…; see Upgrading from 0.1.0.

  1. In deploy/compose-panel.env, list the origins. This setting allows HTTPS access and makes Secure cookies and Caddy's X-Forwarded-For apply on those hosts:

    COMPOSE_PANEL_PROXY_ORIGINS=https://panel.example.com
    

    Each entry must be https (outside COMPOSE_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.net
    

    With 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=true
    

    Upgrading from 0.1.0: COMPOSE_PANEL_PUBLIC_ORIGIN is refused at startup — rename it to COMPOSE_PANEL_PROXY_ORIGINS, and remove any COMPOSE_PANEL_TRUST_PROXY (forwarded headers now follow the matched origin automatically). See ADR 0012.

  2. 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: host for 127.0.0.1 to mean the host. Then add to the panel service in deploy/compose.yaml:

    labels:
      caddy: panel.example.com
      caddy.reverse_proxy: 127.0.0.1:5081
    

    A Caddy container on a bridge network cannot reach the panel. That is deliberate: the bind is not configurable.

  3. Restart the panel and check that it says Reverse proxy mode: origins https://panel.example.com; ... at startup. Sign in through the public URL. With COMPOSE_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_auth in 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.

  1. Install cloudflared on 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
    
  2. 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=true
    

    The 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.

  3. 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 cloudflared validates 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-For entry is read — the one the local proxy (cloudflared) appended. Vendor identity headers such as CF-Connecting-IP are 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-For entry 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.

  1. creates /etc/caddy/compose-panel.d owned by caddy;
  2. 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;
  3. installs and enables the systemd socket and service units;
  4. appends import /etc/caddy/compose-panel.d/*.caddy to the Caddyfile after a timestamped backup, validates as the caddy user, then systemctl reload caddy; either failure restores the backup. An existing import of that directory with another pattern is refused, never rewritten. It also runs caddy validate once inside the helper's sandbox and warns if that fails (see "Systemd sandbox" below);
  5. adds COMPOSE_PANEL_CADDY_ENABLED=true and COMPOSE_PANEL_CADDY_SOCKET_PATH to deploy/compose-panel.env after a backup (ignored by Git; it contains the same secrets as the original). Other values already set there are refused, not overwritten;
  6. 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.env is 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.

  1. Prepare Caddy (host operation, once). The helper owns exactly one directory; your main Caddyfile imports 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/*.caddy
    
    caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
    systemctl reload caddy
    

    Keep Caddy's administration API enabled (the default): caddy reload uses it, and the helper refuses to work without it.

  2. 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.socket
    

    The helper runs as the caddy user, one process per request, with no resident daemon. It only ever writes /etc/caddy/compose-panel.d and only runs caddy validate|adapt|reload with fixed arguments — no shell, no other command.

  3. Expose the socket to the panel. deploy/compose.yaml already mounts the socket's directory (/run/compose-panel, read-only, same path on both sides), not the socket file: restarting caddy-helper.socket recreates 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 earlier docker compose up), set it with chmod 750 /run/compose-panel. Then, in deploy/compose-panel.env, set:

    COMPOSE_PANEL_CADDY_ENABLED=true
    COMPOSE_PANEL_CADDY_SOCKET_PATH=/run/compose-panel/caddy-helper.sock
    

    Then 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.1 bindings 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 main Caddyfile), 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 every caddy command 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 import in the main Caddyfile refuses the attach before anything is published.
  • Stacks with a stack.managed-by label 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 --check reports every check and the planned changes without changing anything; --install applies them, and a second --install adds nothing (no duplicate import or setting, no new Caddyfile or compose-panel.env backup).
  • 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 adapt contains the route, and the application answers through Caddy.
  • Re-attach the same mapping: reported unchanged, no reload.
  • Attach with the import line removed from the main Caddyfile: 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 Caddyfile or by another tool: refused, nothing published, its routing untouched.
  • While with-caddy-lock sleep 20 runs, 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 caddyfile succeeds (repeat with adapt), 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:

  1. Per-save backups made by the panel itself under <root>/.compose-panel/backups/<stack>/ (previous compose.yaml/.env before each write). To roll a stack back, copy the backup over the live file, then docker compose up -d from that directory. The panel keeps the COMPOSE_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 .env contain its secrets). Only files the panel created are counted or deleted (ADR 0017).
  2. Host-level stacks backup before upgrades or risky edits: tar -czf /root/stacks-backup-$(date +%F).tar.gz -C /opt stacks. Restore with tar -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):

  1. Check & download update pulls the image tag the panel runs from. Nothing restarts. Use image: ghcr.io/lekoala/compose-panel:latest in deploy/compose.yaml to follow releases: a version tag such as 0.3.0 stays on that release version rather than following new releases.
  2. 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.
  3. Install update… asks for a confirmation and checks everything again. It is refused while a stack action or save is running.
  4. 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 as compose-panel:rollback, runs docker 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.
  5. 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-updater works 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.

  1. Back up (layer 2).
  2. Built locally: git pull (or check out the new tag), then docker compose -f deploy/compose.yaml build. Published image: set image: ghcr.io/lekoala/compose-panel:<version> in deploy/compose.yaml, then docker compose -f deploy/compose.yaml pull panel.
  3. 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 through tests/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 real ps/images against an explicitly selected env file and with default .env discovery 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. Run tests/fs-safe.test.ts on 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 + app suites), not re-proven against the live daemon.
  • up: pulls the image, creates network/container, SSE reports succeeded exit 0; detail shows the real container state afterwards.
  • down: stack returns to not-created, no leftover containers.
  • Without a socket mount, up fails honestly (failed, exit 1, Docker's error in the output, state unknown) — no invented success.
  • Interruption/restart: docker restart invalidates the old session (/ → 302, re-login required), health stays ok, 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.sh passes 14/14 — Secure cookies, foreign Origin and Host refused, forged X-Forwarded-For ignored, live SSE for actions and logs, UTF-8, sign-out closes open streams.
  • Multiple access origins (ADR 0012): scripts/proxy-e2e/run.sh with two TLS origins and the coexisting loopback path passes 20/20 (2026-10-11, WSL Ubuntu 24.04, fake docker) — 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:rollback holds the previous image and the documented rollback brings it back. Pulling multi-platform alpine by tag records the index digest from buildx imagetools inspect in RepoDigests (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): start brings stopped containers back, scoped restart --no-deps leaves a stopped dependent alone, scoped up --no-deps does not start dependencies, ?service=&tail= reads through the -- separator, config --services discovery and refusals work, and pull succeeds on a stack mixing registry, build-only and image+build services (the local ones are skipped, then built by up).
  • 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 follows PORT), a newer image pushed to a lab registry, then compose-panel-updater started with the arguments the panel builds: exit 0, new container on the target image and healthy, compose-panel:rollback on 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:latest at 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-updater exited 0, managed stacks untouched, the rollback commands from About bring the previous image back. Then make it fail (for example change image: in deploy/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; .env overrides YAML name:; 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 up from another directory with the same name: (and then with COMPOSE_PROJECT_NAME in .env). Expect 409 and unchanged original containers. A unique name must use the resolved -p identity 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 SIGTERM while 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.