Self-hosted network device controller. Monitor your equipment with live ping status, wake machines with Wake-on-LAN, reboot or shut them down remotely, stream metrics and energy usage in real time, open an SSH terminal from the browser β all behind a single master password.
- Features
- Screenshots
- Architecture
- Getting started (local development)
- Docker
- Environment variables
- Agent & onboarding
- Network scanner
- Web SSH terminal
- Device groups
- Metrics, energy & cost
- Power actions
- API overview
- Hibernation & the swap preflight
- Contributing
- Scripts
- Dashboard β real-time online/offline status for every device, grid or list view with selectable card size, per-device ping test, in-flight action log with failure output
- Network scanner β scan an IP range (auto-detected default) for live hosts: ping + ARP enrichment (hostname, MAC, vendor), open-port discovery, and one-click "add as device"
- Wake-on-LAN β sends magic packets to the local broadcast addresses (and an optional per-device broadcast address); available for offline devices of any type
- Remote reboot / shutdown / hibernate / agent update β hybrid delivery: queued to the agent when the machine is online, SSH fallback via the assigned profile otherwise; confirmation step in the UI; failures (e.g. insufficient swap for hibernate) are surfaced on the dashboard
- Web SSH terminal β an xterm.js terminal in the browser connects over WebSocket (
/api/terminal/ws) using the device's assigned SSH profile or any profile chosen ad hoc, with live resize support - Push agent & metrics β a lightweight shell agent installs on target machines (
curl β¦ | sh), collects CPU, load, memory, swap, disks, network throughput, GPU utilization and power draw every 30s (plus the OS name) and pushes them to the server; live gauges plus per-device history (7 days raw, hourly/daily aggregates kept forever). The agent reports its version with each push β devices running an older version are marked outdated in the UI, and agents can be updated or reinstalled remotely from the dashboard - Energy & cost β kWh consumption per device and for the whole fleet, with a configurable base-power offset (
base_power_w) for idle draw; setcost_per_kwhand a currency to see costs. Gaps wider than 150s (agent offline) contribute zero energy - Device groups β organize devices into named groups with an icon and a color; reorder devices within or across groups by drag & drop
- Zero-config onboarding β generate a one-time claim link in the UI, run
curl β¦ | shon the target machine, and the first agent push registers the device automatically (name from hostname, IP, OS) - SSH profiles β password or private-key auth; credentials are encrypted at rest (AES-256-GCM) and never returned by the API
- External API β a dedicated public surface for outside systems with scoped
ncapi-*tokens (per-device scoping, per-operation permissions, usage logs) - Data import/export β JSON export of devices, profiles and groups; idempotent upsert import that re-encrypts plaintext credentials
- Security β master-password login (bcrypt), 24h JWT sessions, login rate limiting, change-password flow
Dashboard β fleet status at a glance: online/offline counts, total fleet power and energy (kWh with estimated cost), device cards grouped by group with live CPU/RAM/GPU utilization and power draw per device.
Device detail β live metrics β real-time gauges for CPU, memory, GPU and power consumption, plus swap, load, uptime, network throughput, disk usage and full system information (OS, kernel, CPU model, RAM), with the agent status badge up top.
Device detail β history β energy consumption broken down per GPU and base system for the selected range, alongside utilization, power and network charts across selectable ranges from 1h to 1y.
Web SSH terminal β a full xterm.js session to a host profile straight from the browser, streamed over WebSocket with live resize support.
browser target machines
βββββββββββββββββββββββββ ββββββββββββββββββββββββββββ
β React SPA β REST β β
β β ββββββββββββββββββΊ β pc Β· server β
β dashboard Β· scanner β /api/* β Β· nas Β· router β
β groups Β· terminal β β Β· tv Β· iot β
β (xterm.js) β WebSocket βββββββββββββ²βββββββββββββββ
βββββββββββββ¬ββββββββββββ /api/terminal/ws β
βΌ β
βββββββββββββββββββββββββββββββββββββ WOL magic packets β
β Express server β (UDP broadcast) β
β ββββββββββββββββββββββββββ€
β SQLite (single file) β SSH: power commands, β
β β terminal, reinstall β
βββββββββββββββββββββ²ββββββββββββββββ ββββββββββββββββββββββββ
β
β every 30s: metrics + action results
β install: curl β¦/api/agent/install | sh
βββββββββββ΄βββββββββββ
β agent script β
ββββββββββββββββββββββ
Requires Node.js >= 20.
npm install
npm run dev # starts API + Vite dev server on :3000Environment variables (PORT, DB_PATH, JWT_SECRET, APP_ENCRYPTION_KEY) must be exported in your shell for npm run dev β .env is only read by docker compose.
The first request to GET /api/setup/status reports isSetup: false; log in with the setup form to create your master password.
cp .env.example .env # set JWT_SECRET (and optionally APP_ENCRYPTION_KEY, OPENHOMELAB_VERSION)
docker compose pull && docker compose up -ddocker-compose.yml pulls the pre-built ghcr.io/mossaab/openhomelab:${OPENHOMELAB_VERSION:-latest} image. Both values are overridable in .env (OPENHOMELAB_IMAGE, OPENHOMELAB_VERSION). To upgrade, pin OPENHOMELAB_VERSION=vX.Y.Z in .env, then run docker compose pull && docker compose up -d.
cp .env.example .env # optional
docker compose -f docker-compose.dev.yml up -d --buildThe container uses host networking so Wake-on-LAN broadcast packets reach the LAN, and runs with NET_RAW (ICMP ping) and NET_BROADCAST (WOL UDP broadcast send) capabilities as a non-root user (node, uid 1000). The database is persisted in the sqlite_data volume β both compose files use the same named volume (openhomelab_sqlite_data), so switching dev β prod needs no data migration.
Existing installations: the database lives in the
openhomelab_sqlite_datavolume. If you upgraded from an older version, copy your database from the old volume before the firstdocker compose up, or the app starts with an empty database:docker run --rm -v <old-volume>:/old -v openhomelab_sqlite_data:/new alpine sh -c 'cp /old/database.sqlite /new/database.sqlite'Older image versions ran as root, so an existing volume's files are owned by root and the non-root container cannot write them. Fix ownership (no data loss) before starting:
docker run --rm -v openhomelab_sqlite_data:/data alpine chown -R 1000:1000 /dataMachines that still run a legacy agent should be cleaned with
scripts/remove-legacy-agents.sh(run as root on the machine).
The footer displays the running version, taken from the version field of package.json at build time. Release images have it baked in from the tag by the CI workflow (see Β«ReleasingΒ»); for local builds you can override it per build with the APP_VERSION env var (e.g. APP_VERSION=$(git describe --tags) docker compose -f docker-compose.dev.yml up --build -d) to match the release tag exactly.
Releases are cut from git tags. The version lives in package.json; bump it with npm, push, and the release workflow does the rest:
npm version patch # or minor / major β updates package.json, commits, creates tag vX.Y.Z
git push && git push --tagsOn the v* tag the workflow verifies the tag matches the package.json version, runs lint + tests, publishes a GitHub Release with source archives (.tar.gz/.zip) and auto-generated notes, and builds + pushes the Docker image to ghcr.io/mossaab/openhomelab under tags vX.Y.Z and latest (the image digest is noted in the release notes).
Prerequisite: repository settings β Actions β Workflow permissions must grant Read and write for packages (so GITHUB_TOKEN can push to GHCR) and for contents (to create the release).
| Variable | Required | Description |
|---|---|---|
PORT |
no (default 3000) |
HTTP listen port |
DB_PATH |
no (default ./database.sqlite) |
SQLite database file location |
JWT_SECRET |
yes in production | Secret used to sign session tokens. The server refuses to start in production if it is missing or the default value |
APP_ENCRYPTION_KEY |
no | 64-char hex key (AES-256) for encrypting stored SSH credentials. If omitted, a random key is generated once and stored in the database. Never change it after first use β stored credentials would become undecryptable |
The agent is a single shell script served by the server:
curl -fsS https://<server>/api/agent/install | sh -s -- https://<server> <agent_token>Two ways to distribute it:
- Per-device token β open a device, generate its agent token (
POST /api/devices/:id/agent/token, returned once and stored as a hash), then run the install command on the machine. The UI shows a ready-to-paste command. Agents can later be updated in bulk (POST /api/devices/agents/update) or reinstalled over SSH by the server itself (POST /api/devices/:id/agent/reinstall). - Claim link (zero-config) β for machines you don't manage yet, create a claim:
POST /api/devices/claimreturns a one-time token (15-minute TTL) plus the install path β the UI turns that into a ready-to-pastecurl β¦ | shcommand. Run it on the machine; when the agent's first metrics push arrives, the server registers a new device from the snapshot (hostname, IP, OS) and consumes the claim.
UI "onboard" βββΊ POST /api/devices/claim βββΊ one-time token (15-min TTL)
β
target machine: curl -fsS <server>/api/agent/install | sh -s -- URL TOKEN
β
first metrics push
βΌ
device auto-registered (hostname, IP, OS) β claim consumed
POST /api/scan starts a background job over an IP range (startIp, endIp, optional port list of 1β100 ports). While it runs you can poll progress with GET /api/scan/:id or stop it with POST /api/scan/:id/cancel. Live hosts are enriched with hostname, MAC and vendor (ARP table) plus open ports; hosts that are already managed in the app are flagged as such.
POST /api/scan {startIp, endIp, ports?} βββΊ scanId (background job)
β
poll GET /api/scan/:id βΌ
running βββββββββββββββββββββΊ done | cancelled | failed
hosts: ip Β· online Β· hostname
mac Β· vendor Β· ports[]
β
βΌ
add discovered host as device
GET /api/scan/default-range returns the detected local LAN range to pre-fill the form.
The browser terminal is a WebSocket at /api/terminal/ws?target=device&deviceId=<id>&token=<jwt> (or target=host&profileId=<id> for an ad-hoc connection through any profile). The server opens the SSH session with ssh2 using the device's assigned profile credentials β decrypted only in memory, never exposed to the client β and streams it into xterm.js. Terminal resize is forwarded ({ type: "resize", cols, rows }). Devices can disable the terminal per-device (disable_terminal).
Devices can be sorted into named groups (icon + color) shown as sections on the dashboard. POST /api/groups creates one, POST /api/groups/reorder reorders all of them (full id list), and PUT /api/devices/:id/move moves a device to a position inside a group (group_id = null for ungrouped). Deleting a group detaches its devices.
The agent pushes a snapshot every 30s (interval configurable in settings). Each push upserts the live row and appends a raw point; a 5-minute job rolls raw data into hourly/daily aggregates (raw is pruned after 7 days) and settles stale actions.
agent (every 30 s) server 5-min job
ββββββββββββββββββββββββ βββββββββββββββββββββ βββββββββββββββββββββ
β cpu Β· load Β· memory β POST β metrics_live β roll-up β metrics_hourly β
β swap Β· disks Β· net ββββββββββΊβ (last snapshot) ββββββββββββββΊβ metrics_daily β
β gpu Β· power (W) Β· os β /api/ ββββββββββ¬βββββββββββ β (kept forever) β
ββββββββββββββββββββββββ agent/stats β βββββββββββ¬ββββββββββ
βΌ β
βββββββββββββββββββββ βΌ
β metrics_raw β computeEnergyKwh()
β (every push, β β kWh Β· cost
β pruned at 7 d) β
βββββββββββββββββββββ
Per-device energy (GET /api/devices/:id/stats/energy?range=β¦) splits consumption into CPU/GPU components plus the base-power offset: the device's base_power_w (idle draw you set once) is applied for every second the agent is online. Fleet totals use GET /api/devices/stats/energy?range=, and the fleet power curve in watts uses GET /api/devices/stats/power?range=. Ranges: 1h | 6h | 24h | 7d | 30d | 1y for the energy endpoints, 1h | 24h | 30d for the power curve. With cost_per_kwh set, every response includes the monetary cost in the configured currency.
POST /api/devices/:id/command accepts reboot, shutdown, hibernate and update. Delivery is hybrid: if the device's agent pushed metrics within the staleness window (90s at the default 30s interval), the action is queued for the agent; otherwise it runs the profile's command over SSH. The external machine API (/machines/:id/restart, /stop) uses the same path.
UI /api/devices/:id/command API /api/machines/:id/restart
β
βΌ
βββββββββββββββββββββββ
β agent online (<90s)?β
ββββββ¬ββββββββββββ¬βββββ
yes no
β β
βΌ βΌ
device_actions queue SSH via the assigned profile
pending (reboot_command / shutdown_command)
β
βΌ
acknowledged
β
βΌ
started βββΊ done agent reports via POST /api/agent/action-result
ββββββββΊ failed stale actions are settled after 10 min (agent offline)
Action state is visible live on the dashboard (GET /api/devices/actions returns in-flight actions from the last 10 minutes, failures include the agent output). The update action makes the agent re-download itself from GET /api/agent/script and restart.
All routes are under /api. Three auth models coexist: JWT bearer tokens (24h sessions from setup/login) protect the UI and management routes; per-device tk-β¦ agent tokens authenticate the /agent/* endpoints; scoped ncapi-* tokens protect the public machine surface used by external automation.
Full route reference: docs/API.md. External machine API (/machines/*): docs/MACHINE_API.md.
The agent's hibernate action checks that the target machine has enough swap to hold currently used RAM (MemTotal β MemAvailable, not total RAM) before invoking systemctl hibernate. If swap covers the used memory, it hibernates directly. If not (common on servers with large RAM and small/missing swap), the agent attempts an idempotent auto-repair first:
- sizes a swap file to the used RAM, rounded up to a power-of-two GB,
- formats and activates it (
mkswap/swapon), - persists it in
/etc/fstab, - adds
resume=<swapfile>to GRUB and regenerates the bootloader,
then proceeds with the hibernate β the dashboard shows a βrepaired swap β¦β notice on success. The target paths are overridable in the agent environment (SWAP_FILE, FSTAB, GRUB_DEFAULT).
If the auto-repair cannot complete (e.g. no free space), the action fails fast with the exact figures β how much RAM is in use vs how much swap exists β plus a hint pointing at the helper script, and the reason is surfaced on the dashboard: no half-suspended NVIDIA drivers, no bootable-but-broken image.
For machines without an agent (or to set things up manually), run the helper from this repo as root on that machine:
sh scripts/fix-hibernate.shIt is idempotent and distro-agnostic: sizes a /swap.img (power-of-two GB β₯ RAM), persists it in /etc/fstab, sets resume=/swap.img in GRUB and regenerates the bootloader, then prints a verification summary (swap total, kernel cmdline, NVIDIA suspend state). Reboot the machine when convenient to pick it up.
Contributions are welcome β see CONTRIBUTING.md for the development setup, project conventions, and pull-request requirements (lint + tests must pass). Please also read the Code of Conduct before participating.
| Command | Description |
|---|---|
npm run dev |
Run API + Vite in development mode |
npm run build |
Build the frontend and bundle the server into dist/ |
npm start |
Run the production build |
npm run lint |
ESLint + TypeScript type check |
npm test |
Run the API test suite (Vitest + Supertest) |
npm run render-icons |
Regenerate the committed PNG icons from logo.svg β requires npx playwright install chromium once; only needed after a logo change |



