A bookmark widget can now carry an optional links array instead of a single href, rendering as a compact card with one row per link - for grouping several quick links (e.g. "all services", "all Coolify apps") into one widget instead of one card per link. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpxhtQY3CExQdMs4j7MmJe
PulseNode
A self-hosted infrastructure dashboard: system stats, Docker containers,
databases, HTTP checks, and combined docker+API service widgets, configured
entirely through a YAML file and pushed to the browser live over WebSockets.
No database of its own - config.yml is the source of truth, metrics are
ephemeral.
Features
- YAML-configured widgets and layout, hot-reloaded - edit
config.ymland connected browsers update without a page refresh - Secrets via
.env, interpolated intoconfig.ymlat load time (${VAR}/${VAR:-default}). Widget fields namedapiToken/apiKey/passwordare stripped before the config ever reaches the browser (lib/config/public.ts) - collectors run server-side and use the real values, the client never sees them - Live updates over WebSocket - each widget subscribes to its own topic; the server only serializes results for widgets someone is actually viewing
- Widget types: Docker containers, Postgres/Redis instances, system resources, HTTP health checks, combined docker+API service widgets (Gitea, Coolify, Immich, n8n, Umami, Headscale, Traefik), bookmarks, search
- Optional label-based auto-discovery - containers carrying
traefik.enable=truecan be turned into dashboard widgets automatically; manualconfig.ymlentries always take precedence - CSS-variable theming, light/dark toggle,
theme.customCssPathfor a fully custom stylesheet - Ships as a single Docker image, designed to run non-root with a read-only root filesystem
Quick start (local development)
pnpm install
cp config/.env.example config/.env # fill in real values
cp .env.example .env # only needed for docker compose
pnpm dev
Open http://localhost:3000. pnpm dev runs the custom server
(server.ts) via tsx watch, which boots Next.js, the WebSocket server, and
the collector scheduler together, and restarts on server-side code changes.
Editing config.yml itself does not restart the process - it's picked
up live via the config watcher.
Other scripts: pnpm build (production build), pnpm start (run the
production build via the same custom server), pnpm lint, pnpm exec tsc --noEmit.
Configuration
config/config.yml
Everything is organized into groups, each a list of widgets:
settings:
title: PulseNode
theme:
mode: dark # dark | light | auto
# variables: # override any --pn-* token from app/globals.css
# --pn-accent: "#ff6b6b"
# customCssPath: custom.css # relative to config/, served at /api/theme/custom-css
discovery:
docker:
enabled: false # set true to auto-discover traefik-labeled containers
network: falcon_network # only include containers on this docker network
groups:
- name: Infra
widgets:
- type: system
interval: 5s
- name: Services
widgets:
- type: service
service: traefik
name: Traefik
containerName: traefik
icon: traefik
href: https://traefik.example.com
interval: 15s
Config errors are non-destructive: if config.yml fails validation
after a live edit, PulseNode logs the error and keeps serving the last
valid config instead of crashing.
config/.env
Secrets and per-deployment values referenced from config.yml via
${VAR} or ${VAR:-default}. Loaded once at startup and on every config
reload; never exposed to the client. See config/.env.example.
Widget types
type |
Required fields | Notes |
|---|---|---|
bookmark |
name, href |
Static link card. Optional description, icon (see below). |
search |
- | Client-side search box. Optional engines (duckduckgo/google/bing, default [duckduckgo]), defaultEngine. |
docker |
name, containerName |
Status, uptime, CPU/mem, restart count via dockerode. Optional href, icon (see below), showStats (default true), interval (default 5s). |
database |
name, containerName, engine |
Thin skin over the same Docker collector - engine is postgres or redis, used for icon/label only. interval default 10s. |
system |
- | Host CPU/mem/disk/network via systeminformation. Optional name (default System), interval (default 5s). |
http |
name, url |
HTTP health check with latency and consecutive-failure tracking. Optional method (default GET), timeout (default 5s), interval (default 30s), expect.status (default 200). |
service |
name, containerName, service |
Docker health/CPU/mem (same collector as docker) merged with a stat pulled from the service's own API. service picks the variant (see below) and its extra fields. A failed API call surfaces as a non-fatal statError on the card without hiding docker health. Optional href, icon, showStats (default true), interval (default 15s). |
interval/timeout values are duration strings: 500ms, 5s, 1m, 1h.
service variants
Each service widget calls its target container directly by name on the
docker network (http://<containerName>:<port>), not through Traefik/TLS -
no apiUrl field needed.
service |
Extra fields | Stat shown |
|---|---|---|
gitea |
username (default Valknar), apiToken |
Repository count |
coolify |
apiToken |
Project count, resource count |
immich |
apiKey |
Photo count, video count |
n8n |
apiKey |
Workflow count |
umami |
username, password, websiteId |
Active visitors (login-and-cache, self-hosted has no static API key) |
headscale |
apiToken |
User count, node count |
traefik |
- | HTTP router count (needs --api.insecure=true or a dashboard entrypoint reachable on the docker network) |
icon on docker/bookmark widgets is a key into lib/brand-icons.ts (currently
traefik, coolify, gitea, docker, immich, n8n, passbolt, umami,
postgresql, redis, headscale), rendered monochrome so it doesn't compete
with the accent color. database widgets pick their icon automatically from
engine. Regenerate/extend the icon set with python3 scripts/extract-brand-icons.py
after adding an entry to that script's ICONS map (path data comes from the
CC0-licensed simple-icons package, bundled at build time - no runtime CDN calls).
Auto-discovery
With discovery.docker.enabled: true, PulseNode scans running containers
every 60s (and immediately on any config.yml change) for
traefik.enable=true labels, parses the router's Host(...) rule for a
link, and adds anything not already listed manually to a synthetic
"Discovered" group. discovery.docker.excludeLabels and .network narrow
what qualifies; .labelPrefix (default traefik) controls which label
namespace is read.
Deployment
cp .env.example .env
cp config/.env.example config/.env
# edit both, then:
docker compose up -d --build
.env(repo root) holdsTRAEFIK_HOST,NETWORK_NAME, andDOCKER_GID- compose-level values, interpolated intodocker-compose.yml's Traefik labels and used to join the container to the docker group. Find your host's docker group id withgetent group docker | cut -d: -f3; without it the non-root container getsEACCESon/var/run/docker.sock.config/.envholds values referenced fromconfig.yml(see above).- The compose file joins the container to an external
falcon_networkso collectors can reach services directly by container name (http://umami:3000/...) instead of only through Traefik. /var/run/docker.sockis mounted read-only;/app/configis mounted read-only from./config.- The container runs as a non-root user with a read-only root filesystem,
dropped capabilities, and
tinias PID 1. /api/healthbacks the containerHEALTHCHECK.
Project structure
config/ config.yml + .env (gitignored) live here
server.ts custom server: http + WebSocket (/ws) + collector scheduler
app/ Next.js App Router pages and API routes
components/widgets/ one folder per widget type (Widget.tsx + shared Skeleton/StatusDot)
components/layout/ Dashboard shell, theme toggle, brand mark
lib/config/ schema (zod), loader (parse/interpolate/watch), effective (+ discovery merge),
public.ts (strips secrets before the config reaches the browser)
lib/collectors/ docker, system, http collectors, service.ts (docker+API merge) with
one collector per service under services/, + the scheduler
lib/discovery/ traefik-label auto-discovery
lib/ws/ WebSocket server (topics) and the browser-side subscription hooks
Known limitations
- No authentication yet. PulseNode assumes it's reachable only on a private network (e.g. behind Traefik with no public route, or on a VPN). A minimal session-based auth gate is a planned follow-up, not yet built.
systemwidget metrics reflect whatever cgroup/namespace the container runs in - for true host-level stats rather than the container's own view, mount/procand/sysfrom the host and run withpid: host(not configured by default).