Local-only pipeline: laptop → Raspberry Pi over SSH using Docker Compose. No GitHub Actions, no online registry, no AWS.
- Prod: public HTTPS via Tailscale Funnel (
https://<hostname>.<tailnet>.ts.net) - Test: LAN-only at
http://<pi>:3001
Both stacks run on the same Pi with separate data directories, so test votes never touch prod data.
- Laptop: Docker Desktop with buildx, Node 24+, rsync, SSH key auth to the Pi
- Pi: Docker installed, SSH enabled, internet access
Visit https://login.tailscale.com/admin and complete these four steps.
Enable DNS features (Settings → DNS):
- Turn on MagicDNS
- Turn on HTTPS Certificates
Grant Funnel access (Access controls → Edit ACLs) — add:
Acknowledge Funnel (one-time) — the ACL attr only grants the capability;
Tailscale also requires a one-time confirmation before it will publish the
public DNS record and serve traffic. Until you do this, everything looks correct
locally (tailscale funnel status says "Funnel on", the cert issues) but the
public URL fails to resolve ("server not found"). Trigger it once:
npm run pi:funnel # or: docker exec <ts-container> tailscale funnel 3000and open the https://login.tailscale.com/f/funnel?node=… link it prints to
confirm.
Create an auth key (Settings → Keys → Generate auth key):
- Reusable: ✓
- Pre-approved: ✓
- Tags:
tag:scrollsurf
Copy the key — you will put it in .env.prod as TS_AUTHKEY.
SSH into the Pi and run once:
sudo mkdir -p /srv/scrollsurf/prod/datasets /srv/scrollsurf/test/datasets
sudo chown -R 1000:1000 /srv/scrollsurfThe container runs as uid 1000 (node), so the bind-mounted directories must be
owned by that user.
docker context create pi --docker "host=ssh://pi@raspberrypi.local"
docker context ls # confirm 'pi' appearsReplace pi@raspberrypi.local with your actual Pi SSH address. Ensure SSH key
auth works (ssh pi@raspberrypi.local should not ask for a password).
Copy .env.example to the three environment files and fill in the values:
cp .env.example .env.local # dev (Next.js auto-loads this)
cp .env.example .env.test
cp .env.example .env.prodMinimum values per file:
.env.local (local dev):
SCROLLSURF_DATA_DIR=.
.env.test:
PI_SSH=pi@raspberrypi.local
DATA_DIR_HOST=/srv/scrollsurf/test
TEST_PORT=3001
SCROLLSURF_DATA_DIR=. # not used by deploy script, but needed if running locally against test
.env.prod:
PI_SSH=pi@raspberrypi.local
DATA_DIR_HOST=/srv/scrollsurf/prod
TS_AUTHKEY=tskey-auth-...
TS_HOSTNAME=scrollsurf
SCROLLSURF_DATA_DIR=.
All three files are gitignored. Only .env.example is committed.
# Deploy to test (LAN only):
npm run deploy:test
# → app running at http://<pi>:3001
# Deploy to prod (public HTTPS):
npm run deploy:prod
# → app running at https://scrollsurf.<tailnet>.ts.net
# Check the public Funnel URL:
npm run pi:funnel
# Tail logs:
npm run pi:logs:test
npm run pi:logs:prod
# Tear down a stack:
npm run pi:down:test
npm run pi:down:prodEach deploy:
- Runs
npm run check(type-check + lint) — fails fast if the code is broken - Rsyncs
datasets/into the Pi data dir. If the localdatasets/directory has no*.dbfiles, this step is skipped with a warning (so the deploy still works on a fresh checkout, and an emptydatasets/never wipes the remote copy via--delete). Run thedownload-*scripts first if the app should have content. - Builds the Docker image on the Pi (ARM-native via the SSH context) and starts the stack
Datasets and code deploys are independent: re-deploying without changing datasets is fast because rsync only transfers diffs.
The first time prod starts, Tailscale provisions an HTTPS certificate — this takes
up to ~60 seconds. The app is reachable once npm run pi:funnel shows the public
URL as active.
npm run check is the single gate both deploys depend on. To add steps:
"check": "npm run type-check && npm run lint && npm test"No pipeline restructure required — just extend the check script.
If the Pi runs out of memory during the Docker build:
# Build for ARM64 on the laptop:
docker buildx build --platform linux/arm64 -t scrollsurf:latest --load .
# Ship the image to the Pi:
docker save scrollsurf:latest | ssh pi@raspberrypi.local docker loadThen change build: . → image: scrollsurf:latest in the compose file and
re-run the deploy. No Pi host changes required.