Two simple scripts for deploying sleepypod to Pod hardware.
- Pod 3 (no SD card), Pod 4, or Pod 5
- Root access via JTAG
- WiFi configured
- Software updates disabled
See main installation guide for hardware setup.
Initial root access on a Pod 5 is a JTAG bootstrap — there is no
software-only escalation. At a high level: tear down to the circuit board,
connect a TC2070-IDC + FTDI FT232RL to the JTAG header, open a 921600-baud
serial console, interrupt U-Boot at the Hit any key to stop autoboot
prompt, then:
setenv bootargs "root=PARTLABEL=rootfs_a rootwait init=/bin/bash"
run bootcmd
# in the resulting single-user shell, mount /proc /sys /dev /run,
# then `mount -o remount,rw /` and set passwords:
passwd root
passwd rewt
sync
reboot -f
After reboot, login at the serial console as root with the password you
just set, disable software-update services (swupdate, defibrillator,
eight-kernel, telegraf, vector, frankenfirmware, dac,
swupdate.socket) via systemctl disable --now + systemctl mask, then
join wifi with nmcli connection add type wifi … so the pod is reachable
over LAN.
A few names that trip people up on first contact:
rewtis a user account on the pod (Eight Sleep's stock service user — has a shell), not a tool you run. You set its password during the JTAG step and use it later for ssh whenPermitRootLogin noblocks direct root login.dacis also a system account but ships asnologin. sshd will refuse interactive logins for it; you'll seedac not allowedif you tryssh dac@<pod>. Don't try to "fix" this —dacis only meant to own the hardware-control daemon, not log in.- Stock Pod 5 sshd is locked down:
PermitRootLogin no,PasswordAuthentication no, no preinstalledauthorized_keysfor root.
So the practical Pod 5 install flow after JTAG bootstrap is:
# From your laptop, with PasswordAuthentication=yes temporarily enabled
# on the pod (default if you haven't touched sshd_config yet — it will be
# `no` once the JTAG image is fully booted, in which case flip it back to
# `yes` via the serial console and `systemctl restart sshd`).
ssh -p 8822 rewt@<pod-ip>
su - # password: whatever you set in JTAG step 7
curl -fsSL https://raw.githubusercontent.com/sleepypod/core/main/scripts/install | bashThe optional SSH-setup step at the end of the installer writes your public
key to whichever authorized_keys file sshd actually reads for root, then
re-hardens sshd (port 8822, key-only, no root password login, no empty
passwords). After that first install Pod 5 behaves like Pod 4 — key-based
root ssh on port 8822, no rewt user needed for updates.
The path is resolved at runtime by scripts/lib/ssh-helpers rather than
hardcoded, because /root/.ssh/authorized_keys is the wrong answer on
this firmware: root's home is /home/root (Yocto/poky convention), and
free-sleep's setup_ssh.sh pins an absolute
AuthorizedKeysFile /home/root/ssh/authorized_keys that survives the
installer's edits. Writing to a path sshd doesn't read, while also setting
PasswordAuthentication no, is an unrecoverable lockout — port 8822 is the
pod's only remote entry point. For the same reason the installer folds any
keys stranded in /root/.ssh/authorized_keys by an older installer into the
real file on re-run.
Everything else in that step exists to avoid locking you out against a key that can't actually authenticate. Before password auth is disabled, the installer:
- parses your key with
ssh-keygen -l(a format regex acceptsssh-ed25519 A, which sshd silently ignores), and declines to harden ifssh-keygenis missing; - refuses to guess a path when sshd is set to
AuthorizedKeysFile none, including via aMatch User rootblock, or when root's home directory can't be resolved at all; - adds directives that are absent rather than only rewriting ones that are
present —
PasswordAuthenticationdefaults toyes, so a config that never mentions it stays wide open; - re-reads the effective config with
sshd -Tand aborts, restoring the per-run/etc/ssh/sshd_config.pre-installsnapshot, unless port/auth values are what it just asked for. The auth directives are read in root's connection context (sshd -T -C user=root,…), since all three are Match-able — a global reading would pass whileMatch User rootquietly re-enabled password auth for the account being hardened. If sshd can't be asked in that context at all (an older sshd that rejects the-Cspec), the installer treats the auth directives as unverifiable and aborts+restores rather than trusting the global reading — it will not claim a hardened pod it can't confirm.
Run on the pod:
curl -fsSL https://raw.githubusercontent.com/sleepypod/core/main/scripts/install | sudo bashThis will:
- Pre-flight checks - Verify disk space, network, dependencies
- Download code - From GitHub tarball (or use
--local) - Detect pod generation - Auto-detect dac.sock path and pod hardware (
scripts/pod/detect) - Install Node.js 22 - Binary download (no apt required)
- Install dependencies - With
--frozen-lockfile - Build application - Next.js production build (skipped if pre-built)
- Database migrations - Run automatically on startup
- Create systemd service - With auto-restart and hardening
- Install CLI tools - From
scripts/bin/to/usr/local/bin/ - Install uv - Rust-based Python package manager (bypasses broken Yocto stdlib)
- Install biometrics modules -
uv syncfor each module + systemd services - Optional SSH setup - Interactive prompt for SSH on port 8822 (keys only)
flowchart TD
Start([curl install | bash]) --> Preflight[Pre-flight checks\ndisk, network, deps]
Preflight --> Download{Code source?}
Download -->|--local| Local[Use code on disk]
Download -->|default| Release{CI release\navailable?}
Release -->|yes| Tarball[Download pre-built tarball]
Release -->|no| Source[Download source tarball\nfallback build on pod]
Local --> Detect
Tarball --> Detect
Source --> Detect
Detect[Detect pod generation\nscripts/pod/detect] --> Node[Install Node.js 22 + pnpm]
Node --> Deps[pnpm install --frozen-lockfile --prod]
Deps --> Build{.next exists?}
Build -->|yes| Skip[Skip build]
Build -->|no| BuildApp[pnpm build\n⚠️ needs ~1GB RAM]
Skip --> Env
BuildApp --> Env
Env[Write .env\nDAC_SOCK_PATH, DATABASE_URL] --> DB[Backup existing DB\nMigrations run on startup]
DB --> Service[Create systemd service\nstart sleepypod]
Service --> CLI[Install CLI tools\nscripts/bin/ → /usr/local/bin/]
CLI --> UV{uv\navailable?}
UV -->|no| InstallUV[Install uv\ncurl astral.sh]
UV -->|yes| Modules
InstallUV --> Modules
Modules[Install biometrics modules] --> UVSync[uv sync per module\ncreates .venv + installs deps]
UVSync --> ModService[Create module systemd services]
ModService --> SSH{Interactive\nterminal?}
SkipBio --> SSH
SSH -->|yes| SSHSetup[Optional SSH setup\nport 8822, keys only]
SSH -->|no| Done
SSHSetup --> Done([Installation complete])
After installation (installed from scripts/bin/):
sp-status- Report service + firmware variant + biometrics pipeline (old.RAWshim vs mid-era direct.RAWvs new NATS JetStream), module health, and firmware-side service rollup. Output is paste-friendly for support threads.sp-restart- Restart sleepypod + reconnect frankenfirmwaresp-logs- View live logssp-bundle-logs- One-shot diagnostic capture (/tmp/sleepypod-bundle-<ts>.tar.gz); redacts secrets by default, pass--no-redactfor rawsp-update- Update to latest version from GitHubsp-uninstall- Remove sleepypod and all related services
Ad-hoc Python probes under scripts/ (run in place; not copied to /usr/local/bin/):
probe-nats-capture.py- Subscribe to a NATS subject filter (defaultraw.>) for a fixed window, dump every message as private NDJSON (subject, headers, payload bytes, CBOR-decoded shape), and print a per-subject histogram on exit. Use on new-firmware pods to discover what frankenfirmware actually publishes before building a consumer. Its uv shebang resolvesnats-py+cbor2on first run, so temporarily enable WAN or pre-warm the uv cache before running it on an internet-blocked pod. See the script header for usage.probe-cover-side.ts/probe-formats.ts- One-shot TypeScript probes; ad-hoc, see file headers.
Block all WAN internet (keep local network only):
sudo scripts/internet-control blockRestore full internet access:
sudo scripts/internet-control unblockFeatures:
- Blocks both IPv4 and IPv6 (prevents bypass)
- Preserves local network access
- Keeps mDNS for local discovery
- Connection tracking for established connections
# Status
systemctl status sleepypod
# Start/stop/restart
systemctl start sleepypod
systemctl stop sleepypod
systemctl restart sleepypod
# Logs
journalctl -u sleepypod -f
# Enable/disable auto-start
systemctl enable sleepypod
systemctl disable sleepypodDuring installation, you'll be prompted to configure SSH on port 8822 with keys-only authentication.
If you need to configure SSH later:
- Edit
/etc/ssh/sshd_config - Set
Port 8822andPermitRootLogin prohibit-password - Add your public key to the file sshd reads for root — check with
sshd -T -C user=root,host=localhost,addr=127.0.0.1 | grep -i authorizedkeysfile(the-Cmatters: a plainsshd -Treports the global value and misses anyMatch User rootoverride), and remember root's home is/home/root, so the default resolves to/home/root/.ssh/authorized_keys, not/root/.ssh/authorized_keys - Confirm key auth works (
ssh -p 8822 root@<POD_IP>) before settingPasswordAuthentication no— there is no other way back in - Restart:
systemctl restart sshd
Connect with:
ssh root@<POD_IP> -p 8822After installation, sleepypod provides:
- Temperature Scheduling - Set temperature by day/time
- Power Scheduling - Daily on/off cycles with temperature
- Alarm System - Vibration alarms with temperature changes
- System Automation - Daily priming and reboot schedules
- Hardware Control - Direct DAC socket communication
- Health Monitoring - Scheduler status and hardware connectivity checks
- Timezone Support - Full timezone awareness for all schedules
scripts/
├── install # Core orchestrator
├── lib/
│ └── iptables-helpers # Shared WAN/iptables functions (sourced by sp-update)
├── pod/
│ └── detect # Pod detection: DAC_SOCK_PATH, POD_GEN
├── bin/ # CLI tools — copied to /usr/local/bin/ during install
│ ├── sp-status
│ ├── sp-restart
│ ├── sp-logs
│ ├── sp-bundle-logs
│ ├── sp-update
│ └── sp-uninstall
├── deploy # Dev deploy (build local, push to pod)
├── push # Fast push (pre-built .next only)
└── internet-control # WAN block/unblock utility
Biometrics modules use uv for Python environment management. uv is a Rust-based tool that creates virtualenvs and installs packages without relying on Python's stdlib (ensurepip, pyexpat, etc.) — which are broken on Pod 3/4 Yocto images.
Each module has a pyproject.toml and uv.lock. The install script runs uv sync per module, which creates a .venv and installs locked dependencies.
- Installation:
/home/dac/sleepypod-core/ - Database:
$DATA_DIR/sleepypod.db—$DATA_DIRis chosen at install time (larger of/vs/persistentby total partition size) and persisted to/etc/sleepypod/data-dir. Default on Pod 4/5:/persistent/sleepypod-data; on Pod 3 + SD card:/sleepypod-data. Override withbash scripts/install --data-dir <path>. - Service:
/etc/systemd/system/sleepypod.service - Environment:
/home/dac/sleepypod-core/.env
Check logs:
sp-logsCommon issues:
- dac.sock path incorrect (auto-detected from
frank.sh: Pod 3/4 uses/deviceinfo/dac.sock, Pod 5 uses/persistent/deviceinfo/dac.sock) - Port 3000 already in use
- Database initialization failed
- Scheduler failing to start (check timezone in database)
- Check service is running:
sp-status - Check firewall isn't blocking port 3000
- Verify pod IP:
ip -4 addr show "$(ip route | awk '/default/ {print $5; exit}')"
Reset database:
cd /home/dac/sleepypod-core
rm "$(cat /etc/sleepypod/data-dir 2>/dev/null || echo /persistent/sleepypod-data)/sleepypod.db"
pnpm db:generate
pnpm db:push
sp-restartThe pod has limited RAM (~512MB) and cannot reliably build the Next.js app. All deployment paths build locally or in CI and ship pre-built artifacts.
From the pod or the web UI's Software card:
sp-update # update to latest main
sp-update feat/alarms # update to a specific branchHow it works:
- Opens WAN temporarily (toggles iptables)
- Tries to download a CI release tarball first (includes pre-built
.next) — no build needed on pod - Falls back to a source tarball from GitHub if no CI release exists
- Only builds on-pod if
.nextis missing (risky on low-RAM pods — avoid if possible) - Installs prod dependencies, runs migrations on startup, restarts service
- Re-blocks WAN, rolls back on failure
From your development machine:
./scripts/deploy # current branch → default pod (192.168.1.88)
./scripts/deploy 192.168.1.50 # current branch → different pod
./scripts/deploy 192.168.1.88 feat/alarms # checkout + deploy a branchHow it works:
- Builds Next.js locally on your Mac (fast, full RAM)
- Tars source +
.nextbuild, pipes over SSH to the pod - Runs
scripts/install --local --no-sshon the pod (prod deps only, no build) - Service restarts automatically
Skip the build entirely — push an already-built .next directory:
./scripts/push # push pre-built .next to default pod
./scripts/push 192.168.1.50 # push to different podUse this when you've already run pnpm build locally and just want to sync.
Next.js production builds require 1-2GB RAM for Turbopack. The pod has ~512MB. Attempting pnpm build on-pod may OOM-kill the process or produce a corrupted build. All deployment paths avoid this by shipping pre-built .next artifacts.