Skip to content

Guide

Home lab blog on a static website: Astro, Nginx Proxy Manager on a Raspberry Pi, served from Cloudflare's cache

Published · Updated · Discuss on Hacker News
Discuss on Lobsters

I wanted a low-maintenance, fast static website for the things I’ve learned in my home lab. I wanted to host it from my Raspberry Pi, but not risk it getting flooded by requests, especially if I post it on Hacker News, where you get a flood of requests. In the end Cloudflare seemed like a good choice to cache it, even though it still pulls from the Pi. It also keeps my Pi’s IP address somewhat private: the blog’s name doesn’t expose it. The Pi 5 already runs Home Assistant OS with Nginx Proxy Manager (NPM) in front of my apps, so the site is a folder of static files on the Pi: no CMS, no database, no CI runner. I write a post in Markdown on my laptop, and one command builds it, checks it for anything that would identify my family or my network, and swaps it into place. The visitor numbers come from Cloudflare too, so there’s no tracking script and no cookies.

A drawing: a laptop sends a page to a small green Raspberry Pi, an orange cloud above the Pi holds copies of the page, and phones and a laptop around the cloud get their pages from it

Diagram of the two paths. Publishing: on the laptop, Markdown goes through the Astro build and the privacy check; git push sends the source to Gitea as the backup; the build goes over SSH to Nginx Proxy Manager on the Pi, where the site folder is swapped in one step; then the script empties Cloudflare’s cache, fetches every file once to fill it again, and tells search engines through IndexNow. Reading: a browser asks a nearby Cloudflare location; a cached copy is answered there; only a miss goes to an upper-tier Cloudflare location and then through the router’s port 443 to Nginx Proxy Manager on the Pi

Both paths. Publishing touches the Pi once; reading almost never does.

Part Mine
A laptop with Node.js Node 24, Astro 7 with Starlight, Astro’s docs theme
A git server for the source Gitea on the same Pi. It’s the backup, and it goes into the nightly Home Assistant backup
A small always-on machine with nginx Raspberry Pi 5 with Home Assistant OS and the Nginx Proxy Manager app
SSH from the laptop to the Pi The Advanced SSH & Web Terminal app, with a key
One port forward TCP 443 to the Pi, the same one my client certificate setup uses
A domain on Cloudflare Free plan: DNS, the proxy and cache, and the analytics
A TLS certificate for the public names Let’s Encrypt by DNS challenge, made and renewed by NPM

A cartoon of a Raspberry Pi 5 seen from above at an angle, so its top and two sides show: a bright green board with gold pins along the back, chunky silver USB and Ethernet ports on one side, USB-C and micro HDMI on the other, and a smiling face with rosy cheeks on its main chip

My Pi 5, drawn. It holds the files for this site; Cloudflare answers almost every request for them.

Step 1: An Astro site with the Starlight theme

Section titled “Step 1: An Astro site with the Starlight theme”

Each site has its own git repo: one for the home page at jacq.cc, one for this blog, and one per small web app later, under jacq.cc/<app>/. I picked Astro because I want JavaScript components when a post needs one, and the output is still plain HTML. For the blog I added Starlight, Astro’s docs theme, because most posts are how-to guides: it brings a sidebar, a table of contents per page, copy buttons on code blocks, and search with Pagefind, a static index built with the site, so search needs no server either.

Terminal window
npm create astro@latest -- --template starlight
npm run dev # preview at http://localhost:4321

A post is a Markdown file, and its path is its URL: src/content/docs/guides/my-post.md becomes /guides/my-post/. There are two kinds: guides, which I keep up to date, and notes, written once. I extended Starlight’s front matter with a publish date and a list of changes, and replaced two of its components so each post shows Published and Updated dates under its title and its change history at the end:

src/content.config.ts
schema: docsSchema({
extend: ({ image }) => z.object({
cover: image().optional(), // the picture next to the post on the home page
coverAlt: z.string().optional(),
pubDate: z.coerce.date().optional(),
// one line per change a reader would care about; the newest date is "Updated"
changes: z.array(z.object({ date: z.coerce.date(), note: z.string() })).default([]),
}),
}),

