Serving a Website from a 1 GB Pi 4B on Home WiFi
I have a Raspberry Pi 4B at home on WiFi, and it serves a public website. It is a Rev 1.5 board with 4x Cortex-A72 at 1.8 GHz, 1 GB of RAM, a 64 GB microSD card and Debian 13 (trixie). Nothing is forwarded on my router, no ports are open, and my home IP address is never published.
The page is live at pi.deepakness.com, and it is about the machine serving it. You get live readings for CPU, memory, temperature, disk, network and WiFi signal, an hour of history, an availability record with the state of every service, the home connection measured four times a day, and how many people are reading right now.
The path from the internet to the Pi
- The Pi sits on my home WiFi as a normal client.
- Caddy serves the site files on
127.0.0.1:8080, which exists only inside the Pi. - A Cloudflare Tunnel dials out to Cloudflare and holds that connection open.
- Cloudflare publishes the local address as a subdomain of mine, with TLS handled at the edge.
Caddy listens on localhost only, so nothing on my network can reach the site directly, and the tunnel is the only way in. There is no port forwarding on the router, no static IP, and no dynamic DNS record to keep updated.
I had the tunnel pattern written down earlier when I was looking at ways to publish local apps, and the feed reader that runs on this Pi uses the same route.
Getting in without opening SSH
Tailscale is the other half of this setup. The Pi is on my tailnet, which is a private network between my own devices, and it is how I open a shell on the Pi or reach anything else at home from anywhere. It runs as a system service and is not in the serving path at all. The website would work without it, and the admin access would work without the tunnel.
Port 22 is never exposed to the internet, and I still get a terminal on the Pi from basically anywhere.
What Caddy does
The config is mostly security headers and caching. These are the parts that matter, minus the headers:
:8080 {
bind 127.0.0.1
@page path / /index.html
handle @page {
header Cache-Control "public, max-age=0, s-maxage=60"
file_server
}
@live path /hits.json /uptime.json /speedtest.json
handle @live {
header Cache-Control "public, max-age=0, s-maxage=15"
file_server
}
handle /hit {
respond 204
}
}
The page is cached at the edge for 60 seconds and the data files for 15, which is just longer than the collector's sampling interval. The numbers stay seconds behind the Pi without the Pi answering every poll from every open tab, and the page's own polls still carry no-store, so those do come here.
/hit is one request per page load, answered with an empty 204 and never cached. That is how page views are counted. Counting them from the access log stopped working once the page was cached at the edge, since the Pi only saw cache fills and the counter advanced about once per edge location per minute, whatever the size of the audience.
That caching is what got it through the afternoon it was shared. Four IP addresses and later much more fetched the page in a loop, the combined peak reached 1,028 requests per second, and the first version of this setup crashed badly.
However, the same board then took 91 million requests in around 15 hours, with 85 million stopped at Cloudflare and 2.76 million served by origin, and it still stayed up.
What keeps it running
Caddy, the collector and the speed test run as systemd user services, symlinked into ~/.config/systemd/user/ from the project. And changes are committed in the repo on the Pi, then the affected unit restarts.
Each service has a memory cap, because memory is the thing that actually runs out on a 1 GB board:
MemoryMax=256M
MemoryHigh=192M
Environment=GOMEMLIMIT=192MiB
Here, Caddy and cloudflared are both Go, and Go holds on to its heap after a burst instead of giving it back. The GOMEMLIMIT above is the setting doing the real work, since the runtime then keeps itself under the soft cap instead of borrowing memory the Pi does not have.
A watchdog covers what I cannot. A two minute timer probes the public URL and reads the tunnel's memory, and restarts the tunnel if two probes fail or if it crosses 250 MB. It has restarted it a few times, mostly during the attack. A five second restart is cheaper than an outage nobody is watching.
The collector
Everything on the page comes from one Python file with no dependencies outside the standard library. It reads /proc and /sys every 10 seconds.
def meminfo():
out = {}
for line in rfile("/proc/meminfo").splitlines():
key, _, rest = line.partition(":")
out[key] = int(rest.split()[0])
return out
The JSON goes into /dev/shm, which is RAM, because a file every 10 seconds is over 3 million writes a year and the SD card is the first part of a Pi to fail. The counters are the only thing that touches the card, flushed once a minute with a .bak beside them, and every write goes through os.replace so a reader never sees a half-written file.
The bug worth reading this post for: The collector used to empty the access log once it passed a size cap. Caddy holds that file open and keeps its own position in it, so the next write landed past the new end – a sparse file, enormous in size and nearly empty in content, over the cap again on the next tick, emptied again ten seconds later, forever. Visitors read 1 while the site was fast, the CPU was idle and every service reported active. But now, Caddy rotates the log itself, and the collector only reads it.
The counters: Page views come from the page's own /hit request, and readers are counted per device – an open tab sends a random id it keeps in localStorage on the JSON polls it already makes, so two devices on one WiFi count as two. What reaches the collector is a salted hash, held in memory for three minutes, with an address as the fallback for a browser that keeps no storage. And nothing is written to disk.
Response times and the speed test: Response times come from the access log. The speed test runs four times a day against Cloudflare's edge, since Ookla's own CLI does not work here, so the script uses curl with a browser user agent.
A weak power supply: The collector also reads vcgencmd get_throttled for undervoltage and throttling flags, so a weak charger shows up on the page.
The live page itself
Four files with no framework and no build step. index.html, style.css, app.js and a one line beacon.js that reports the page load come to about 14 KB gzipped in total.
The icons are one inline SVG sprite with 26 symbols, so a page with this many small labels still makes no extra requests. By the way, I printed the numbers in full with separators, so a visitor count reads 1,247 rather than 1.2k.
There are no cookies and no third-party requests, and a default-src 'self' policy keeps it that way. The one identifier anywhere is the random device id above, and it never leaves the browser except as a salted hash in memory. For my other sites I use the analytics setup I run on a VPS, and this page needs none of that.
The WiFi link, measured
The Pi is a client on my network rather than a server, so the WiFi link is the slowest part of the setup.
- The link runs on 5 GHz, channel 36, at a reported 270 Mbit/s, but the real throughput measures around 43 Mbps against Cloudflare's edge.
- Upload matters more than download here, because the visitors pull the page from my house, so the upload side of the line is what everyone shares.
- Power save can add latency, and NetworkManager reports it as
defaulton this setup, and it is the first setting I would check if SSH felt sluggish.
What it costs to run
- Between 3-5 W of electricity.
- Around 250 MB of the 906 MB of RAM the OS can see, with the web server, the collector and the tunnel running.
- Under 2% CPU while idle, and about a millisecond for Caddy to hand over the page at the median.
- 43 Mbps of a 200 Mbps fiber line, which is the WiFi link rather than the board.
By the way, could a Zero 2 W do the same job?
What I learned
- Memory is what fills first: Caddy and cloudflared both kept growing under load, on a board with 906 MB. The caps are what stop that from ending in an outage.
- Caching made the biggest difference: The page is answered at the edge now, so the Pi stopped handing the same bytes to every visitor, and the size of the audience stopped mattering as much.
- The WiFi link is the slowest part: Slower than the board and slower than the line it sits on.
- The SD card fails first: Keeping the writes in RAM is the one habit I would keep from this project.
- Counting readers without collecting anything was the hard half: Reading
/procis a solved problem.
The page is live at pi.deepakness.com, and it updates every 10 seconds. The Caddyfile, the collector, the units, and the watchdog are on GitHub in a public repo.
Overall, it was a super fun project and I loved every bit of it.