Skip to content
deepaknessPublic

About

Live status page for a 1 GB Raspberry Pi 4B, served by Caddy and published through a Cloudflare Tunnel.

Topics

Resources

Stars

13 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

piserver

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

What the page shows

  • 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.

Requirements

  • 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.
  • curl on 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.

How it fits together

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.

Layout

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

Setup

1. Clone it to ~/piserver

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 ~/piserver

2. Download the Caddy binary

bin/ 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 version

Swap arm64 for armv7 if you are on 32-bit Raspberry Pi OS. Check the Caddy releases page for the current version.

3. Run it by hand first

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/Caddyfile

In 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.

4. Cloudflare Tunnel

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 cloudflared

Log 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/token

Now 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 cloudflared

5. Run the site and the collector as user services

Lingering 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.timer

The unit files are symlinked rather than copied, so edits in the repo take effect on the next restart without reinstalling anything.

6. Memory limits for the tunnel

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 cloudflared

The 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.

7. The watchdog

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.timer

It 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.

8. Verify

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.timer

Configuration

Everything 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=300

Data

stats.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

/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.

Making changes and deploying

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-collector

Verify after either one:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/

Troubleshooting

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.json

Two 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.

Security notes

  • Caddy binds to 127.0.0.1 only, and admin off disables 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:20241 by default, and that listener also serves /debug/pprof/. If you do not use it, turn it off by adding --management-diagnostics=false to its ExecStart, and consider sandboxing it with NoNewPrivileges, an empty CapabilityBoundingSet and ProtectSystem=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.

Licence

MIT.

Issues and pull requests are welcome at github.com/deepakness/piserver.

About

Live status page for a 1 GB Raspberry Pi 4B, served by Caddy and published through a Cloudflare Tunnel.

Topics

Resources

Stars

13 stars

Watchers

0 watching

Forks

Contributors

Languages