The build (npm run build) writes everything to dist/: 202 files and 4.9 MB for this blog with this post, most of it images and the search index.

Commit a draft to a branch as soon as it exists. An untracked file is gone after a git clean, and I lost drafts that way on 16 October. If it does happen, Astro’s content cache in .astro/data-store.json still holds every post’s Markdown from the last build: copy it before the next one.

Step 2: A readable folder for NPM (Nginx Proxy Manager)

Section titled “Step 2: A readable folder for NPM (Nginx Proxy Manager)”

On Home Assistant OS, the NPM app runs in a container that mounts only /data, /config and /dev. My first thought was /share, the folder every app can usually see, but NPM doesn’t mount it, so nginx can’t read anything there. /config inside the container is /addon_configs/a0d7b954_nginxproxymanager/ on the host, so the sites live in a folder there:

/addon_configs/a0d7b954_nginxproxymanager/sites/ (= /config/sites/ inside NPM)
├── root/ → https://jacq.cc/
├── blog/ → https://blog.jacq.cc/
└── apps/
└── foo/ → https://jacq.cc/foo/

Each site has its own folder, so publishing one can never delete another. It also means the built sites are in NPM’s app folder, which the nightly backup already includes.

Step 3: Adding a proxy host that serves the files

Section titled “Step 3: Adding a proxy host that serves the files”

