Why build it this way
Most websites are rendered on demand by a database somewhere in a data center that is always on. This one is a folder of files on a 512 MB computer the size of a credit card, sitting on my desk. It draws about as much power as an LED bulb and will, once the solar part exists, sometimes go offline when the sun does. The idea is borrowed from Low-tech Magazine's solar-powered website: make the material circumstances of the server part of the design instead of hiding them. The line at the bottom of every page is the machine talking about itself.
Three principles follow from that. Static: pages are generated once, on my laptop, and served as files. Small: no libraries in the browser, one typeface, pages measured in kilobytes, with the honest weight printed in the footer so I cannot pretend otherwise. Honest: nothing in this stack is faked or outsourced to a service that would make the numbers meaningless.
The machine
The server is a Xunlong Orange Pi Zero: an Allwinner H2+ with four 32-bit ARM cores, 512 MB of RAM, a microSD card for the operating system, one USB port, and no real-time clock, so it asks the network for the time every boot. It runs Armbian, a Debian-family Linux for these boards, with the CPU capped at 816 MHz because a static site never needs more and a cooler chip is a longer-lived chip. A 128 GB pendrive holds a staging copy of the site; what nginx actually serves lives on the SD card, so the pendrive can fail without the site noticing.
After stripping the default install of everything a web server does not need (the snap package system, automatic update timers, a second logging daemon, the TV-out and unused USB controllers) the box idles at about 63 MB of RAM used and 14 running services. With the two networking daemons described below it sits at roughly 115 MB. It has been measured, not guessed: every change on this box was recorded with a before and after.
The web server
nginx serves the files with three deliberate settings. Text files are compressed once at deploy time and served precompressed, so no CPU is spent on every request. Every response carries a cache lifetime: an hour for pages, a day for stylesheets, a month for images, a year for the fonts. And the server never announces its version. The whole site configuration fits on a screen:
map $sent_http_content_type $expires {
default off;
text/html 1h;
text/css 1d;
~image/ 30d;
font/woff2 1y;
}
server {
listen 80 default_server;
root /var/www/datta-site;
server_tokens off;
include /etc/nginx/mime.types;
types { font/woff2 woff2; }
gzip_static on; # serve the .gz twin written at deploy time
gzip on; # fall back to on-the-fly for anything else
gzip_http_version 1.0;
expires $expires;
open_file_cache max=256 inactive=5m;
access_log /var/log/nginx/access.log combined buffer=32k flush=5m;
location / { try_files $uri $uri/ =404; }
}
Reaching it without an address
The box has no public IP address and will have none on a mobile data connection either: carriers put phones and modems behind shared addresses that cannot receive incoming connections. So the site is published the other way round. A small daemon on the box, cloudflared, opens outbound connections to Cloudflare's network and keeps them open; visitors reach Cloudflare's edge, and the request travels back down that tunnel to nginx. No port is open, no address is needed, and the arrangement survives reboots and changes of network. The edge also keeps a copy of each page for the hour nginx declares, so a busy day costs the box one request per hour per edge location rather than one per visitor, which will matter when every byte rides a SIM card.
# /etc/cloudflared/config.yml on the box
protocol: http2 # measured lighter than QUIC on this CPU
ingress:
- hostname: dattasaurabh.work
service: http://127.0.0.1:80
- hostname: www.dattasaurabh.work
service: http://127.0.0.1:80
- service: http_status:404
Everything at the edge that would inject code into the pages is switched off: no script for email obfuscation, no "Rocket Loader", no analytics beacon. The HTML that leaves nginx is the HTML you receive. Administration goes over a separate private network, Tailscale, which lets me reach the box from anywhere, again without any open port; that is also the rescue path once the box is in a field.
Building and deploying
The site is built with Eleventy from markdown and a small set of templates. Design decisions live in a tokens file that compiles to CSS custom properties, so no color or size in the stylesheet is typed by hand. Case studies are markdown files in a folder next to the repository; a build step copies the ones marked public, strips anything marked internal, and lifts the first image out as the hero.
The built HTML and CSS are committed to git; the box pulls them. Fonts (licensed) and images (heavy) stay out of git and are copied once. On the box, one command does the rest:
# the core of /usr/local/bin/site-deploy
git pull --ff-only
rsync -a --delete --exclude "api/" "$SRC/_site/" "$DST/"
find "$DST" -path "$DST/api" -prune -o -type f \( -name '*.html' -o -name '*.css' \) -print0 \
| xargs -0 -r gzip -9kf
curl -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_PURGE_TOKEN" --data '{"purge_everything":true}'
Mirror to the SD card, write the compressed twins, tell the edge to forget the old pages. A deploy is fresh worldwide in seconds.
The line at the bottom
Every page ends with one sentence from the server. Half of it is fixed at build time: the page's weight, computed from the finished HTML and every file it references. The number includes the images at their real size, which is why it is embarrassing on some pages; that is the point, and it will fall by itself when the placeholder images are replaced.
// tools/page-weight.mjs, the essence
const htmlGz = zlib.gzipSync(html).length;
const imgs = [...html.matchAll(/<img[^>]+src="([^"]+)"/g)].map(m => m[1]);
const total = htmlRaw + cssBytes + fontBytes + imgs.reduce((a, u) => a + sizeOf(u), 0);
The other half is live. Every minute the box writes a small JSON file with its local time, uptime, CPU temperature, load, and a three-day sunshine forecast for its location, taken from the German weather service's open data through the BrightSky API. Fifteen lines of plain JavaScript in the footer fetch it and fill the line; without JavaScript the fixed half still reads correctly. The file is cached at the edge for sixty seconds, so a thousand readers a minute cost the box one request.
{"location":"Berlin","local_time":"07:25 CEST","uptime":"2 h 15 min",
"temp_c":41,"load":[0.19,0.07,0.04],"mem_used_mb":159,"power_w":null,
"sun":[{"date":"2026-09-04","hours":1.5,"kwh_m2":1.99},
{"date":"2026-09-05","hours":5.5,"kwh_m2":3.21},
{"date":"2026-09-06","hours":7.8,"kwh_m2":4.10}],
"weather_station":"Berlin-Tempelhof"}
The forecast is in sunshine hours rather than sky icons because sunshine hours are what a solar-powered server cares about; the JSON also carries the daily solar radiation, which will size the panel. The power_w field is null because there is no meter yet. When there is, the line will say how many watts the site is drawing as you read it.
Numbers so far
- Idle RAM after stripping: 63 MB of 484, down from 81 MB; 115 MB with the tunnel and the private network up.
- CPU capped from 1.3 GHz to 816 MHz with no measurable change in page delivery over the LAN (about 260 requests per second for the home page, limited by the 100 Mbit Ethernet, not the chip).
- Home page on the wire: 4.7 KB of compressed HTML; about 59 percent fewer bytes per page than before precompression and cache headers.
- The two daemons idle at about 34 MB and 40 MB of RAM and under three percent of one core; http2 was measured lighter than QUIC for the tunnel and stays.
- Watts: not yet measured. A meter is the next purchase, because the next decisions (panel size, battery size) depend on it.
What is next
The mobile connection: a data SIM on either a USB modem or a small LTE router, both of which work unchanged with the tunnel. Then the solar part: measure the real draw, size a panel and a battery for a Berlin winter rather than a Berlin summer, and add a current sensor so the footer can show watts and battery state, with a Low-tech Magazine style battery meter in the background of the page. And then the honest part: accept that the site will go offline on dark weeks, and say so here.
The operational record of all this (every change, every measurement, every mistake) lives with the site's source, so the next person to rebuild the box, most likely me after a lost SD card, can do it from the repository alone.