A self-hosted "homepage" dashboard — a lighter-weight, more UI-customizable alternative to Homepage / Homarr / Heimdall. Every bit of configuration (pages, tiles, themes, integrations) is editable from the browser; nothing is YAML-file-driven. Config is persisted to SQLite + uploaded assets under a single data directory, so the whole instance is portable via the built-in backup/restore.
Sample page shown — clock, weather, server stats, bookmarks, an RSS feed, notes, search, and two groups of app-launcher tiles. None of it is fixed: tile types, layout, grouping, and theme are all picked per page from the browser.
- No YAML. Pages, tiles, themes, and integrations are all configured by clicking around in the browser — nothing to hand-edit, indent correctly, or restart the container over.
- 12 tile types, not just links: clock, weather, notes, search, RSS/Atom, an ICS calendar, bookmarks, a custom-API mapper, host resources (CPU/mem/disk/net), an iframe embed, plus a widget tile for any of the 14 built-in service integrations.
- Portable by default. Everything lives in one SQLite file plus an uploads folder — export
a
.zip(full backup) or a.yaml(version-controllable config, secrets kept out) and restore either onto a fresh container. - Drop-in integrations. Add a new service by dropping a
*.integration.jsfile into the data volume — no image rebuild, no core code changes. The five generic view types (stats,nowplaying,queue,list,calendar) mean the frontend needs zero changes for it either. - Actually light. ~30-40 MB idle RSS, ~0% idle CPU, a ~240 MB image built from bare Alpine
(not
node:22-alpine) with no bundler or dev toolchain shipped at runtime. - 8 built-in themes (minimal, glass, terminal, gradient, nord, rosé pine, dracula, oled) × light/dark/system, plus a free accent color and per-tile color overrides.
| selfdash | Homepage | Homarr | Heimdall | |
|---|---|---|---|---|
| Configuration | In-browser | YAML files | In-browser | In-browser |
| Backup/restore | Built-in .zip + .yaml export |
Manual (your YAML files) | Varies | Manual |
| Add an integration | Drop a JS file, no rebuild | Edit YAML / core code | Plugin system | Limited |
| Idle RSS | ~30-40 MB | Low | Higher (Next.js) | Low |
(A fair comparison, not a takedown — Homepage's YAML model is a deliberate, well-executed choice that suits a lot of setups better than a database-backed UI does.)
Grab docker-compose.yml (it pulls the prebuilt
tstech0806/selfdash image — nothing is built
locally) and run:
docker compose up -dThen open http://localhost:3000. That's it — a "Home" page is created automatically on first
boot. docker compose down stops it; your data stays in the ./data directory next to
docker-compose.yml (a bind mount) — back that folder up and the instance moves with it.
The container starts as root only to fix ownership of ./data, then drops to PUID:PGID
(default 1000:1000). If the host user that owns your stack directory isn't uid 1000, set
PUID/PGID in .env (id -u / id -g).
Update to a newer image with:
docker compose pull && docker compose up -dPrefer to run it without Docker for local development:
npm install
npm run build # bundles the frontend into public/
npm run dev # Fastify with --watch, serves on :3000, data in ./data/The image on Docker Hub is built from the Dockerfile in this repo. To build it yourself:
docker build -t selfdash:local .then point docker-compose.yml at image: selfdash:local.
BuildKit note: in some environments
docker buildfails partway throughnpm installwithnpm error Exit handler never called!— the isolated BuildKit network namespace can't reach the npm registry. Give the build the host network stack:docker build --network=host -t selfdash:local .(orDOCKER_BUILDKIT=0 docker build -t selfdash:local .).
Run the full-validation suite after every change:
npm test # unit + API — ~10s, no network, no browser
npm run test:all # + headless-browser smoke (needs Chrome; skips otherwise)npm test boots the real server against throwaway SQLite DBs and exercises every
route, plus every pure helper in src/shared and src/lib. The case-by-case
catalog and the process for adding tests when you add a feature live in
TESTPLAN.md; running details are in test/README.md.
| Var | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP listen port |
DATA_DIR |
/data |
SQLite DB, uploads, and runtime integrations live here |
APP_SECRET |
(unset) | If set, encrypts stored integration credentials at rest (AES-256-GCM) |
LOG_LEVEL |
info |
Fastify/pino logger level |
POLL_MIN_INTERVAL |
15 |
Safety floor (seconds) — no integration can be polled faster than this, regardless of its configured interval |
PUID / PGID |
1000 |
uid/gid the container chowns DATA_DIR to and runs as (Docker only) |
For docker compose, secrets live in a git-ignored .env file: cp .env.example .env,
then generate a value with openssl rand -hex 32 and set APP_SECRET. Compose loads it
automatically (env_file in docker-compose.yml); the file is optional, so the stack still
comes up without it (integration config is then stored as plaintext).
Set APP_SECRET before you configure any integrations with real credentials — changing it
later won't re-encrypt anything already stored, and stored secrets become undecryptable if you
lose the value.
Everything under DATA_DIR (/data in the container, bind-mounted from ./data on the host)
is what makes an instance portable:
/data
├── selfdash.db # pages, tiles, settings, integration config/cache — everything
├── selfdash.db-wal # SQLite WAL files, present when the db is open
├── selfdash.db-shm
├── uploads/ # icons, backgrounds, favicons uploaded through the UI
└── integrations/ # drop a *.integration.js file here to add a new integration —
# no image rebuild needed (see "Writing an integration" below)
Back this whole directory up (or use the built-in export, see below) and you can restore the entire instance — pages, tiles, theme, integrations and their credentials, uploaded assets — onto a fresh container.
Settings → Backup & restore:
- Export downloads a
.zipcontainingselfdash.db, everything inuploads/andintegrations/, and amanifest.jsonlisting it all. The db is WAL-checkpointed first so the copy is a consistent snapshot even while the server keeps running. - Import uploads a
.zipin the same shape and restores it. This overwrites all current data — the UI asks for confirmation. Before overwriting, the server snapshots the currentselfdash.db/uploads//integrations/into a timestamped.pre-import-backup-<ts>/folder inside the data volume (not deleted automatically — that's your manual undo if an import goes wrong). After swapping the files in, the server exits;restart: unless-stoppedindocker-compose.ymlbrings it back up against the restored data through its normal boot sequence (migrations, integration registry, poller — all re-initialized fresh, same as any other container start).
Alongside the .zip, Settings → Backup & restore has Export config (.yaml) — a
human-readable snapshot of pages, tiles, integrations, and settings (no uploaded images).
It's for version control: commit it, git diff it, share it, reproduce an instance from it.
- Secrets (
password-type fields — API keys, tokens) are written as${SELFDASH_SECRET_<REF>_<FIELD>}placeholders, never the real value. - Import config replaces all pages/tiles/integrations/settings in one transaction
(rolled back on any structural error; uploaded images untouched). Secret placeholders are
read from the container's environment; anything unset imports blank and is reported so you
can fill it in (Settings → Integrations) or add the env var and re-import. Widget tiles
reference integrations by a stable
ref, so ids don't have to line up. - Also available over HTTP:
GET /api/config/export,POST /api/config/import(a.yamlfile upload, or a raw body withContent-Type: application/yaml; add?settings=0to leave settings alone).
Settings → Docker Compose scan. Point selfdash at a directory that contains one or more
compose stacks (searched recursively, node_modules/.git/hidden dirs skipped, bounded to 6
levels / 250 files / 512 KB per file) and the dashboard grows a panel listing, per container:
- every published port (
host → container, with host IP and non-TCP protocol shown), plusexpose-only internal ports - every volume (
source → target), tagged bind vs named, withroflagged - each stack's declared top-level named volumes
compose.yaml / docker-compose.yml and their *.override.* variants are recognised. ${VAR}
references are resolved from a sibling .env file and from ${VAR:-default} fallbacks; anything
still unresolved is shown literally so you can see it's environment-driven. Files that fail to
parse are listed at the bottom of the panel rather than failing the whole scan. Results are
cached ~15 s server-side; Rescan forces a fresh read.
This feature only ever reads. It never creates, edits, moves, or deletes a file — it
readdir/stat/readFiles compose files and parses them in memory, nothing else. A symlink
pointing outside the configured directory is not followed.
Running in Docker, the compose directory has to be visible inside the container. The shipped
docker-compose.yml bind-mounts ${COMPOSE_SCAN_DIR:-/compose-stacks} read-only at the same
path inside the container; set COMPOSE_SCAN_DIR in .env to the absolute path of your stacks
directory and put that same path in Settings → Docker Compose scan. To mount it somewhere
else:
volumes:
- ./data:/data
- /opt/stacks:/stacks:ro # <- your compose stacks
# then set the path in Settings to: /stacksThe enable flag and directory path live in settings (so they're covered by backup/restore);
the compose files themselves are external and are not backed up.
The Host resources tile shows CPU, memory, one bar per configured drive, and one row per configured network interface (leave the interface list empty to auto-pick the busiest).
Running in Docker it reads /proc. By default that's the container's /proc, which is fine
for CPU/memory but shows only the container's own filesystem and eth0. To report the host:
volumes:
- /proc:/host/proc:ro # host CPU / memory / disk
- /mnt/media:/host/mnt/media:ro # any extra drive you want in the tile
pid: host # host network interfaces (per-interface stats)Then use the in-container paths (/host/mnt/media, …) in the tile's drive list. pid: host
is only needed for real host network stats — /proc/net/* is network-namespace scoped, so the
tile reads pid 1's view (/host/proc/1/net/dev) when the PID namespace is shared.
Drop a file matching *.integration.js into DATA_DIR/integrations/ (e.g.
/data/integrations/myservice.integration.js inside the container, or the corresponding path on
your bind-mounted/named volume) and restart the container — no image rebuild, no core code
changes. It's picked up automatically. A file in the runtime folder with the same key as one of
the built-in integrations overrides it, so you can patch a shipped integration too.
export default class MyServiceIntegration {
static key = 'myservice'; // unique, used as the DB/API identifier
static title = 'My Service'; // shown in the "add integration" list
static defaultInterval = 60; // seconds; still floored by POLL_MIN_INTERVAL
static configSchema = {
fields: [
{ name: 'url', label: 'Server URL', type: 'url', required: true },
{ name: 'apiKey', label: 'API Key', type: 'password', required: true },
],
};
async fetchData({ config, http }) {
// `http` is a shared client with a per-host concurrency cap and a request timeout —
// use it instead of the global fetch so your integration plays nicely with others
// hitting the same host.
const data = await http.fetchJson(`${config.url}/api/status`, {
headers: { 'X-Api-Key': config.apiKey },
});
// Throwing here is the correct way to signal failure — the scheduler catches it,
// marks the integration "unreachable", records the error, and (importantly) leaves
// the last successfully-fetched data in place so the UI doesn't blank out.
if (!data.ok) throw new Error('service reported not-ok status');
// Must return one of five normalized shapes — the frontend has one generic
// renderer per type, so a new integration needs zero frontend changes.
return {
type: 'stats', // 'stats' | 'nowplaying' | 'queue' | 'list' | 'calendar'
items: [{ label: 'Status', value: 'OK' }],
};
}
}configSchema.fields is auto-rendered into a form in the Integrations panel — no bespoke UI per
integration. Field types: text, url, password, number, checkbox, select (single,
options: [{value,label}]), and multiselect (checkbox group, value is an array). type: 'password'
fields are never sent back to the frontend in plaintext (only whether one is set); leaving one
blank when editing keeps the existing stored value.
An integration that can show more than one thing (queue and library stats, say) uses the
_views.js helper. Declare a views map and always fetch every one of them, every poll:
import { runAllViews } from './_views.js';
const VIEWS = {
queue: { label: 'Download queue', run: (ctx) => fetchQueue(ctx) },
stats: { label: 'Library stats', run: (ctx) => fetchStats(ctx) },
};
static views = Object.fromEntries(Object.entries(VIEWS).map(([k, v]) => [k, v.label]));
static configSchema = { fields: [ /* url, apiKey, */ ] };
async fetchData(ctx) {
return runAllViews(ctx, VIEWS);
}fetchData always returns { type: 'multi', byView: { <viewKey>: WidgetModel } } — the
integration doesn't decide what's displayed, it just keeps every view's data fresh. Which
view(s) a tile shows is a property of the tile, not the integration (tile.config.views, set
in the tile's edit modal via static views's catalog) — so two tiles pointed at the same
integration can each show something different. Static views is also what makes an
integration "mergeable": a tile can pick a single view and, if another integration exposes a
view with that same key (tile.config.moreIntegrationIds), combine both into one tile — e.g. one
calendar showing releases from two Radarr instances, or all Radarr + Sonarr calendars together.
calendar/list/queue views merge their items; stats/nowplaying fall back to one section
per source since there's no sane way to merge a single number or now-playing card. See
mergeModel in web/components/WidgetTile.jsx. If every view in byView failed, runAllViews
throws so the tile keeps its last good data; a view that fails on its own becomes
{ type: 'error', error } in its own slot instead of taking the rest down with it.
The five WidgetModel shapes, and what each items[] entry looks like:
type |
items[] shape |
Used by |
|---|---|---|
stats |
{ label, value } |
Audiobookshelf, Plex, Tautulli, *arr library stats |
nowplaying |
{ title, subtitle?, image?, progress? } (0-1) |
Plex, Tautulli (active sessions) |
queue |
{ title, status?, progress? } (0-1) |
qBittorrent, SABnzbd, Radarr, Sonarr, Readarr, Tautulli streams |
list |
{ title, subtitle?, image? } |
*arr upcoming, Tautulli history, Bookdrop |
calendar |
{ ts, title, subtitle? } (ts = epoch ms; bucketed by local day, rendered one month at a time with ‹ › navigation) |
Radarr / Sonarr release calendar |
Fourteen integrations ship out of the box (src/integrations/*.integration.js):
- Downloads — qBittorrent, SABnzbd
- *Media libraries / arr — Radarr, Sonarr, Readarr, Prowlarr, Plex, Tautulli, Audiobookshelf
- Requests — Overseerr (also works with Jellyseerr / seerr)
- Other self-hosted — Immich (photos), Mealie (recipes), Gluetun (VPN control server), Bookdrop (upcoming audiobook releases)
They were built and validated against documented API shapes plus a mock-HTTP-server test suite; qBittorrent, SABnzbd, Radarr, Sonarr, Plex, and Audiobookshelf have also been confirmed against live instances. Treat the rest as a solid first draft, not as pre-verified against every real-world version/config quirk — endpoint paths and field names in particular vary by app version.
Settings → pick a theme (minimal, glass, terminal, gradient, nord,
rosé pine, dracula, oled) and an appearance mode (light / dark / system).
Every theme defines both a light and dark token set;
system resolves via prefers-color-scheme in the browser and updates live if the OS setting
changes while the tab is open. The accent color is a single CSS variable (--accent) layered on
top of whichever theme is active, so any theme + any accent combination works.
Themes are plain CSS custom properties in web/style.css, scoped under
[data-theme="..."][data-mode="..."] selectors — adding a new theme is a CSS-only change, no
JS required.
- Backend: Fastify +
node:sqlite(Node's built-in SQLite binding — no native module to cross-compile between the Docker build and runtime stages). Runs behind a singlepreHandlerhook point reserved for future auth (there is no auth in v1 by design — anyone who can reach the instance can configure it). - Frontend: Preact +
@preact/signalsfor state, bundled by esbuild only duringdocker build— the runtime image ships static files and has no bundler or dev toolchain in it. Drag-to-reorder tiles use SortableJS. - Integrations:
src/integrations/_base.js(BaseIntegration) +_registry.js(loads*.integration.jsfrom both the shipped directory andDATA_DIR/integrationsat boot).src/poller/scheduler.jsruns onesetIntervalper enabled integration, in-process — no extra worker, no Redis.src/integrations/configCodec.jsis the single place that encrypts/decrypts/masks/validates integration config, shared by the routes and the poller so that logic can't drift apart between the two call sites. - Storage: one SQLite file (WAL mode) holds pages, tiles, settings, and integration
config/cache. Uploaded assets (icons, backgrounds, favicons) live as plain files under
DATA_DIR/uploads, served by a second, separately-rooted@fastify/staticregistration.
node:sqliteoverbetter-sqlite3— no native module means the build and runtime Docker stages can't break on an ABI mismatch. Cost:node:sqliteis still experimental in Node 22 (the--experimental-sqliteflag plus--disable-warning=ExperimentalWarningare baked into thenpm start/devscripts and the DockerfileCMDto make this invisible in normal operation).- Fastify over a compiled-binary backend — costs a bit of idle RSS (see below) versus, say,
Go, but buys runtime-loadable integration files: dropping a
.jsfile into the data volume is enough, no recompiling or rebuilding the image. - In-process poller over a separate worker/cron process — one
setIntervalper enabled integration is simple to reason about and cheap enough for a personal dashboard's integration count; there's no case here for the operational overhead of a second process or a queue. - CSS Grid + SortableJS over a free-form pixel-grid drag library — tile positions are S/M/L/ wide presets on a fixed-column grid, not arbitrary x/y coordinates. Free-form grids are a common performance sink in comparable dashboards; this sidesteps that entirely.
Measured with docker stats — Fastify + the frontend served + the integration registry loaded
(all built-in integrations) but zero integrations actually configured/polling:
- Idle RSS: ~30-40 MB (well under the ~55-80 MB original estimate and the 100 MB target)
- Idle CPU: ~0% (timer-driven only — nothing polls unless an integration is configured and enabled)
- Docker image size: ~240 MB. The runtime stage isn't
node:22-alpineitself — it's bare Alpine plus just the Node binary (build/deps stages still usenode:22-alpineto install dependencies and run esbuild, but npm/corepack/yarn/headers never make it into the final image, since nothing needs them after build time). Down from ~290 MB before that split.
Each additional enabled integration adds one lightweight timer plus whatever memory its last cached JSON response occupies — not meaningfully measured individually here, but bounded by design (no per-integration process, no unbounded cache growth).
Actively developed — see TESTPLAN.md for the full case-by-case catalog. Every
change ships behind the automated suite (unit + API + a headless-browser smoke test) plus a
manual click-through checklist for anything touching layout or interaction; nothing merges
without both. Six of the fourteen built-in integrations (qBittorrent, SABnzbd, Radarr, Sonarr,
Plex, Audiobookshelf) have also been confirmed against live instances — the rest are a solid
first draft built from documented API shapes, not yet exercised against every real-world
version/config quirk.
There is intentionally no auth in v1 (see Architecture below) — put it behind a reverse proxy or VPN if it's reachable from anywhere you don't fully trust.

