A one-page site that shows a Raspberry Pi's vitals. Caddy serves the static files on localhost and a Cloudflare Tunnel publishes them, so there are no open ports and no port forwarding.
Live at https://pi.deepakness.com
- Right now — visitors, uptime, CPU, memory and temperature, plus an hour of CPU and network history.
- Still running — availability over the last 14 days, drawn from the collector's own sample history.
- The home connection — download, upload and latency from a scheduled speed test.
- The whole machine — board, CPU, RAM, storage, OS, network, power draw and location.
- People who have seen this — concurrent visitors and total page views.
- The actual hardware — photos of the board and the setup.
- A Raspberry Pi (this runs on a 4B) with Raspberry Pi OS or Debian 13, 64-bit.
- Python 3.11 or newer. Standard library only, so there is nothing to pip install.
- Caddy v2. It ships as a single static binary and is not in this repo, so you download it yourself.
- cloudflared, a Cloudflare account and a domain you can add a hostname to.
- systemd.
curlon the PATH.
The Pi-specific parts are vcgencmd for the throttled flag,
/sys/class/thermal for temperature and /proc/net/wireless for WiFi signal.
Everything else is generic Linux. The collector follows whichever interface
holds the default route, so WiFi and Ethernet both work.
browser
|
v
Cloudflare edge --> Cloudflare Tunnel --> cloudflared
|
v
Caddy on 127.0.0.1:8080
|
v
site/ (static files)
collector.py
every 10 s --> /dev/shm/piserver/stats.json (RAM, served at /stats.json)
tails --> Caddy's JSON access log (RAM, rotated at 4 MiB)
every 60 s --> site/hits.json, site/uptime.json
--> state/ (internal counters)
the page
fires one --> /hit (never cached, Caddy answers 204)
Nothing writes to the SD card on a timer. The two files that change constantly
live in /dev/shm, which is tmpfs. The beacon to /hit is what makes a page view
countable at all, since the page itself is answered from the edge cache.
bin/ caddy static binary (downloaded, not in git)
caddy/ Caddyfile
collector/ collector.py live stats, hits, uptime, access-log parsing
speedtest_run.py speed test via curl against Cloudflare
site/ web root: index.html, assets/, robots.txt, sitemap.xml
hits.json, uptime.json, speedtest.json (generated, not in git)
state/ hits-state.json, uptime-state.json, both with a .bak (not in git)
units/ systemd units
watchdog/ watchdog.sh, restarts the tunnel when it wedges
LICENSE MIT
AGENTS.md conventions for contributors and AI coding agents
The systemd units refer to %h/piserver, which systemd expands to the user's
home directory, so the repo is expected at ~/piserver. Clone it anywhere else
and you have to edit every unit.
git clone https://github.com/deepakness/piserver.git ~/piserver
cd ~/piserverbin/ is not in git, so this step is not optional.
mkdir -p ~/piserver/bin
cd /tmp
curl -LO https://github.com/caddyserver/caddy/releases/download/v2.11.4/caddy_2.11.4_linux_arm64.tar.gz
tar xzf caddy_2.11.4_linux_arm64.tar.gz caddy
mv caddy ~/piserver/bin/caddy
chmod +x ~/piserver/bin/caddy
~/piserver/bin/caddy versionSwap arm64 for armv7 if you are on 32-bit Raspberry Pi OS. Check the
Caddy releases page for the
current version.
The Caddyfile uses relative site roots, so Caddy has to start with the repo root
as its working directory. The systemd unit sets this for you; when running by
hand, cd first.
cd ~/piserver
./bin/caddy run --config caddy/CaddyfileIn another shell:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/You want 200. Then stop Caddy with Ctrl-C. The page will look empty of live
numbers at this point, because the collector is not running yet.
Install cloudflared:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflaredLog in, create a tunnel, and store its token where the unit expects it:
cloudflared tunnel login
cloudflared tunnel create piserver
sudo install -d -m 700 /etc/cloudflared
sudo cloudflared tunnel token piserver | sudo tee /etc/cloudflared/token >/dev/null
sudo chmod 600 /etc/cloudflared/tokenNow point the tunnel at Caddy. In the Cloudflare dashboard go to Zero Trust →
Networks → Tunnels, pick your tunnel, and add a public hostname with the
service http://127.0.0.1:8080.
With a token-based tunnel the ingress rules live in the dashboard, not in a
local config.yml. That is why this repo has no tunnel config in it.
Then start the tunnel:
sudo systemctl enable --now cloudflaredLingering is what keeps user services running with no login session and brings them back after a reboot. Without it, everything dies when you log out.
sudo loginctl enable-linger "$USER"
mkdir -p ~/.config/systemd/user
ln -sf ~/piserver/units/piserver-caddy.service ~/.config/systemd/user/
ln -sf ~/piserver/units/piserver-collector.service ~/.config/systemd/user/
ln -sf ~/piserver/units/piserver-speedtest.service ~/.config/systemd/user/
ln -sf ~/piserver/units/piserver-speedtest.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now piserver-caddy piserver-collector piserver-speedtest.timerThe unit files are symlinked rather than copied, so edits in the repo take effect on the next restart without reinstalling anything.
cloudflared is the only process here facing the internet, and left alone its Go heap grows and stays grown. This drop-in gives it three layers.
sudo install -d /etc/systemd/system/cloudflared.service.d
sudo install -m 644 ~/piserver/units/cloudflared-memory.conf \
/etc/systemd/system/cloudflared.service.d/20-memory.conf
sudo systemctl daemon-reload
sudo systemctl restart cloudflaredThe comments in that file explain why MemoryMax is deliberately set well above
GOMEMLIMIT. Short version: a limit that is actually reached is worse than no
limit, because the process stops making progress while systemd still reports the
unit as healthy.
The failure this guards against is the nasty kind, where every unit reports as active and the site is still down. It restarts cloudflared if the process is over a memory mark, or if your public URL fails to answer twice in a row.
sudo install -m 644 ~/piserver/units/piserver-watchdog.service /etc/systemd/system/
sudo install -m 644 ~/piserver/units/piserver-watchdog.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now piserver-watchdog.timerIt runs as root because the thing it repairs is a system service. It checks twice before acting, because the check itself makes an outbound request, and it refuses to restart anything when the Pi has no internet, since that would just loop.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ # 200
curl -s -o /dev/null -w '%{http_code}\n' https://your-hostname/ # 200
systemctl --user is-active piserver-caddy piserver-collector piserver-speedtest.timer
systemctl is-active cloudflared piserver-watchdog.timerEverything you may want to change, and where it lives.
| What | Where |
|---|---|
| Public URL the watchdog probes | PISERVER_URL env var, or the default in watchdog/watchdog.sh |
| Memory mark that triggers a tunnel restart | PISERVER_MEM_LIMIT_MB env var, default 250 |
| Which services the page reports on | the SERVICES list in collector/collector.py |
| Sampling and flush intervals | the constants near the top of collector/collector.py |
| Speed test schedule | units/piserver-speedtest.timer |
| Site title, description, and the location line | site/index.html |
| Your domain | site/index.html, site/robots.txt, site/sitemap.xml |
PISERVER_URL and PISERVER_MEM_LIMIT_MB are read from the environment, so you
can set them without touching the script by adding to the watchdog unit:
[Service]
Environment=PISERVER_URL=https://pi.example.com
Environment=PISERVER_MEM_LIMIT_MB=300stats.json lives in /dev/shm, so the 10-second writes never reach the SD
card. It is served at /stats.json with Cache-Control: no-store.
The access log is also in RAM, at /dev/shm/piserver/access.log. Caddy rotates
it and the collector only reads it. Never truncate that file from the collector
or a script. Caddy keeps writing at its own position, so emptying it leaves a
sparse file of enormous apparent size with a few real lines at the tail, which
overruns the cap and is emptied again on the next tick, forever, while nothing is
ever read and the counters sit frozen. The rotation settings are roll_size 4MiB,
roll_keep 2 and roll_keep_for 1h. If the reader ever falls more than 4 MiB
behind, it skips to the tail rather than re-reading entries it would double-count.
A page view is counted from a beacon rather than from the page request. The HTML
is answered from the edge cache, so a request for / often never reaches the Pi,
and counting those advanced the total about once per edge location per minute
whatever the audience. Instead beacon.js fires one request to /hit on every
load, and that route is answered 204 with no-store, so it is never cached and
always arrives. Counting the beacon is also stricter than counting page requests
was, because a prefetch, an unfurler or a crawler fetching HTML never runs the
script and so never counts. A substring list in collector.py filters obvious
bots on top of that, and the collector counts how many it turned away so you can
sanity check the ratio.
Concurrent visitors are identified by a random id the page keeps in local storage
and sends as X-Pi-Device on the polls the collector already counts. Counting by
address alone merged two devices on one WiFi into one visitor, and split one
device that moved between networks. The id is client-supplied, so treat it as a
hint rather than identity. It is hashed with a salt generated fresh in each
collector process and never written anywhere, the namespaced keys stay in memory
for a three-minute window, and the address is the fallback for any browser that
keeps no storage. Only the count is published, so neither an id nor an address is
ever stored or served.
The counters flush to three files every 60 seconds. site/hits.json is public,
state/hits-state.json is internal, and there is a .bak beside it. On startup
the collector loads the state, falls back to the backup if it is unreadable, and
only rebuilds from the published totals if both are gone. That means deleting
the state file cannot zero the count.
uptime-state.json keeps 400 days of samples and uptime.json publishes the
last 30, so the payload stays small.
The speed test pulls 4x10 MB down and pushes 4x2.5 MB up, about 50 MB a run, four times a day (00:00, 06:00, 12:00 and 18:00, with a five minute random delay). It saturates your link for about ten seconds. A run missed while the Pi is off is caught up at boot.
Only stats.json, hits.json, uptime.json and speedtest.json are public.
Everything in state/ stays internal and is not in git.
/assets/* is served immutable and cached by the browser and the edge for a
year, so a changed asset keeps being served from its old URL.
Version a changed script by filename, not by ?v=. app.js became
app-23.js this way: a new path is a new cache entry, so it is picked up at once
with nothing to purge, whereas a new ?v= value is the same path and still needs
the old copy invalidated. app.js is kept only because browsers holding the old
cached HTML still request it; a fresh deployment does not need it.
The images and style.css still use ?v= and that is fine. One caveat if you
use it: a cache rule can reject a query string it does not recognise with a 403.
An asset behind that 403 takes the page's live numbers down with it, because the
script never runs. Either add the new value to the rule before you deploy, or
rename the file.
The photos are 3:2, or 4:3 below 620 px, and are cropped to that ratio before encoding so the browser never crops them again. Each has a 450w and a 900w WebP for the page, and a 2200w WebP for the lightbox.
There is no build step. Caddy serves the files as they are on disk.
# after a Caddyfile or site change
systemctl --user restart piserver-caddy
# after a collector change
systemctl --user restart piserver-collectorVerify after either one:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/systemctl --user status piserver-caddy piserver-collector
journalctl --user-unit piserver-caddy -f
journalctl --user-unit piserver-collector -f
journalctl -u cloudflared -f
journalctl -u piserver-watchdog -f
# what the collector last wrote
cat /dev/shm/piserver/stats.json
cat state/hits-state.jsonTwo things that catch people out.
journalctl --user -u <unit> finds nothing. Use --user-unit, as above. That
is a systemd quirk, not a problem with your install.
If the numbers on the page are frozen but the Pi is fine, check whether the
collector is running at all, and check that Caddy can write to
/dev/shm/piserver. Both units create that directory in ExecStartPre.
- Caddy binds to
127.0.0.1only, andadmin offdisables its config API. The tunnel dials out, so no inbound port is open and nothing needs port forwarding. - The tunnel token stays in
/etc/cloudflared/token, readable only by root. It is never in this repo. - cloudflared builds a local metrics listener on
127.0.0.1:20241by default, and that listener also serves/debug/pprof/. If you do not use it, turn it off by adding--management-diagnostics=falseto itsExecStart, and consider sandboxing it withNoNewPrivileges, an emptyCapabilityBoundingSetandProtectSystem=strict, since it does not need any of those capabilities to serve one tunnel. - The collector never writes a visitor address to disk, and no address or filesystem path is ever published.
MIT.
Issues and pull requests are welcome at github.com/deepakness/piserver.