NPM only makes proxy hosts: it has no “static site” type. But it has one useful habit. Before it writes a host’s config, it checks the host’s own nginx config (the gear icon in the edit dialog, Custom Nginx Configuration; older versions call it the Advanced tab), and if that contains a location / { block, it leaves out its own location / proxy block (advancedConfigHasDefaultLocation in its source). Yours replaces it.

So the blog is an ordinary proxy host for blog.jacq.cc that forwards to http://127.0.0.1:80. That target is a placeholder NPM insists on, and nothing ever reaches it.

Nginx Proxy Manager’s Edit Proxy Host dialog, Details tab: domain name blog.jacq.cc, scheme http, forward hostname 127.0.0.1, port 80, access list Publicly Accessible, and the options Cache Assets, Block Common Exploits and Websockets Support all switched off

The blog’s host in NPM 2.16. The forward target is the placeholder; the three options are off.

The custom config does the real work:

# Relative redirects (Location: /foo/), so the scheme and host are never wrong behind a proxy
absolute_redirect off;
location / {
root /config/sites/blog;
try_files $uri $uri/ =404;
# Text files say they're UTF-8; nginx sends no charset by default
charset utf-8;
charset_types text/plain application/json application/xml text/xml application/rss+xml text/css application/javascript;
}
# Each post as Markdown (/guides/<slug>.md). nginx's mime.types has no .md,
# so without this it sends application/octet-stream and browsers download the file.
location ~ \.md$ {
root /config/sites/blog;
try_files $uri =404;
types { }
default_type "text/markdown; charset=utf-8";
}
# /api and /api/v1 answer straight away, without a redirect: agents probe both
location ~ ^/api/v1/?$ {
root /config/sites/blog;
try_files /api/v1/index.json =404;
charset utf-8;
charset_types application/json;
}
location = /api {
root /config/sites/blog;
try_files /api/index.html =404;
charset utf-8;
}

The same dialog on the gear tab, Custom Nginx Configuration: the absolute_redirect line, the location / block with root /config/sites/blog, the location block for .md files and the two blocks for /api, each with a comment

The same config where it goes, behind the gear icon.

I use root rather than alias, because alias together with try_files has known bugs. The rest of the host’s settings:

  • SSL: Force SSL, HTTP/2 and HSTS on. HSTS subdomains stays off: on jacq.cc it would force HTTPS on every name under it for two years.
  • Block Common Exploits off. It’s a list of URL patterns that protects nothing on a folder of static files, and it can block normal requests.
  • Cache Assets off. Cloudflare does the caching (Step 6).
  • Certificate: a Let’s Encrypt certificate just for jacq.cc, www.jacq.cc and blog.jacq.cc, made by DNS challenge. I already had a wildcard certificate, but it also names my internal-only subdomain, and I didn’t want the public sites handing that to everyone who looks.

The dialog’s SSL tab: the certificate for blog.jacq.cc, jacq.cc and www.jacq.cc, with Force SSL, HTTP/2 Support and HSTS Enabled switched on and HSTS Sub-domains off

The SSL tab: a certificate of its own, HSTS on, sub-domains off.

www.jacq.cc is an NPM redirection host: a 301 to https://jacq.cc with the path kept. The home page at jacq.cc is a second proxy host like this one, with root /config/sites/root; a web app later gets its own location /foo/ block in that host, with root /config/sites/apps.

Step 4: Allow public domain names in from the internet

Section titled “Step 4: Allow public domain names in from the internet”

My NPM has one global rule that gives every request from outside my networks no answer at all (nginx’s 444), whatever name it asks for. My private apps get through only with a client certificate; that rule and why it’s built that way are in the client certificate guide. For the public sites I added two lines that let them through without one:

# Key: outside (1 or 0) : the name asked for : the client certificate result
map "$jacq_outside:$host:$ssl_client_verify" $jacq_block {
default 0;
# ...the private names, each with :SUCCESS...
"~^1:jacq\.cc:" 0;
"~^1:blog\.jacq\.cc:" 0;
"~^1:" 1; # anything else from outside: no answer
}

www.jacq.cc needs no line: NPM’s redirection hosts don’t load the file that applies the rule.

In DNS, jacq.cc, www and blog are CNAMEs to my dynamic DNS name, which follows my home IP address. A CNAME on the bare domain isn’t allowed in plain DNS, but Cloudflare flattens it, and the mail records on jacq.cc (MX, SPF, DKIM, DMARC) didn’t change.

Terminal window
./deploy.sh

deploy.sh in the blog repo warns me if there are commits Gitea doesn’t have yet (Gitea is the backup), runs npm run build, then hands the dist/ folder to publish.sh, a script in my home lab repo that every site uses:

  1. Privacy check on exactly what’s about to go public. A 🔴 stops everything here. More on the check below.
  2. Copy and swap. tar streams the build over SSH. On the Pi it unpacks next to the live folder, gets owner root and read-only permissions, and two mvs swap it in. Files I deleted disappear with the old folder, and nobody ever gets a half-copied site.
  3. Empty Cloudflare’s cache. If this fails, the script stops with a 🔴, because Cloudflare would otherwise keep the old pages for up to a year.
  4. Warm the cache: fetch every file of the site once through Cloudflare, 8 at a time, so the first visitor or impatient bot gets a cached copy. On 15 October that was 89 files in about 2 seconds.
  5. IndexNow: one request tells Bing and the other IndexNow search engines about every page.

The swap, as it runs on the Pi (pi is a host entry in my SSH config):

Terminal window
tar -C dist -cz . | ssh pi '
set -e
d=/addon_configs/a0d7b954_nginxproxymanager/sites/blog
sudo rm -rf "$d.new" "$d.old"
sudo mkdir "$d.new"
sudo tar -xz -C "$d.new"
sudo chown -R root:root "$d.new"
sudo chmod -R u=rwX,go=rX "$d.new"
if [ -d "$d" ]; then sudo mv "$d" "$d.old"; fi
sudo mv "$d.new" "$d"
sudo rm -rf "$d.old"'

And the warm-up. It turns each index.html into the URL a visitor would use (guides/x/, not guides/x/index.html, which Cloudflare caches as a different entry):

Terminal window
cd dist && find . -type f | sed 's|^\./||; s#\(^\|/\)index\.html$#\1#; s| |%20|g' |
sed 's|^|https://blog.jacq.cc/|' |
xargs -P 8 -I{} curl -sS -o /dev/null --compressed -m 30 \
-w '%{http_code} %header{cf-cache-status} %{url}\n' {}

Nothing on these sites may identify my family, the house or my home network: no names, no suburb, no IP or MAC addresses, no internal hostnames, device names, serial numbers or Home Assistant entity IDs, no home-folder paths, and no photo metadata. A Bash script checks for all of that, and it runs in three places, so a slip has to get past it three times:

When Run by Checks
Every build the prebuild script in package.json src/ and public/
Every push a git pre-push hook the same folders, in each commit being pushed
Every publish publish.sh the finished dist/, which is what actually goes public

The text part is one grep with a list of private words that’s never published (names, places, device and network names, serials), plus patterns for IPv4 and MAC addresses, email addresses, any name under my domain other than the public ones, home paths, entity IDs, and the file names Inkscape writes into SVGs. Each IPv4 part must be 0 to 255, so the numbers in an SVG path don’t trip it. A false positive goes into an allow list as the exact string. For images it runs ImageMagick’s identify -verbose and fails on any EXIF, XMP, IPTC, GPS, comment or PNG text chunk. Any other binary, like a PDF, fails until I’ve checked it by hand.

What it can’t do is look at a photo. So I still look at every new one myself before it goes in, for faces, the house or street, screens, and stickers with serial numbers or Wi-Fi passwords. Photos get shrunk and stripped first:

Terminal window
convert in.jpg -auto-orient -resize '1600x1600>' -strip -quality 78 out.jpg

The Pi could serve this blog to a lot of people. My upload can’t: it’s about 45 Mbps, and a page with photos is 0.5 to 1 MB, so 6 to 12 new visitors a second, a Hacker News front page, would fill it and slow down everything else at home. So these three names, and only these three, go through Cloudflare’s proxy (the orange cloud in its DNS page). Cloudflare keeps a copy of each file, and the Pi sends each one about once per publish.

Setting Value Why
SSL/TLS mode Full (strict) Cloudflare checks the Pi’s real certificate. Flexible would talk to the Pi on port 80, which isn’t forwarded
Always Use HTTPS on Without it, Cloudflare fetches over HTTPS and hands the page to an http:// visitor unencrypted
Cache rule cache everything on the three names, edge TTL 1 year, browser TTL from the origin By default Cloudflare doesn’t cache HTML at all. My files only change when publish.sh runs, and that empties the cache
Tiered Cache, Smart topology on (free on every plan) A Cloudflare location without a copy asks an upper-tier location near the Pi, not the Pi. One warm-up from home fills it for everyone

The cache rule, as cloudflare.sh setup sends it to PUT /zones/<zone id>/rulesets/phases/http_request_cache_settings/entrypoint (it keeps any other rules in that list):

{
"ref": "jacq_static_sites_cache",
"description": "jacq.cc static sites: cache everything at the edge",
"expression": "(http.host in {\"jacq.cc\" \"www.jacq.cc\" \"blog.jacq.cc\"})",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": { "mode": "override_origin", "default": 31536000 },
"browser_ttl": { "mode": "respect_origin" }
}
}

