The control panel for everything you self-host at home.
The harness for your servers.
Point it at the things you run — a gateway, a media server, a bot, a database — and it starts them, watches them, restarts what dies, and shows you one page of what is going on. Split them into workspaces when one panel holds more than one setup. And BYOU, for a specialized UI that fits you exactly.
🚀 Quick start · 🗂 Workspaces · 🧩 Servers · 🤖 Agents & API · ✨ Features · 🛠 CLI · 🔔 Notifications · 🔀 Reverse proxy · 🎨 BYOU
The stock panel, the NOC-console that ships alongside it (for TUI and shortcuts wizards), and UI directions you could build yourself — BYOU.
You run a handful of services at home. The usual choices are extremes — 🧟 tmux sessions you
forget about, 📜 a hand-written systemd unit per service (times six), or 🐳 a whole docker/k8s
stack??? - too extreme! — plus 😩 monitoring, rebooting and changing the host machine, yuck!
🙂✨ home-hosted enhances on top: a panel/supervisor that starts them, watches them, restarts what
dies, and puts the whole stack on one page, with deep backup support — whether a server is a plain
command or a docker compose stack.
┌──────────────────────────────────────┐
your browser ──▶│ home-hosted · 127.0.0.1:3999 │
│ your UI + JSON API + SSE logs │
└───────────────┬──────────────────────┘
│ supervises
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ gateway │ │ files │ │ bot │
│ :4000 │ │ :4010 │ │ ... │
└────┬────┘ └─────────┘ └─────────┘
│ compose up -d
▼
┌────────────┬────────────┬────────────┐
│ gateway │ postgres │ redis │ restarts ↻
└────────────┴────────────┴────────────┘
health ✓ (the published port is the probe)
| ❌ "Is it still running?" | Health, CPU/mem, uptime and live logs per server — no ps, no curl, no hope |
| ❌ Silent deaths | Restarted automatically, and the panel or Telegram notifies |
| ❌ Fragile reboots | autostart brings the stack back; one down stops it all cleanly |
| ❌ "Move it to the new box" | One archive: config and data, restored on a blank host |
| ✅ home-hosted | Declare it once, watch it forever, one command to stop it all |
It ships with nothing: no blessed paths, no opinion about what you run — a server is a command, some arguments, and the environment you give it:
{ "id": "gateway", "command": "node", "args": ["server.js"], "port": 4000, "autostart": true }Manage them from the panel, home-hosted start|stop <id> from a shell, home-hosted status --json,
or GET /api/state; GET /healthz is the same status line for your own monitor, no session needed.
npx home-hosted # start it — detached, it stays running
npx home-hosted status # where is it, is it healthy
# pnpm instead of npx: `pnpm dlx home-hosted …`That is it. The panel is on http://127.0.0.1:3999 and keeps running after the terminal closes. Needs Node 24 or newer. Stop it whenever you like:
npx home-hosted down # stops the panel *and* everything it startedNote
The first boot writes a default password (hh) so the panel is never unprotected. Change
it under Global settings → Authentication — binding beyond 127.0.0.1 stays refused until you do.
📦 Install it instead of npx-ing it
npm install -g home-hosted # or: pnpm add -g home-hosted
home-hosted up # `hh up` does the sameEverything it owns — workspaces, settings, secrets, logs, TLS, backups — lives under
$HHOSTED_HOME/.hh, default ~/.home-hosted/.hh. Delete that and nothing of yours is left behind.
An installed home-hosted answers to hh too — hh up, hh status, hh down. Only the
installed form gets it; npx stays npx home-hosted ….
📁 Keep a whole setup in a project you can take anywhere
Commit the project and it is the setup. init scaffolds it:
npx home-hosted init # or: pnpm dlx home-hosted init
# project directory, package name, package manager, install, git — every step has a default
# `--yes` takes them all, for an agent or a CI jobIt writes a manifest whose scripts all pass --home ./state:
node_modules/
state/.hh/* # secrets, logs, TLS keys and archives stay local…
!state/.hh/settings.json # …but the workspace definitions are committed
!state/.hh/workspaces.json
state/.hh/*/*
!state/.hh/*/settings.json
!state/.hh/*/servers.config.json
data/ # per-server data directories, declared through dataEnvsOne clone, pnpm install --frozen-lockfile, pnpm run up — the setup is up on any machine with Node.
Worked example, with per-server data inside the project:
hhosted-ai-pack.
Call them as pnpm run up — pnpm up is pnpm's own update, not your script.
[!TIP] Give a project its own panel port (
control.port, e.g.4399) — the default3999is what the global instance and every other project also want.
🧰 Run it under systemd or Docker (no daemon needed)
home-hosted up --foreground # stays in the foreground, logs to stderr[Unit]
Description=home-hosted
[Service]
ExecStart=/usr/local/bin/home-hosted up --foreground
Environment=HHOSTED_HOME=/srv/home-hosted
Restart=always
[Install]
WantedBy=multi-user.targetEverything the panel does, a script or an agent can do: the API takes a long-lived token in place of the browser cookie. One command for a credential, then plain HTTP to start, stop, inspect, restart and read logs.
home-hosted set-token --generate
# hh_9uA2… (printed once; only its hash is kept, mode 0600)
curl -H "Authorization: Bearer hh_9uA2…" http://127.0.0.1:3999/api/state
curl -H "Authorization: Bearer hh_9uA2…" -X POST 'http://127.0.0.1:3999/api/servers/omniroute/restart?workspace=default'
curl -N -H "Authorization: Bearer hh_9uA2…" 'http://127.0.0.1:3999/api/servers/omniroute/stream?workspace=default' # SSE
home-hosted status --json # machine-readable: pid, url, health, pathsA token has the same authority as a signed-in browser and outlives restarts; set-token --clear
revokes it instantly. Workspace-scoped routes take ?workspace=<id>; omitting it means the panel's
default workspace.
🌐 The endpoints worth knowing
?workspace |
||
|---|---|---|
GET /api/state |
the full snapshot: global settings, every workspace with its servers, host vitals | ❌ |
GET/POST /api/workspaces, PATCH/DELETE /api/workspaces/:id |
the registry: list, create, rename, remove | ❌ |
GET /api/events |
SSE: the live state, plus logs (?logs=0, ?serverId=…; a server payload carries its workspaceId) |
❌ |
GET/POST /api/servers, POST /api/servers/{start,stop}-all |
that workspace's entries, and its lifecycle for all of them | ✅ |
GET/PATCH/DELETE /api/servers/:id |
one entry | ✅ |
POST /api/servers/:id/{start,stop,restart}, POST /api/servers/:id/free-port |
one entry's lifecycle, and freeing its port | ✅ |
GET /api/servers/:id/logs, GET /api/servers/:id/stream |
that entry's buffered lines, and its SSE | ✅ |
GET /api/logs |
persisted history and files | ✅ |
GET/PUT /api/ddns, /api/ddns/credentials/:id, /api/ddns/check |
that workspace's Dynamic DNS policy, credentials and a check | ✅ |
PUT/DELETE /api/notifications/token, POST /api/notifications/{test,detect-chats} |
that workspace's bot credential and a test send | ✅ |
GET/PATCH /api/settings/workspace |
server defaults, logs, notifications | ✅ |
GET/PATCH /api/settings |
panel-wide: listener, authentication, TLS, host vitals, backups | ❌ |
GET/POST /api/backups, restore |
archives | ❌ |
GET /healthz |
no session needed — the one an external monitor wants (its per-server detail needs a credential) | ❌ |
GET /api/metrics |
Prometheus text (needs a token or session, like every /api route) |
❌ |
✅ takes ?workspace=<id>; ❌ is panel-wide. Omitting it means the panel's default workspace — never
another one.
GET /openapi/spec.json describes all of it, /openapi/ui is the browsable version, and every error
comes back as one envelope ({ message, code, detail }) with a stable code a tool can branch on.
🧑💻 Pointing an agent at it
Give the agent four things and it can run your home server without guessing:
- the token (
home-hosted set-token --generate), http://127.0.0.1:3999/openapi/spec.json— the API it may call,home-hosted status --json— where things are,- SERVERS.md — how an entry is declared when it needs a new server.
For a UI rather than the API, UI_CREATION.md is the whole contract, and the panel can be told what to be: "Help me build a UI for home-hosted: nostalgic game theme, including …".
| 🗂 Workspaces | One panel, many scopes: pick a workspace in the header and it owns its servers, secrets, logs and settings. Create, rename and delete them there. Panel-wide things — listener, auth, host vitals, backups, TLS, UI — stay in Global settings. |
| 🚦 Lifecycle | Start, stop, restart from the panel or the API; autostart entries come up with it. |
| 📝 Hand edits welcome | Change a workspace's servers.config.json in an editor, a git checkout or a config tool: the panel notices within seconds, no restart. A file it cannot read is reported in the panel, and the running servers are left alone. |
| ♻️ Auto-restart | Exponential backoff on crash, with the counter reset once a process stays up. |
| 🩺 Health that acts | TCP or HTTP probes per server: warn on the card, force a restart after a timeout, check ports before starting — and follow or replace a program that restarts itself. |
| 🔗 Ordered startup | dependsOn waits for a dependency to be healthy — not merely spawned — and stops in reverse. |
| 📜 Logs | Live per-server stream, buffer plus rotated files on disk, search, download, one click to clear. |
| 📈 Resources | CPU and RSS of the whole process tree, with an optional memory ceiling that triggers a restart. |
| 🌡️ Host vitals | Load, memory, swap, disk and CPU temperature, with thresholds that notify once and again on recovery. |
| 🤖 Token API | Scripts and agents drive it with Authorization: Bearer — no browser, no session. ↑ |
| 🔔 Notifications | Telegram on crash, unhealthy, forced restart, recovery, host thresholds and DNS changes — setup here. |
| 🌐 Dynamic DNS | Keep hostnames pointed at your public IP — Cloudflare, Namecheap, Spaceship, Porkbun, GoDaddy, Gandi and more. DDNS.md |
| 🔀 Reverse proxy | One engine in front of everything, with automatic HTTPS: git.example.com → a Gitea entry, media.example.com → a service on another box, panel.example.com → the panel. Caddy, installed and supervised by the panel. REVERSE_PROXY.md |
| 💾 Backups | Two levels: pick global settings, global secrets, TLS or a whole workspace at the top, then the pieces inside it (settings, servers, secrets, data directories) — plain .zip, or AES-256 with a password, restored per path. |
| 🎨 BYOU — Bring Your Own UI | Upload a static build, ui-update to follow its releases, ui-revert to go back. UI_CREATION.md |
| 🔐 Security | Cookie sessions, API tokens, scrypt hashes, per-IP lockout, optional TLS, and a refusal to expose itself without a password. |
| 🧩 No special treatment | A server is command + args + env + cwd; nothing is built in for any particular app. |
| 🖥 Cross-platform | Linux, macOS and Windows: /proc, ps or Win32_Process, process groups or taskkill /T, no shell dependencies. |
A workspace is the ownership boundary: its own servers, secrets, logs, nanny state and settings. The
header picker is the whole control — choose one, create one, rename it, or delete it (which stops
what it supervises first). The first boot creates a default workspace; a pre-workspaces instance is
relocated into one automatically.
Important
Upgrading from before workspaces (0.6 → 0.7)? That release is breaking: state moves under
$HHOSTED_HOME/.hh, settings split global vs. per-workspace, and backups become two-level. The
relocation runs on the next command — home-hosted migrate reports it and stamps every file. See
Upgrading.
| page | url | scope |
|---|---|---|
| Overview | /w/<workspace> |
the selected workspace: its servers, counts and live status |
| Servers · Logs · Workspace settings | /w/<workspace>/servers · /w/<workspace>/logs · /w/<workspace>/settings |
the selected workspace |
| Global Overview | /global/overview |
host vitals plus every server from every workspace |
| Global settings | /global/settings |
Listener, Authentication, Host vitals, Backups, TLS, Interface, Paths |
Every page is bookmarked by its own URL: the workspace id is part of it, so a link opens the same
workspace even in a browser that never selected it. / opens Global Overview. The sidebar lists
the panel-wide pages first, then the selected workspace's.
🗂 What lives where
global — $HHOSTED_HOME/.hh/ |
settings.json (listener, auth, TLS, host vitals, backups), workspaces.json, .control-secrets.json (password + API token, 0600), .tls/, .ui/, .backups/, .logs/, run.json |
workspace — .hh/<id>/ |
settings.json (server defaults, logs, notifications, DDNS), servers.config.json, .secrets.json (Telegram + DDNS credentials, 0600), .logs/, .state/ |
An entry is a few lines. Add one with ➕ Add server, or write it into the selected workspace's
servers.config.json ($HHOSTED_HOME/.hh/<workspace>/servers.config.json):
{
"id": "myapp",
"command": "node",
"args": ["server.js"],
"port": 8080,
"autostart": true,
"dataEnvs": { "DATA_DIR": "{projectDir}/data/myapp" }
}dataEnvs declares a data directory once: it is exported to the process and picked up by Backups.
Every field, every placeholder, the port-conflict policies (including adopting a server that
restarts itself), and how hand-edits are validated: SERVERS.md.
| command | |
|---|---|
home-hosted up |
start the panel detached, and keep it alive in the background |
home-hosted down |
stop it cleanly — supervised processes included, persistent entries left running |
home-hosted restart |
down, then up |
home-hosted status |
pid, URL, health, uptime, state and log paths (--json for scripts) |
home-hosted start <id> |
start one server in the default workspace — and anything it dependsOn (--workspace <id>) |
home-hosted stop <id> |
stop one server, nothing else (--workspace <id>) |
home-hosted set-password |
set the panel password without opening a browser |
home-hosted set-token |
set the API token scripts and agents use (--generate, --clear) |
home-hosted migrate |
relocate a pre-workspaces state directory and stamp every config for this release (--dry-run, --yes) |
home-hosted init |
scaffold a project that keeps state/ and its data in the repo |
home-hosted ui-switch |
install a UI from a release asset, a zip file or a URL (interactive) |
home-hosted ui-update |
bring an installed UI up to date, or pick a release (--old, --check) |
home-hosted ui-revert |
go back to the stock panel UI after uploading your own |
⚙️ Flags
home-hosted <command> --help prints what that command takes.
up, restart -c/--config -p/--port --host --open --no-autostart --foreground --print-config
down (no flags)
status --json
start, stop <id> [-w/--workspace <id>] (both need the panel up)
init --dir --name --pm --no-install -y/--yes
set-password --clear
set-token --generate --clear
migrate --config --dry-run -y/--yes
ui-switch --repo --tag --asset --file --list --token -y/--yes
ui-update --check --tag --asset --old --repo --token -y/--yes
ui-revert (no flags)
every command --home <dir> --project <dir> (or $HHOSTED_HOME, $HHOSTED_PROJECT)
env vars HHOSTED_PASSWORD, HHOSTED_MIGRATE=allow, HHOSTED_TOKEN, GITHUB_TOKEN or GH_TOKEN
up and restart share the same flags: restart is down, then up with exactly what it was given.
-c/--config is the default workspace's servers file (<state>/.hh/default/servers.config.json),
for a launcher that pins one; a workspace picked in the UI keeps its own. start/stop omit
--workspace to act in the panel's default workspace.
🧭 Upgrading, and why the panel sometimes refuses to start
Breaking in 0.7 — workspaces. State moved under
$HHOSTED_HOME/.hh: global files at its top level, one directory per workspace. Settings split into panel-wide Global settings and per-workspace Workspace settings, backups became two-level, and--confignow points at the default workspace's servers file. The oldservers.config.jsonbecomes.hh/default/servers.config.json.
Relocation is automatic: the CLI relocates a pre-.hh $HHOSTED_HOME before any command runs, so an
existing instance keeps working after the upgrade. home-hosted migrate reports what it moved and
stamps every file for this release:
home-hosted migrate --dry-run # print the steps, write nothing
home-hosted migrate # ask, then write — keeps a .bak beside each rewritten fileUnattended, consent comes from --yes or HHOSTED_MIGRATE=allow; without it the command stops.
Each config records what wrote it: meta.writtenBy (the release) and meta.schema (the shape). That
buys two guarantees:
- A newer home-hosted always reads an older config — every existing key keeps its meaning.
- Keys a newer release added are ignored, not fatal. The panel names them in its log, leaves them in the file, and never resets the settings around them.
What it will not do is run a config it cannot read. A wrong value, a duplicate id, an unreadable file
or a config whose schema is newer than the running release stops up with the exact problem, rather
than starting with defaults that quietly differ from your file. Fix the file, or install the release
that wrote it.
Everything binds 127.0.0.1 until you say otherwise.
- A password is required to expose the panel. Replace the default, then bind to
lan— in the UI, in the config, or with--host lan. The same guard applies in all three places. - Sessions live in memory only; the cookie is
HttpOnlyandSameSite=Strict, and the login route locks out repeated failures per IP. - API tokens for scripts and agents:
home-hosted set-token --generateprints one once, and a request proves itself withAuthorization: Bearer …— the same access as a signed-in browser, stored as a SHA-256 hash, revoked withset-token --clear. - Port conflicts are named —
port 4010 is already in use (pid 4242)— and can be resolved from a confirmation popover on that banner or card. The process is looked up again at that moment, never taken from the message, and anything the panel supervises is refused, not killed. A server that restarts itself can be followed, or replaced with a supervised copy. - Secrets never enter the config: the password hash and API token hash live in the global
.hh/.control-secrets.json, each workspace's Telegram bot token and DDNS credentials in its own.hh/<workspace>/.secrets.json, all mode0600; the TLS pair sits in.hh/.tls/. DDNS credentials are sealed with AES-256-GCM underHHOSTED_DDNS_SECRET(defaulthh— set your own). - Behind a proxy turn on
trustProxyand letcookieSecure: autoaddSecureon https, or upload a PEM pair and let home-hosted terminate TLS itself.
Telegram, when something happens while you are not looking: a server that gave up restarting, a failing health check, a forced restart, a recovery, a host threshold (disk, memory, swap, load, temperature), or a dynamic DNS change. Opt-in, rate-limited per server and reason, and the bot token stays in the secrets file. Two minutes of setup: NOTIFICATIONS.md.
Workspace Settings → Dynamic DNS keeps a list of hostnames pointed at this machine's public IP — add an account, paste its credentials, add hostnames. The panel checks the address on an interval and calls a provider only when it actually changed, and the last confirmed address survives a restart. Provider tokens stay in the workspace's secrets file. Providers and the config shape: DDNS.md.
Global settings → Backups archives the panel's own settings, its secrets and the TLS pair — plus
every workspace you pick. It is two-level: the top of Create backup… lists the global items and
each workspace; opening a workspace lists its settings, servers, secrets and every data
directory its entries declare, and you can drop any single piece. Restore… shows the same list from
an archive before anything is written. Either way it is an ordinary .zip, or WinZip AES-256 with a
password, restored per path. Known build output and dependency directories (node_modules, dist,
.next, framework caches) are skipped per entry; backupIgnoreGenerated: false captures them anyway.
🚚 One archive is a whole setup
Start a blank home-hosted anywhere — another machine, another user, a fresh container — upload the
archive and restore. Definitions come back, data lands where this machine's config says, and
autostart entries come up immediately.
It works because an archive carries each workspace's settings.json and servers.config.json, and
paths are matched by the declaration (omniroute:DATA_DIR), not by an absolute path from the
source machine. A restore never writes where no config declares. A declaration using {projectDir},
{dataRoot} or {home} follows the restoring panel ({projectDir} is HHOSTED_PROJECT or the
panel's cwd, not HHOSTED_HOME); a literal absolute path is restored to that same path.
The panel is a static site: $HHOSTED_HOME/.hh/.ui overrides the packaged one, and Global
settings → Interface takes a zip. No restart, no fork — and home-hosted ui-revert brings back the
stock panel if yours breaks.
Two ship in this repo: uis/stock, and uis/noc-console for TUI and shortcuts wizards; a release
attaches both as home-hosted-ui-<name>.zip. Yours can be anything that compiles to static files —
the server never cares what built it.
Install a UI from the CLI: home-hosted ui-switch --asset noc-console selects an UI directly, no flags interactively show official assets. Official UIs's version auto-sync when you update home-hosted. For unofficial UIs, home-hosted ui-update offers its newer releases to pick from — or --old for older (author must set up ui.json and GH releases).
🤖 Or have an agent build the UI you actually want
The whole contract fits in one file, so a coding agent can do this. Point it at this repo and be specific:
Help me build a UI for
home-hosted: nostalgic game theme, including … features.
UI_CREATION.md has the endpoints, the SSE frames, the auth rules and a checklist.
Is it a systemd replacement?
No — it is a friendlier layer for the handful of things you run yourself. Keep systemd for the
panel itself (--foreground) and for system services; use home-hosted for the rest.
What happens to my servers when the panel stops?
home-hosted down stops them — that is the point of the command. SIGTERM/SIGINT are handled
the same way: every supervised process tree is stopped before the panel exits.
An entry marked persistent is the exception, and down names it instead of stopping it: it runs
under its own nanny process, keeps logging, and is reattached by the next panel
(SERVERS.md).
Nothing starts and the port is busy
A supervised server whose port is taken is reported rather than started over — the panel names the holder and offers to free it, and a program that restarts itself can be followed or reclaimed instead (SERVERS.md). The control port itself is checked before the listener is opened.
Where is my state?
$HHOSTED_HOME/.hh, default ~/.home-hosted/.hh. Global files at the top level, one directory per
workspace:
settings.json panel-wide: listener, auth, TLS policy, host vitals, backups
workspaces.json the registry: ids and labels, in selector order
.control-secrets.json password hash + API token hash (mode 0600)
.tls/ an uploaded PEM pair
.ui/ an installed custom UI
.backups/ zip archives
.logs/ the panel's own console log
run.json the running panel (pid, url, token, mode 0600)
<workspace>/settings.json server defaults, log retention, notifications, DDNS
<workspace>/servers.config.json your servers, plus meta: which release and schema wrote it
<workspace>/.secrets.json Telegram token + DDNS credentials (mode 0600)
<workspace>/.logs/ rotated per-server logs + history
<workspace>/.state/ persistent entries' nanny state
home-hosted status prints the paths.
Which ports does it use?
Just the control panel, 3999 by default. Supervised servers use the ports you give them.
Windows support, really?
Yes. Process trees are sampled from Win32_Process, termination uses taskkill /T, and the shipped
examples avoid POSIX-only commands. CPU temperature and swap are best-effort where the OS does not
expose them to an unprivileged process, and adopting a self-restarted process is Linux/macOS only.
src/ control plane: config, supervisor, API, providers, services
src/cli.ts the command line; one file per command under src/cli/
src/index.ts the control plane itself, used by `up --foreground`
uis/ UIs: `stock` (shipped in the package) and alternatives — any framework, static output
bin/ the published entry point
docs/ topic docs, UI examples and the README's media
scripts/ builds, typechecks, media capture, release helpers
test/ the vitest suite
pnpm dev runs the panel with tsx watch plus one UI's dev server on the 6xxx range — panel 6000,
UI 6001 (pnpm dev --ui noc-console), state in .dev-state/ — so a dev instance never fights an
installed panel's 3999. pnpm build produces dist/ and uis/stock/dist/; pnpm quickcheck is lint
plus types; pnpm test is vitest; pnpm run media regenerates the GIF above.
📚 Which doc do I need?
| if you want to… | read |
|---|---|
| declare a server: every field, placeholders, port conflicts | SERVERS.md |
| get Telegram alerts working end to end | NOTIFICATIONS.md |
| expose the stack behind one hostname, with HTTPS | REVERSE_PROXY.md |
| build a UI against the API | UI_CREATION.md |
| change the internals: architecture and the rules | AGENTS.md |
| poke the live API on your own panel | /openapi/ui |
🔗 Interesting resources
- dsh-home-hosted — home-hosted servers management with boot autostart from inside DeepSeek Harness
+ PR to add yours
MIT
