ComposePulse is a self-hosted web UI for safely updating selected Docker Compose apps on a NAS or Docker host.
It is built for setups where compose projects live under a single configured root path and you want a narrow, auditable workflow instead of a general-purpose container manager.
The current public release scope is desktop-first. Mobile browsers and installed PWA mode are best-effort conveniences, not part of the current release gate, so use a desktop browser for first setup and important operational changes.
What ComposePulse does:
- Registers approved compose projects from a fixed root path
- Runs manual updates with
docker compose pullanddocker compose up -d - Accepts DIUN webhooks and queues only matching targets for auto update
- Skips targets that already have queued or running update jobs instead of re-queueing them
- Streams live job logs and dashboard updates
- Shows 24h operational metrics and falls back to zero for missing optional telemetry history during upgrades
- Shows the running app version in the login screen and dashboard header so you can confirm the deployed build quickly
- Includes best-effort mobile/PWA support
- Keeps job history, audit summaries, DIUN events, and webhook receipts
- Runs
docker image prune -ffrom the UI
What ComposePulse does not do:
- It does not browse arbitrary filesystem paths
- It does not write persistent data outside
/data - It does not execute arbitrary shell commands
- It is not a full Docker or Kubernetes control plane
Login
Dashboard example
Many self-hosted setups have a gap between "I can run docker compose pull manually" and "I trust a tool to auto-update the right stack without touching everything else."
ComposePulse is the middle ground:
- safer than handing a UI broad Docker host access and arbitrary path control
- easier than SSHing in for every single update
- more selective than blanket auto-update tools
- ComposePulse runs as its own container.
- It mounts one compose-project root as read-only and
/dataas writable app storage. - You register only the compose directories you want ComposePulse to manage.
- For each target, ComposePulse stores one or more image repositories to match against DIUN events.
- Updates run through a single in-process queue so jobs do not overlap.
Allowed runtime commands are intentionally limited to:
docker compose -f <compose_file> pulldocker compose -f <compose_file> up -ddocker image prune -f
You need:
- Docker Engine with the Docker Compose plugin available inside the app container
- Your managed compose projects stored under one configured root path
- A writable directory for ComposePulse data
- DIUN if you want automatic updates; manual-only operation does not require it
Example target layout:
/share/Container/
app1/
docker-compose.yml
app2/
compose.yaml
ComposePulse only accepts one-level directories under the configured root. With the bundled compose example, for instance:
- allowed:
/share/Container/app1 - rejected:
/share/Container - rejected:
/share/Container/app1/subdir
cp .env.example .envMinimum required values:
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password
DIUN_WEBHOOK_SECRET=change-me-diun-webhook-secret
APP_DATA_BIND_DIR=dataNotes:
APP_DATA_BIND_DIR=datastores the SQLite DB and app state in./data- If you prefer a NAS path, use something like
/share/Container/composepulse/data DIUN_WEBHOOK_SECRETis still required by the app configuration even if you start with manual-only updates
Generate a strong DIUN secret:
./scripts/generate_diun_secret.shOptional: generate Web Push VAPID keys:
./scripts/generate_vapid_keys.shIf your .env lives elsewhere:
./scripts/generate_diun_secret.sh /path/to/.env
./scripts/generate_vapid_keys.sh /path/to/.envPull a published Docker Hub image with the default compose file:
COMPOSEPULSE_IMAGE=changjo/composepulse:v0.1.0 docker compose up -dBuild from the local checkout instead:
docker compose -f docker-compose.build.yml up -d --buildDefault URL:
http://<HOST-IP>:8087/login
File roles:
docker-compose.yml: pull a published image from Docker Hubdocker-compose.build.yml: build from the current checkout
The default Docker Hub compose file uses changjo/composepulse:latest, but pinning COMPOSEPULSE_IMAGE to a release tag such as changjo/composepulse:v0.1.0 is safer for production.
The local build compose file defaults the in-app version label to dev; set COMPOSEPULSE_APP_VERSION if you want a custom version string in a locally built image.
If a pull fails with toomanyrequests, ComposePulse is hitting a registry rate limit. The app now waits longer before retrying, but repeated update triggers can still exhaust the registry window.
If you are not using /share/Container, override both the read-only bind mount and CONTAINER_ROOT in your own local override file such as docker-compose.custom.yml. Do not commit personal override files to the public repository.
Use the credentials from .env and sign in at:
http://<HOST-IP>:8087/login
After login:
- Open
Register Container. - Pick a discovered compose project.
- Pick the image repository that should trigger updates for that target.
- Save the target.
Discovery resolves compose image: values with .env and environment-variable interpolation, so a target can map to multiple repositories through image_repos.
- Select one or more rows in
Registered Containers. - Click
Run Selected Update. - Watch the job in
Live Logs.
- Enable
Autofor the target. - Deploy or configure DIUN to watch the same image repositories.
- Point DIUN at the webhook endpoint.
- When DIUN sends an event for a matching repository, ComposePulse queues only the matching target.
Automatic updates depend on DIUN as the upstream image-change detector. If you only use manual updates, you can skip the DIUN runtime setup and ignore this section until you enable auto-update.
Recommended internal webhook target:
- URL:
http://composepulse:8087/api/diun/webhook - Header:
X-DIUN-SECRET: <DIUN_WEBHOOK_SECRET>
ComposePulse accepts DIUN repository information from these payload fields:
entry.image.nameentry.image.repositoryentry.imageentry.repositoryimage.repositoryrepositoryimage
Repository matching is normalized before comparison:
nginxbecomesdocker.io/library/nginxmyorg/appbecomesdocker.io/myorg/apprepo:tagis matched asrepo
Notes:
/api/diun/webhook/configreturns a masked secret by default- Only set
WEBHOOK_CONFIG_SHOW_SECRET=trueif you explicitly want the raw secret exposed in the UI - Cooldown windows and maintenance windows can be used to suppress duplicate or badly timed auto updates
- Webhook receipts now use explicit failure reasons such as
payload_invalid,no_match,queue_full, andinternal_errorinstead of a generic unknown reason - Existing targets from older builds are normalized to canonical repository names on startup so Docker Hub shorthand values such as
nginxkeep matching DIUN webhook payloads
- Dashboard APIs require a login session cookie
- Login requests are rate-limited by IP and username
- DIUN webhooks require
X-DIUN-SECRET - Compose directories are restricted to one level under the configured
CONTAINER_ROOT - App data stays under
/data - Runtime commands are allowlisted and fixed
For internet-exposed deployments, put ComposePulse behind a reverse proxy and optionally add edge auth such as Caddy BasicAuth.
composepulse.example.com {
reverse_proxy composepulse:8087
}With BasicAuth:
composepulse.example.com {
basicauth {
admin <bcrypt-hash>
}
reverse_proxy composepulse:8087
}services:
composepulse:
labels:
- "traefik.enable=true"
- "traefik.http.routers.composepulse.rule=Host(`composepulse.example.com`)"
- "traefik.http.routers.composepulse.entrypoints=websecure"
- "traefik.http.routers.composepulse.tls=true"
- "traefik.http.services.composepulse.loadbalancer.server.port=8087"With BasicAuth:
services:
composepulse:
labels:
- "traefik.enable=true"
- "traefik.http.routers.composepulse.rule=Host(`composepulse.example.com`)"
- "traefik.http.routers.composepulse.entrypoints=websecure"
- "traefik.http.routers.composepulse.tls=true"
- "traefik.http.services.composepulse.loadbalancer.server.port=8087"
- "traefik.http.middlewares.composepulse-auth.basicauth.users=admin:$$2y$$14$$..."
- "traefik.http.routers.composepulse.middlewares=composepulse-auth"Web Push is disabled by default.
WEB_PUSH_ENABLED=false
# WEB_PUSH_VAPID_PUBLIC_KEY=
# WEB_PUSH_VAPID_PRIVATE_KEY=
# WEB_PUSH_SUBJECT=mailto:admin@example.comWhen enabled, the UI provides:
- device-specific push subscription management
- push test delivery
- badge clearing when the app is opened
- production notifications only for update success and update failure, including the target image summary when available
- prune jobs use explicit prune success/failure notification text instead of update wording
Notes:
- Each browser or device must subscribe separately
- Rotating VAPID keys invalidates existing subscriptions
- Push status lookup uses a timeout and fallback path so it does not block the rest of the dashboard
- Android: use the browser install or add-to-home-screen menu
- iPhone: Safari -> Share -> Add to Home Screen
- If you change iPhone icons later, remove the existing shortcut and add it again
- After frontend updates, reopen or refresh the installed app once so the latest versioned assets and service worker are activated
- Web Push on iOS requires iOS 16.4+
- Mobile/PWA behavior is not part of the current public release gate; treat it as convenience functionality rather than the primary operating path
Required:
ADMIN_USERNAMEADMIN_PASSWORDDIUN_WEBHOOK_SECRETAPP_DATA_BIND_DIR
Frequently adjusted optional values:
CONTAINER_ROOT(when overridden in your local compose file)WEBHOOK_CONFIG_SHOW_SECRETCOOLDOWN_SECONDSPULL_RETRY_MAX_ATTEMPTSPULL_RETRY_DELAY_SECONDSAUTO_UPDATE_WINDOW_START_HOURAUTO_UPDATE_WINDOW_END_HOURLOGIN_RATE_LIMIT_WINDOW_SECONDSLOGIN_RATE_LIMIT_MAX_ATTEMPTSLOGIN_RATE_LIMIT_LOCK_SECONDSWEB_PUSH_ENABLEDWEB_PUSH_VAPID_PUBLIC_KEYWEB_PUSH_VAPID_PRIVATE_KEYWEB_PUSH_SUBJECT
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/health |
Health check | None |
| POST | /api/auth/login |
Log in | None |
| POST | /api/auth/logout |
Log out | None |
| GET | /api/auth/me |
Session state | None |
| GET | /api/containers/discover |
Discover registration candidates | Session |
| GET | /api/targets |
List registered targets | Session |
| POST | /api/targets |
Add a target | Session |
| PATCH | /api/targets/{id} |
Update target settings | Session |
| DELETE | /api/targets/{id} |
Delete a target | Session |
| POST | /api/jobs/update |
Queue selected update | Session |
| POST | /api/jobs/prune |
Queue prune | Session |
| POST | /api/jobs/delete-all |
Delete all jobs | Session |
| GET | /api/jobs |
List jobs | Session |
| DELETE | /api/jobs/{id} |
Delete a job | Session |
| GET | /api/jobs/export.csv |
Export jobs as CSV | Session |
| GET | /api/jobs/{id}/stream |
Job log SSE | Session |
| GET | /api/stream/dashboard |
Dashboard patch SSE | Session |
| GET | /api/audit/targets |
Audit summary | Session |
| GET | /api/diun/events |
Recent DIUN events | Session |
| GET | /api/diun/receipts |
Webhook receipts | Session |
| GET | /api/diun/webhook/config |
Webhook config | Session |
| GET | /api/metrics |
Operational metrics | Session |
| GET | /api/push/config |
Push configuration and device status | Session |
| POST | /api/push/subscriptions |
Register or update a push subscription | Session |
| DELETE | /api/push/subscriptions |
Remove a push subscription | Session |
| POST | /api/push/test |
Send a push test | Session |
| POST | /api/diun/webhook |
Receive DIUN webhook | X-DIUN-SECRET |
Common error format:
{"error":"message","code":"error_code"}Example login rate-limit response:
{"error":"too many login attempts, try again later","code":"auth_rate_limited","retry_after_seconds":120}Start a local preview:
./scripts/dev_local_ui.sh startUseful commands:
./scripts/dev_local_ui.sh start-hot
./scripts/dev_local_ui.sh logs
./scripts/dev_local_ui.sh restart
./scripts/dev_local_ui.sh restart-hot
./scripts/dev_local_ui.sh stop
./scripts/dev_local_ui.sh cleanIf Go is installed locally:
go test ./...Dockerized Go toolchain:
docker run --rm -v "$PWD":/src -w /src golang:1.22-alpine sh -lc 'apk add --no-cache build-base >/dev/null && /usr/local/go/bin/gofmt -w main.go main_test.go integration_test.go diun_fixture_test.go && CGO_ENABLED=1 /usr/local/go/bin/go test ./...'Manual QA report template:
docs/QA_REPORT_TEMPLATE.md
GitHub Actions handles both verification and image publishing:
.github/workflows/ci.ymlruns onmainpushes and pull requests.github/workflows/release.ymlruns on tag pushes that matchv*- Release builds publish multi-arch Docker Hub images for
linux/amd64andlinux/arm64 - A GitHub Release is created automatically from the pushed tag
- Published Docker images embed the Git tag into the UI so the login screen and dashboard header show the running release version
Configure these repository settings before the first release tag:
- Required repository variable:
DOCKERHUB_USERNAME - Required repository secret:
DOCKERHUB_TOKEN - Optional repository variable:
DOCKERHUB_NAMESPACE - Optional repository variable:
DOCKERHUB_IMAGE_NAME
Defaults:
DOCKERHUB_NAMESPACEdefaults toDOCKERHUB_USERNAMEDOCKERHUB_IMAGE_NAMEdefaults to the GitHub repository name
Tag behavior:
- Stable tag
v0.1.0publishesdocker.io/<namespace>/<image>:v0.1.0 - Stable tag
v0.1.0also publishesdocker.io/<namespace>/<image>:v0.1 - Stable tag
v0.1.0also updatesdocker.io/<namespace>/<image>:latest - Prerelease tag
v0.2.0-rc1publishes onlydocker.io/<namespace>/<image>:v0.2.0-rc1
Create a release:
git tag v0.1.0
git push origin v0.1.0For Docker Hub deployments, use the default docker-compose.yml. It reads COMPOSEPULSE_IMAGE and defaults to changjo/composepulse:latest.
If you see toomanyrequests in job logs, treat it as a registry-side rate limit and expect retries or a later rerun rather than an internal queue bug.
Recommended pinned-tag launch:
COMPOSEPULSE_IMAGE=changjo/composepulse:v0.1.0 docker compose up -dIf you build from the checked-out source and want the UI to show a specific version instead of dev, use docker-compose.build.yml and set COMPOSEPULSE_APP_VERSION before the build:
COMPOSEPULSE_APP_VERSION=v0.1.0 docker compose -f docker-compose.build.yml up -d --buildIts service definition is:
services:
composepulse:
image: "${COMPOSEPULSE_IMAGE:-changjo/composepulse:latest}"
container_name: composepulse
user: "0:0"
environment:
ADMIN_USERNAME: "${ADMIN_USERNAME}"
ADMIN_PASSWORD: "${ADMIN_PASSWORD}"
DIUN_WEBHOOK_SECRET: "${DIUN_WEBHOOK_SECRET:?DIUN_WEBHOOK_SECRET is required}"
DB_PATH: /data/app.db
CONTAINER_ROOT: /share/Container
PORT: "8087"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /share/Container:/share/Container:ro
- ${APP_DATA_BIND_DIR}:/data
restart: unless-stopped
ports:
- "8087:8087"./scripts/prepublish_check.shDetailed checklist:
docs/OPEN_SOURCE_RELEASE_CHECKLIST.md
This project is released under the MIT License.