The script uses an API token that can touch only this one zone: Zone Read, DNS Edit, Zone Settings Edit, Cache Rules Edit and Cache Purge. It has a read-only check mode, and a dns-only mode that turns the proxy off again. Here’s the blog’s home page on 26 October, a week after the last publish:

$ curl -sI https://blog.jacq.cc/
HTTP/2 200
content-type: text/html; charset=utf-8
server: cloudflare
last-modified: Mon, 19 Oct 2026 12:08:35 GMT
strict-transport-security: max-age=63072000; preload
age: 555218
cf-cache-status: HIT

age: 555218 is six and a half days: that copy hasn’t asked the Pi for anything since the warm-up.

Things to know:

  • Never proxy a name that uses client certificates or WireGuard. The proxy ends TLS at Cloudflare, so a client certificate never reaches NPM, and it can’t carry WireGuard at all. My private names stay DNS-only.
  • The home IP is only somewhat private. Looking up jacq.cc or blog.jacq.cc gives Cloudflare’s addresses, not mine. But the CNAMEs point at my dynamic DNS name, which has to point at the house, so anyone who finds that name can still go to NPM directly. Something for later: let NPM answer these names from outside only to Cloudflare’s IP ranges.
  • Visitors show up as Cloudflare’s addresses in NPM’s logs. The real one is in the CF-Connecting-IP header, which I don’t log.
  • Right after switching the proxy on, test through Cloudflare on purpose. My laptop’s resolver kept the old answer for up to 5 minutes, so my first checks went straight to the Pi. curl --resolve blog.jacq.cc:443:<a Cloudflare IP> takes the guessing out.
  • With my internet down, Cloudflare can keep serving cached pages for a while, then the sites are down. That’s fine for a blog.

