Homepage Dashboard Screenshotter (Playwright)
Plan C for Pi Zero Dashboard Kiosk — the Pi Zero (ARMv6) can’t run a modern browser engine reliably (Chromium needs NEON it doesn’t have, WebKitGTK/surf SIGFPEs on Homepage’s React content). Instead of rendering on the Pi, render on the same LXC that already hosts Homepage (LXC 112, 192.168.1.95, 2 core/2GB RAM, 5GB disk free as of 2026-08-25) using Playwright, and have the Pi just display the resulting PNG via fbi (framebuffer image viewer — no X, no browser, no GL).
Deploy as two more services in the same docker-compose.yml as Homepage/Kuma, so they land on the existing homepage_default network automatically and can reach Homepage via its Compose service name — same hairpin-NAT gotcha Kuma already hit and fixed (see log - homepage dashboard setup, bug #1): must use http://homepage:3000, not the LAN IP 192.168.1.95:3000, from inside another container on this host.
1. Screenshotter service
Local mirror path (matching the existing convention): C:\Users\Rick\VMHost\homepage\screenshotter\
Deployed path on LXC: /opt/homepage/screenshotter/
screenshotter/Dockerfile
FROM mcr.microsoft.com/playwright:v1.48.0-noble
WORKDIR /app
COPY package.json .
RUN npm install
COPY shoot.js .
CMD ["node", "shoot.js"]screenshotter/package.json
{
"name": "dashboard-screenshotter",
"version": "1.0.0",
"private": true,
"dependencies": {
"playwright": "1.48.0"
}
}screenshotter/shoot.js
const { chromium } = require('playwright');
const fs = require('fs');
const URL = process.env.TARGET_URL || 'http://homepage:3000';
const OUT = process.env.OUT_PATH || '/screenshots/dash.png';
const INTERVAL_MS = parseInt(process.env.INTERVAL_MS || '60000', 10);
const WIDTH = parseInt(process.env.WIDTH || '1920', 10);
const HEIGHT = parseInt(process.env.HEIGHT || '1080', 10);
async function shoot(browser) {
const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
try {
await page.goto(URL, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForTimeout(1000); // let widgets finish their own data fetches
const tmp = OUT + '.tmp';
await page.screenshot({ path: tmp, type: 'png' }); // type must be explicit — screenshot() infers format from the path's extension, and ".tmp" isn't recognized
fs.renameSync(tmp, OUT); // atomic swap so the Pi never fetches a half-written file
console.log(new Date().toISOString(), 'screenshot ok');
} catch (err) {
console.error(new Date().toISOString(), 'screenshot failed:', err.message);
} finally {
await page.close();
}
}
(async () => {
const browser = await chromium.launch();
await shoot(browser);
setInterval(() => shoot(browser), INTERVAL_MS);
process.on('SIGTERM', async () => { await browser.close(); process.exit(0); });
})();Runs as a long-lived container (not a per-shot cron job) — Chromium stays warm between shots so each screenshot is fast, and setInterval keeps the cadence simple. RAM cost is one persistent headless Chromium instance rather than a repeated cold-start; worth watching docker stats after deploy given the 2GB ceiling shared with Homepage + Kuma.
2. Static file server (serves the PNG to the Pi)
screenshot-server:
image: nginx:alpine
container_name: dashboard-screenshot-server
restart: unless-stopped
volumes:
- screenshots:/usr/share/nginx/html:ro
ports:
- "8080:80"3. Add both to the existing docker-compose.yml
screenshotter:
build: ./screenshotter
container_name: dashboard-screenshotter
restart: unless-stopped
environment:
- TARGET_URL=http://homepage:3000
- OUT_PATH=/screenshots/dash.png
- INTERVAL_MS=60000
volumes:
- screenshots:/screenshots
depends_on:
- homepage
screenshot-server:
image: nginx:alpine
container_name: dashboard-screenshot-server
restart: unless-stopped
volumes:
- screenshots:/usr/share/nginx/html:ro
ports:
- "8080:80"
volumes:
screenshots:Deploy: docker compose up -d --build from /opt/homepage/.
4. UFW — open 8080, same LAN-only pattern as 3000/3001
sudo ufw allow from 192.168.1.0/24 to any port 8080 proto tcp
sudo ufw allow from 192.168.2.0/24 to any port 8080 proto tcp5. Pi side — fetch + display loop (final, working)
STATUS: WORKING, confirmed live 2026-08-29. The version below is the corrected final one — see Pi Zero Dashboard Kiosk for the full debugging story (three stacked bugs: systemd TTYPath couldn’t do VT ioctls, -t timeout didn’t fix a mystery instant-exit, and the actual root cause — a backgrounded & process in a #!/bin/sh script gets its stdin silently redirected to /dev/null by POSIX shells, which broke fbi’s VT ioctls in every single attempt regardless of session type).
~/.bash_profile:
if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
exec /home/coytis/dash-display.sh
fi/home/coytis/dash-display.sh:
#!/bin/sh
while true; do
curl -fsS -o /tmp/dash.png.tmp http://192.168.1.95:8080/dash.png && mv /tmp/dash.png.tmp /tmp/dash.png
pkill fbi 2>/dev/null
fbi -d /dev/fb0 -a --noverbose -t 65 /tmp/dash.png < /dev/tty1 > /tmp/fbi.log 2>&1 &
sleep 60
doneKey details that mattered: -d /dev/fb0 forces plain framebuffer mode (skips fbi’s DRM auto-detect VT-switch path); < /dev/tty1 explicitly gives fbi a real tty on stdin (without this, VT ioctls fail no matter what); -t 65 is a safety-net timeout slightly longer than the loop’s own 60s cadence. Launched via genuine tty1 autologin, not a bare systemd service — a dash-display.service with TTYPath=/dev/tty1 was tried first and didn’t work (that’s dead-end #1 in the debugging log). This replaces the entire X/browser stack from Pi Zero Dashboard Kiosk — no X server, no .xinitrc, no startx, no browser at all; fbi runs directly on the console framebuffer.
Disk footprint (confirmed)
mcr.microsoft.com/playwright base image landed at ~3.1GB actual usage on the LXC (72% of 7.8GB, 2.2GB free remaining) — a bit more than the earlier ~1.5–2GB estimate, but still comfortable headroom alongside Homepage + Kuma.
Next steps
- Create
screenshotter/folder, deploy to LXC at/opt/homepage/screenshotter/ - Add both services to
/opt/homepage/docker-compose.yml,docker compose up -d --build - Confirmed DNS resolution + screenshot working (
docker logs dashboard-screenshotter→ “screenshot ok”) - Confirmed
http://192.168.1.95:8080/dash.pngserves a real 200 OK PNG from the LAN - Checked
df -hon LXC post-build — 2.2GB free, fine - Pi’s
.bash_profilereworked to drop X entirely and run thefbifetch-loop instead — live and working - Consider a systemd service instead of autologin for the Pi-side loop now that the stdin bug is understood (
TTYPath+< /dev/tty1would likely work now) — not urgent, current setup works fine ✅ 2026-09-02 - Fixed a bug in
shoot.jsalong the way:page.screenshot({ path })infers format from the file extension, and the.tmptemp-file suffix broke that — needed explicittype: 'png'(already corrected above) ✅ 2026-09-02