Every screen you own, one price, forever.
Self-hosted digital signage — build playlists of images, videos, web pages, and templated announcement slides; pair any screen (any browser, any device) with a 6-character code; schedule content by time of day; watch every screen's health from one dashboard. No per-screen SaaS rent, no app to install on the TV.
Don't want to touch a terminal? Grab the packaged installer (Windows desktop app + guided VPS deploy) here:
→ https://whop.com/onetime-suite — one-time purchase, lifetime updates.
- Content library — image upload, video upload (mp4/webm), web-page URL (sandboxed iframe, with a dashboard flag if a site blocks embedding), and announcement slides built from 5 layout templates (basic-center, banner-top, split-left, quote, alert) so a "Closed Friday" slide takes 20 seconds, no designer required
- Playlists — ordered items with per-entry duration overrides, drag-to-reorder, cut/fade transition, in-browser preview
- Screen pairing — the player page (
/player) shows a 6-character pairing code; type it into the dashboard, name the screen, assign a playlist. No per-screen accounts, no player app — any smart-TV browser, a Pi in kiosk Chromium, or an old tablet all work identically - Server-side dayparting — schedule rules ("Mon–Fri 07:00–11:00 → Breakfast Menu") evaluated in the screen's own IANA timezone, not the kiosk device's (often wrong) clock. Priority-ordered rules, visual weekly grid in the admin UI
- Screen health dashboard — live grid: online (WS connected or heartbeat < 90s) / offline, last-seen timestamp, currently-playing item, reported resolution, player version
- Live updates — playlist/schedule/takeover edits push to affected players instantly over WebSocket (
reload_manifest); players also poll every 60s as a fallback - Offline caching — the player registers a service worker that pre-caches the current playlist's media via Cache Storage, so a dropped connection mid-loop doesn't blank the screen; cache is evicted of anything no longer in the manifest so a video-heavy playlist doesn't fill a Pi's SD card
- Emergency takeover — push one slide to every paired screen instantly; clear with one click
- 100% local & private — one SQLite file + an uploads folder, no telemetry, no external services
Offline media caching uses the browser Service Worker API, which browsers only allow on https:// origins or localhost/127.0.0.1. If you're deploying on a plain-http:// LAN address (e.g. http://192.168.1.50:5338/player on a bare Pi), the player degrades gracefully to no-cache mode — it still plays fine, it just re-fetches media from the network every loop instead of caching it. For real offline resilience on a LAN, put a reverse proxy (Caddy/nginx/Traefik) with a local CA or Tailscale/self-signed cert in front of the signage box, or just run the player through npm run desktop (Electron), which loads 127.0.0.1 and gets full caching for free.
npm i
npm run build # builds the admin UI
npm start # → http://localhost:5338- Admin panel:
http://localhost:5338/admin(default passwordadmin— change viaADMIN_PASSWORD) - Player page:
http://localhost:5338/player— open this on the TV/tablet/Pi you want to pair
Run it as a desktop app, or deploy to a $5 VPS when you need it public:
npm run desktop # Electron window, auto-logged-in, data stored per-usernpm run desktop also doubles as a literal kiosk player — point a signage Windows box at it and fullscreen /player inside the window.
npm run dist packages a Windows installer (NSIS) via electron-builder.
cp .env.example .env # set ADMIN_PASSWORD!
docker compose up -d # persists SQLite + uploads in a named volumePut Caddy/nginx/Traefik in front for TLS — required if you want players to get real offline media caching (see the HTTPS caveat above).
| Signboard (this) | Yodeck | ScreenCloud | OptiSigns | Xibo Cloud | |
|---|---|---|---|---|---|
| Price | $49 once | $8/screen/mo | $20+/screen/mo | $10/screen/mo | ~$12/display/mo |
| 5-screen annual cost | $0 | $480/yr | $1,200/yr | $600/yr | $720/yr |
| Own your data | Always | On their servers | On their servers | On their servers | On their servers |
| Pairing | 6-char code, any browser | Dedicated player app | Dedicated player app | Dedicated player app | Player app / Xibo client |
| Dayparting | ✅ Server-side, per-screen timezone | ✅ | ✅ | ✅ | ✅ |
| Offline caching | ✅ Service worker | ✅ | ✅ | ✅ | ✅ |
| Emergency takeover | ✅ Built in | Paid tier | Paid tier | ✅ | Limited |
| Source | MIT, self-hosted | Closed | Closed | Closed | Open core |
| Cost over 3 years (5 screens) | $49 | $1,440 | $3,600 | $1,800 | $2,160 |
- Server: Node 20+, Express, better-sqlite3 (WAL),
wsWebSocket server on the same HTTP server — single process serves admin, API, player, and media - Admin UI: React 18, Vite, Tailwind CSS 4, Framer Motion (drag-to-reorder, tab transitions), Lucide icons
- Player: framework-light plain JS (ES2017-safe for ancient smart-TV browsers), double-buffered DOM swap to avoid flicker, muted-autoplay video, service worker for offline media
- Desktop: thin Electron wrapper reusing the exact same server on a free local port
- Storage: one SQLite file + an uploads folder. Back up = copy two things.
| Env var | Default | Purpose |
|---|---|---|
PORT |
5338 |
Server port |
ADMIN_PASSWORD |
admin |
Admin panel password |
DB_PATH |
./data |
SQLite db + uploads live inside this directory |
UPLOAD_DIR |
<DB_PATH>/uploads |
Override just the uploads location |
BASE_URL |
request host | Absolute base URL used to build media links (needed behind some reverse proxies) |
FAKE_NOW |
— | ISO datetime override for the schedule evaluator — testing only |
items (image/video/url/slide) · playlists + playlist_items (ordered, per-item duration override) · screens (device token, pairing code, IANA timezone, default playlist, last-seen/state) · schedule_rules (per-screen daypart rules with days-of-week bitmask, start/end time, priority) · takeover (single-row emergency override) · sessions (admin auth).
npm start # API + player on :5338
npm run dev # Vite dev server for the admin UI on :5339 (proxies /api, /media, /player)
npm test # end-to-end smoke test — spawns the real server on :5438 against a temp DBMIT © 2026 Ben (bensblueprints)