Step 7: Search engines, feeds and AI agents

Section titled “Step 7: Search engines, feeds and AI agents”
  • Google reads the sitemap Starlight builds (/sitemap-index.xml), which I submitted in Search Console. Right after submitting, both sitemaps said “Couldn’t fetch”, which is what Search Console shows while a new sitemap is still pending. Don’t panic and resubmit: URL Inspection’s live test said the page was available to Google.
  • IndexNow covers Bing (and DuckDuckGo through it), Yandex, Seznam and Naver. publish.sh writes the key file into every build and then sends every page in one request to api.indexnow.org. The key is public by design: search engines check it by fetching that file.
  • RSS at /rss.xml, from @astrojs/rss.
  • LLMs and agents. All built from the posts’ Markdown, so nothing to do per post: /llms.txt lists every post, /llms-full.txt has all of them in one file, each post is also at its URL with .md on the end (this one: this page as Markdown), and there’s a small read-only JSON API at /api/ described by /openapi.json. Every page carries schema.org data, and robots.txt allows every crawler, AI crawlers by name, with content signals search=yes, ai-input=yes, ai-train=yes. It’s the most fetched of these files (Step 8).

Step 8: Visitor stats without a tracking script

Section titled “Step 8: Visitor stats without a tracking script”

I didn’t want a counter script on the pages or a stats app on the Pi. And NPM’s logs can’t count anything here: with a one-year cache, the Pi sees about one request per file per publish. But every request passes through Cloudflare’s proxy, so Cloudflare’s analytics see all of them, including the feed readers, crawlers and AI agents that never run JavaScript and wouldn’t show up in a script-based counter anyway.

The numbers come from Cloudflare’s GraphQL Analytics API, with a second API token that can only read, and only this zone. A Bash script, site-stats.sh, asks for the HTML pages answered with a 200, one UTC day at a time, and leaves out my own IP addresses (home, and wherever the laptop is right now). The addresses only go in the query as a filter: the script never prints or saves them.

query($z: string!, $a: Time!, $b: Time!, $ips: [string!]) {
viewer { zones(filter: {zoneTag: $z}) {
httpRequestsAdaptiveGroups(limit: 10000, filter: {
datetime_geq: $a, datetime_lt: $b, requestSource: "eyeball", clientIP_notin: $ips,
edgeResponseContentTypeName: "html", edgeResponseStatus: 200}) {
count
sum { visits }
dimensions { date clientRequestHTTPHost userAgent verifiedBotCategory }
}
} }
}

What the free plan gives you to query:

Dataset History Split by
httpRequestsAdaptiveGroups 31 days host, path, status, content type, country, browser, user agent, Cloudflare’s verified-bot category (AI Crawler, Search Engine Crawler, Feed Fetcher…), cache status, time to the minute
httpRequests1dGroups 1 year day only, all names in the zone together
httpRequests1hGroups 3 days hour, all names together

There’s no referrer on the free plan, so it can’t tell me how many readers came from Hacker News; HN’s own page shows points and comments.

Ask for one day at a time. One query over 30 days came back sampled at 1 in 10: counts in tens, and the 30-day count for llms.txt (110) was lower than the 14-day one (171). So the script asks for each UTC day on its own and adds them up. The counts are exact, and a day shows the same numbers however many days I ask for.

