{"kind":"doc","doc":{"title":"Single-origin deployment with bhvr","section":{"slug":"posts","name":"Posts"},"rkey":"1747182886660-single-origin-deployment-with-bhvr","cid":"bafkreibs2wayzsdqhxj77mwirctl7u5ilr3csyuzbefcz6ej4d7u5agqnu","renderKey":"bafkreibs2wayzsdqhxj77mwirctl7u5ilr3csyuzbefcz6ej4d7u5agqnu@2025-05-13T17:34:00Z@2026-10-20T04:18:19Z@Posts|","href":"/posts/1747182886660-single-origin-deployment-with-bhvr","publishedAt":"2025-05-13T17:34:00Z","updatedAt":"2026-10-20T04:18:19Z","tags":["raspberry-pi","hardware","solar-powered","web-development","cloudflare-tunnel"],"description":"Fried my Pi using a 10V solar panel without a buck converter.","readingMinutes":4,"markdown":"I fried a Raspberry Pi Zero 2 W. A 10-volt solar panel running straight into the [Pi's 5 V USB](https://www.amazon.com/dp/B07XZ8BD6J) was not the move.\n\nWhile I waited on a new board and a buck converter, I rebuilt the site from scratch.\n\n## Old stack\n\n```\nNode.js (ARMv6 build)  \nPNPM  \nHono + hono/jsx  \nCloudflare Tunnel\n````\n\nEverything lived in one server file using `hono/jsx`. HTML, styles, scripts, and API data all came out of the same Hono route handlers:\n\n```ts\napp.get(\"/\", (c) => c.html(`<h1>Air Quality</h1>`));\n```\n\nIt worked, but the file was cramped: no hot reload without a full restart, no separation between UI and API, boilerplate for the smallest content tweak.\n\nMarkdown rendered server-side. The live-data API (air quality, device location) ran on the same routes serving HTML. Everything came out of port 3000 behind a Cloudflare Tunnel. When the Pi slept, the site slept. That was the point.\n\n## Rebuild\n\nI dropped Node and PNPM. [Bun](https://bun.sh/) became the runtime and package manager. [bhvr](https://bhvr.dev/) gave me a monorepo layout for Bun + Hono + React + Vite. The single-origin tunnel stayed: one server, one port, one pipe.\n\n## New stack\n\n```\nbhvr (Bun + Hono + Vite + React)  \nCloudflare Tunnel\n```\n\nThree workspaces:\n\n```\nclient/   → React + Vite frontend  \nserver/   → Hono API + static server  \nshared/   → TypeScript types used by both\n```\n\nThe React app builds separately, and its static files drop into `server/dist/client`. One Bun process on port 3000 serves both the frontend and the API.\n\n```mermaid\nflowchart LR\n  accTitle: One origin for the whole site\n  accDescr: A visitor reaches the Pi through a Cloudflare Tunnel to a single Bun server, which hands back the built React pages and answers API calls with Hono, both sharing the same types, and the API fetches air quality data.\n  V[Visitor] --> T[Cloudflare Tunnel]\n  T --> B[Bun server]\n  B --> R[React pages]\n  B --> H[Hono API]\n  H --> Q[Air quality]\n  S[Shared types] -.-> R\n  S -.-> H\n```\nThe pages and the API come from the same server at the same address, so there is one tunnel, one port, and no cross-site setup.\n\n## Rebuilding locally\n\n```bash\ngit clone https://github.com/iammatthias/feral-pure-internet.git\ncd feral-pure-internet\nbun install\n\nbun run build && bun run build:server\nmkdir -p server/dist/client\ncp -r client/dist/* server/dist/client/\n\nbun run server/dist/server/src/index.js\n```\n\nA static React site and Hono API are now live at [localhost:3000](http://localhost:3000/) and [localhost:3000/api/hello](http://localhost:3000/api/hello).\n\n## Pi deployment\n\nThis time, a proper 5 V buck converter stepped the solar panel's 10 V output down. No smoke.\n\n```bash\ncurl -fsSL https://bun.sh/install | bash\necho 'export PATH=\"$HOME/.bun/bin:$PATH\"' >> ~/.bashrc\nsource ~/.bashrc\n```\n\nClone to `/opt/feral/app`, run `bun install`, and build as above. Wrap it in `build.sh` for automation.\n\n## Systemd\n\n```ini\n# /etc/systemd/system/feral.service\n[Unit]\nDescription=Feral, single-origin Bun server\nAfter=network-online.target\n\n[Service]\nUser=feral\nWorkingDirectory=/opt/feral/app\nEnvironment=WAQI_TOKEN=•••\nExecStart=/home/feral/.bun/bin/bun run server/dist/server/src/index.js\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=multi-user.target\n```\n\n```bash\nsudo systemctl daemon-reload\nsudo systemctl enable --now feral\n```\n\nThis replaces PM2. It restarts on crash or reboot without \"save\" or \"startup\" steps.\n\n## Tunnel on a single port\n\n```yaml\n# /etc/cloudflared/config.yml\ntunnel: <UUID>\ncredentials-file: /etc/cloudflared/<UUID>.json\n\ningress:\n  - hostname: feral.pure---internet.com\n    service: http://localhost:3000\n  - service: http_status:404\n```\n\n```bash\ncloudflared tunnel create feral\ncloudflared tunnel route dns feral feral.pure---internet.com\nsudo systemctl enable --now cloudflared\n```\n\nUI and API both live at `https://feral.pure---internet.com`. No ports, no CORS, no dev/prod shims.\n\n## Update flow\n\n```bash\ncd /opt/feral/app\ngit pull --ff-only\nbun run build && bun run build:server\nmkdir -p server/dist/client\ncp -r client/dist/* server/dist/client/\nsudo systemctl restart feral\n```\n\nA post-merge hook or cron job will automate it. Takes under 5 seconds.\n\n## Before and after\n\n||Old|New|\n|---|---|---|\n|Runtime|Node v20 (ARMv6 build)|Bun|\n|Package mgr|PNPM|None (Bun native)|\n|Front-end|hono/jsx|React + Vite (via bhvr)|\n|Dev build|Rollup + tsc|Bun + Vite|\n|Deployment|PM2|systemd|\n|Architecture|Single file, no separation|client/server/shared|\n|Serving style|hono SSR + inline JSX|Static bundle + JSON API|\n|Origin setup|Single-origin|Single-origin|\n\nThe app is tiny, ephemeral, and still solar-powered. Rebuilding with **bhvr** added real structure: client and server actually separated, deploys and updates much simpler, lighter runtime (just Bun and a couple of systemd units), and no more crashing when I tweak a `<div>`.\n\nSingle-origin deployments suit bhvr well, especially when the whole stack fits in a few megabytes on a Pi Zero 2 W.","media":{},"series":{},"related":[{"title":"Pure Internet","href":"/posts/1731955749292-pure-internet","section":"posts","description":"Pure Internet is a running collection of small experiments in publishing with fewer layers.","publishedAt":"2024-11-18T10:49:00Z"},{"title":"Pure Internet: Feral","href":"/posts/1733357455673-pure-internet-feral","section":"posts","description":"A solar-powered Raspberry Pi runs a small Hono app through a Cloudflare Tunnel and disappears when the battery drains.","publishedAt":"2024-12-04T16:10:00Z"},{"title":"Now Playing","href":"/posts/1784736733544-now-playing","section":"posts","description":"Putting my Apple Music \"Now Playing\" on display with a Raspberry Pi and a 64x64 LED matrix.","publishedAt":"2026-07-25T17:18:48Z"},{"title":"Intern","href":"/posts/1781835006151-intern","section":"posts","description":"The Autonomous Intern ships as a Raspberry Pi 5 in a desk-toy case running a stack I didn't pick. I flashed it to Debian Trixie, swapped the whole thing for the Hermes agent with its own built-in memory, and folded it all into one setup script.","publishedAt":"2026-06-19T02:10:06Z"},{"title":"Adventures in Machine Vision","href":"/posts/1780360240000-adventures-in-machine-vision","section":"posts","description":"A Nat Friedman anecdote about an AI watching him drink water sent me digging for a spare Raspberry Pi. Now a camera and a sensor HAT watch my desk, a self-inflicted panopticon that logs who walks by and what the room is doing.","publishedAt":"2026-06-02T00:30:40Z"},{"title":"Pure Internet: office---space","href":"/posts/1736536967495-pure-internet-office-space","section":"posts","description":"A Pi Zero 2 W and Waveshare Environment HAT record my office conditions every 60 seconds.","publishedAt":"2025-01-10T11:22:00Z"}],"prev":{"title":"Breaking the AI in Parallax","href":"/posts/1746765173152-breaking-the-ai-in-parallax"},"next":{"title":"Take Your Network With You","href":"/posts/1754759911088-take-your-network-with-you"}}}