From 1c5ee1002a806d1cc5768c1075ea7f2813fa615a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 17 Aug 2026 15:42:08 +0200 Subject: [PATCH] feat: add pulsenode stack Infrastructure dashboard monitoring all the stacks in this repo, built via stacks new pulsenode and then hand-adjusted for what a generic scaffold can't know: pulls the published image (dev.pivoine.art/valknar/pulsenode:latest) instead of building, mounts /var/run/docker.sock:ro plus its own config/ directory read-only, runs read-only/non-root/cap-dropped (group_add on DOCKER_GID to get socket access without running as root), and uses its own healthcheck.mjs rather than curl (not present in its minimal image). config/config.yml is real, not a placeholder: docker/database widgets for every container across all ten stacks here, discovery enabled against falcon_network as a safety net for anything added later. --- README.md | 1 + pulsenode/.env.example | 7 ++ pulsenode/compose.yml | 47 +++++++++++ pulsenode/config/.env.example | 11 +++ pulsenode/config/config.yml | 150 ++++++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 pulsenode/.env.example create mode 100644 pulsenode/compose.yml create mode 100644 pulsenode/config/.env.example create mode 100644 pulsenode/config/config.yml diff --git a/README.md b/README.md index 941d077..0e3983d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Each stack is independently deployable with its own `compose.yml` and `.env`. Al | `passbolt` | Password manager (GPG-encrypted, team sharing) | passbolt, db | | `code` | Browser-based VS Code IDE with Anthropic API access | code | | `headscale` | Self-hosted Tailscale control server (WireGuard mesh VPN) | headscale | +| `pulsenode` | Infrastructure dashboard - monitors all the stacks above | pulsenode | ## Config files diff --git a/pulsenode/.env.example b/pulsenode/.env.example new file mode 100644 index 0000000..b5456d7 --- /dev/null +++ b/pulsenode/.env.example @@ -0,0 +1,7 @@ +# Network +NETWORK_NAME=falcon_network +TRAEFIK_HOST=pulsenode.example.com + +# Host docker group id, so the non-root container can read +# /var/run/docker.sock. Find it with: getent group docker | cut -d: -f3 +DOCKER_GID=999 diff --git a/pulsenode/compose.yml b/pulsenode/compose.yml new file mode 100644 index 0000000..c5f0eee --- /dev/null +++ b/pulsenode/compose.yml @@ -0,0 +1,47 @@ +services: + pulsenode: + image: dev.pivoine.art/valknar/pulsenode:latest + container_name: pulsenode + read_only: true + tmpfs: + - /tmp + - /app/.next/cache + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + # Non-root container, but /var/run/docker.sock is owned by root:docker on + # the host - without joining that GID it gets EACCES. DOCKER_GID is this + # host's docker group id (getent group docker | cut -d: -f3). + group_add: + - "${DOCKER_GID}" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config:/app/config:ro + env_file: .env + restart: always + healthcheck: + test: ["CMD", "node", "healthcheck.mjs"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + labels: + - "traefik.enable=true" + - "traefik.http.routers.pulsenode-web.rule=Host(`${TRAEFIK_HOST}`)" + - "traefik.http.routers.pulsenode-web.entrypoints=web" + - "traefik.http.routers.pulsenode-web.middlewares=pulsenode-redirect-web-secure" + - "traefik.http.middlewares.pulsenode-redirect-web-secure.redirectscheme.scheme=https" + - "traefik.http.routers.pulsenode-web-secure.rule=Host(`${TRAEFIK_HOST}`)" + - "traefik.http.routers.pulsenode-web-secure.entrypoints=web-secure" + - "traefik.http.routers.pulsenode-web-secure.tls.certresolver=resolver" + - "traefik.http.routers.pulsenode-web-secure.middlewares=security-headers@file,no-index@file" + - "traefik.http.services.pulsenode-web-secure.loadbalancer.server.port=3000" + - "traefik.docker.network=${NETWORK_NAME}" + networks: + - compose_network + +networks: + compose_network: + name: ${NETWORK_NAME} + external: true diff --git a/pulsenode/config/.env.example b/pulsenode/config/.env.example new file mode 100644 index 0000000..8ac9777 --- /dev/null +++ b/pulsenode/config/.env.example @@ -0,0 +1,11 @@ +# Public hostnames of the services PulseNode links to / monitors. +# These are not secrets, but keeping them here (rather than hardcoded in +# config.yml) means the same config.yml works across environments. +TRAEFIK_HOST_CODE=code.example.com +TRAEFIK_HOST_COOLIFY=coolify.example.com +TRAEFIK_HOST_GITEA=git.example.com +TRAEFIK_HOST_HEADSCALE=headscale.example.com +TRAEFIK_HOST_IMMICH=photos.example.com +TRAEFIK_HOST_N8N=n8n.example.com +TRAEFIK_HOST_PASSBOLT=passbolt.example.com +TRAEFIK_HOST_UMAMI=analytics.example.com diff --git a/pulsenode/config/config.yml b/pulsenode/config/config.yml new file mode 100644 index 0000000..f13db85 --- /dev/null +++ b/pulsenode/config/config.yml @@ -0,0 +1,150 @@ +settings: + title: PulseNode + +theme: + mode: dark + variables: + --pn-accent: "#38bdf8" + +# Manual widgets below always take precedence over an auto-discovered one for +# the same container - this is on as a safety net for anything added to +# falcon_network later without a config.yml update, not to duplicate what's +# already listed here. Requires DOCKER_GID set correctly (see .env.example). +discovery: + docker: + enabled: true + network: falcon_network + +groups: + - name: Core Infra + widgets: + - type: docker + name: Traefik + containerName: traefik + interval: 10s + # No href: the dashboard is disabled (--api.dashboard=false). + # Swap for a `traefik` widget (router/entrypoint/middleware view) + # if the API gets enabled - see Traefik's --api.dashboard docs. + + - type: docker + name: Coolify + containerName: coolify + href: https://${TRAEFIK_HOST_COOLIFY} + interval: 10s + + - name: Dev Tools + widgets: + - type: docker + name: Gitea + containerName: gitea + href: https://${TRAEFIK_HOST_GITEA} + interval: 10s + + - type: docker + name: Gitea Runner + containerName: gitea_runner + showStats: true + interval: 15s + + - type: docker + name: Code Server + containerName: code + href: https://${TRAEFIK_HOST_CODE} + interval: 10s + + - name: Apps + widgets: + - type: docker + name: Immich + containerName: immich + href: https://${TRAEFIK_HOST_IMMICH} + interval: 10s + + - type: docker + name: Immich ML + containerName: immich_ml + interval: 15s + + - type: docker + name: n8n + containerName: n8n + href: https://${TRAEFIK_HOST_N8N} + interval: 10s + + - type: docker + name: Passbolt + containerName: passbolt + href: https://${TRAEFIK_HOST_PASSBOLT} + interval: 10s + + - type: docker + name: Umami + containerName: umami + href: https://${TRAEFIK_HOST_UMAMI} + interval: 10s + + - type: docker + name: Headscale + containerName: headscale + href: https://${TRAEFIK_HOST_HEADSCALE} + interval: 10s + + - type: docker + name: Mailpit + containerName: mailpit + interval: 15s + + - name: Data Services + widgets: + - type: database + name: Coolify DB + containerName: coolify_db + engine: postgres + interval: 20s + + - type: database + name: Coolify Redis + containerName: coolify_redis + engine: redis + interval: 20s + + - type: database + name: Gitea DB + containerName: gitea_db + engine: postgres + interval: 20s + + - type: database + name: Immich DB + containerName: immich_db + engine: postgres + interval: 20s + + - type: database + name: Immich Redis + containerName: immich_redis + engine: redis + interval: 20s + + - type: database + name: n8n DB + containerName: n8n_db + engine: postgres + interval: 20s + + - type: database + name: Passbolt DB + containerName: passbolt_db + engine: postgres + interval: 20s + + - type: database + name: Umami DB + containerName: umami_db + engine: postgres + interval: 20s + + - name: Search + widgets: + - type: search + engines: [duckduckgo, google]