Who counts as a reader. Cloudflare’s verified-bot category wins when it has one. Otherwise the user agent decides: feed readers (FreshRSS, Miniflux), other bots and link previews (Mastodon, Lemmy, chat apps), scripts (curl, Python, headless Chrome), and readers: Chrome, Edge, Firefox or Safari no more than about six months old. The script works out the newest version from the date, because Chrome and Firefox ship a major version every four weeks:

Terminal window
# Chrome 131 came out on 2024-11-12; one major version every 4 weeks since
echo $(( 131 + ($(date -u +%s) - $(date -u -d 2024-11-12 +%s)) / (28 * 86400) ))

Anything older counts as “old or fake”. Scrapers love an old or made-up browser version. A real reader on a browser that hasn’t updated in months gets counted wrong, and that’s the trade-off.

Bar chart of reader page views per day on the blog from 11 to 25 October 2026 (UTC days): 212 on the 11th, when the first two guides went up and late that day onto Hacker News, 555 on the 12th, then mostly 20 to 70 a day, with 171 on the 16th (two new guides, one of them on Hacker News) and 85 on the 19th (one new guide, also on Hacker News)

Readers per day, from site-stats.sh report 30.

Horizontal bar chart of everything that fetched a page in the 30 days to 26 October 2026: old or fake browsers 7,442, other bots and link previews 2,432, feed readers 1,976, readers on recent browsers 1,618, unknown or no user agent 1,055, bots Cloudflare knows 1,007, scripts 706

Who fetched pages in the same 30 days. Readers are about a tenth of it.

Browsers with old or made-up versions fetched almost five times as many pages as readers did, and Mastodon’s link previews alone fetched 777. In those 15 days, 800 of the blog’s 1,550 reader page views were the GameSir T1d guide.

Files for search engines, feed readers and agents, in the same 30 days:

File Fetches Who asked most (Cloudflare’s bot category, or the user agent)
robots.txt 975 Search Engine Crawler 167, AI Crawler 111, Search Engine Optimization 109
rss.xml 683 Chrome 172, GuzzleHttp 164, Firefox 53
Markdown pages (.md) 498 Chrome 136, curl 135, Search Engine Crawler 52
API, OpenAPI, ai-plugin 375 curl 139, Chrome 111, Search Engine Optimization 27
Sitemap 213 AI Crawler 72, curl 50, Chrome 24
llms.txt, llms-full.txt 171 Chrome 53, curl 53, AI Crawler 13

The most fetched file is robots.txt, for the user-agent / bot category. Despite reports of it being ignored, it’s quite the opposite. Fetching it doesn’t mean they obey it, though. Mine allows everyone, so I can’t tell.

It’s going quite well. It’s very quick, and it doesn’t have downtime because it’s served from Cloudflare. Every time there’s a small spike from Hacker News, it serves it fine.

On a positive note, this site is open to user agents, AIs and LLM training. I figured that while I’m creating these articles and blog posts, most people these days will get their information through AI. Google usually gives you the result in its AI response, or people might use Claude chat, and people seldom find websites they don’t already know through search engines. So the only way I can share my learnings and these projects is by opening the site up to LLMs and user agents, so that hopefully other people looking for things I’ve done here can get that information and short-circuit some of the bits I had to figure out myself. That’s the least I can do. I guess it’s the lesser of two evils: unfortunately AI doesn’t really reference where it got its information from. I think Gemini’s responses on Google’s search pages do link to sources, and I’ve noticed that when I get Claude to help me with these posts, it adds sources, which is good. It’s always good to point back to where you actually got help and information from.

A Home Assistant dashboard for the static websites. The stats script will update it every hour and keep each finished day on the Pi, so the history outlives Cloudflare’s 31 days.

Questions or comments? Discuss on Hacker News.

Or discuss on Lobsters.

Change history

  • : First published.

This guide as plain Markdown, for LLMs and scripts: static-blog-raspberry-pi-nginx-proxy-manager-cloudflare.md. All posts: API and Markdown.

Text, photos and drawings: CC BY 4.0. Code: MIT. Share and adapt them, crediting HomeLab Guides (blog.jacq.cc). Licence and privacy