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

> The whole path of a post on this site, from a Markdown file on my laptop to your browser. An Astro build, a privacy check that can stop it, one script that swaps the files into place on a Raspberry Pi, Nginx Proxy Manager serving them as plain files, Cloudflare caching every file for a year, and visitor numbers from Cloudflare's own analytics instead of a tracking script.

Guide · Published 2026-10-26 · Updated 2026-10-26  
Web page: https://blog.jacq.cc/guides/static-blog-raspberry-pi-nginx-proxy-manager-cloudflare/  
Discuss on Hacker News: https://news.ycombinator.com/item?id=50237681  
Discuss on Lobsters: https://lobste.rs/s/nnfgam

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.

[Image: 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]

:::note[Status, October 2026]
Live since 8 October (the home page at `jacq.cc`) and 11 October (the blog's first posts, and Cloudflare in front of both). Visitor stats since 16 October, and since 26 October every hour on a Home Assistant dashboard, kept past Cloudflare's 31 days: [Blog visitor stats in Home Assistant](https://blog.jacq.cc/guides/cloudflare-visitor-stats-home-assistant/). The home IP is only somewhat private: see [Step 6](#step-6-cache-everything-with-cloudflare).
:::

[Image: 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.*

## What you need

| 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](https://blog.jacq.cc/guides/client-certificates-home-assistant-vaultwarden-gitea/) 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 |

[Image: 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

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](https://docs.astro.build) because I want JavaScript components when a post needs one, and the output is still plain HTML. For the blog I added [Starlight](https://starlight.astro.build), 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](https://pagefind.app), a static index built with the site, so search needs no server either.

```bash
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:

```ts
// 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)

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:

```text
/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

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](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/develop/backend/internal/nginx.js)). 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.

[Image: 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:

```nginx
# 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;
}
```

[Image: 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.

[Image: 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

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](https://blog.jacq.cc/guides/client-certificates-home-assistant-vaultwarden-gitea/#step-4-one-rule-for-every-host). For the public sites I added two lines that let them through without one:

```nginx
# 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.

## Step 5: One command publish (unattended)

```bash
./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 `mv`s 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):

```bash
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):

```bash
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' {}
```

### Privacy checks

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:

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

## Step 6: Cache everything with Cloudflare

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):

```json
{
  "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:

```text
$ 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](https://www.cloudflare.com/ips/).
- **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

- **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`](https://llmstxt.org) 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](https://blog.jacq.cc/guides/static-blog-raspberry-pi-nginx-proxy-manager-cloudflare.md)), and there's a small read-only JSON API at [`/api/`](https://blog.jacq.cc/api/) described by `/openapi.json`. Every page carries schema.org data, and `robots.txt` allows every crawler, AI crawlers by name, with [content signals](https://contentsignals.org) `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)).

## 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](https://developers.cloudflare.com/analytics/graphql-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 day at a time (midnight to midnight in my time zone), 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.

```graphql
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 { 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 day on its own and adds them up. Until 26 October those were UTC days, so my "today" started at 10 am; now `$a` and `$b` are midnight to midnight in my time zone. 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:

```bash
# 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.

[Image: 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`.*

[Image: 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](https://blog.jacq.cc/guides/gamesir-t1d-linux-gamepad/).

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.

## How it turned out

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.

## What next

Done on 26 October: a Home Assistant dashboard for the static websites. The stats script updates it every hour and keeps each finished day on the Pi, so the history outlives Cloudflare's 31 days. How it's set up: [Blog visitor stats in Home Assistant](https://blog.jacq.cc/guides/cloudflare-visitor-stats-home-assistant/).

## Sources

- [Astro](https://docs.astro.build), [Starlight](https://starlight.astro.build) and [Pagefind](https://pagefind.app)
- nginx [core module](https://nginx.org/en/docs/http/ngx_http_core_module.html): `location`, `root`, `try_files`, `absolute_redirect`, `types`, `default_type`
- Nginx Proxy Manager: [`internal/nginx.js`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/develop/backend/internal/nginx.js) (`advancedConfigHasDefaultLocation`)
- Cloudflare: [Cache Rules](https://developers.cloudflare.com/cache/how-to/cache-rules/), [Tiered Cache](https://developers.cloudflare.com/cache/how-to/tiered-cache/), [Full (strict)](https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/full-strict/), [purge everything](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-everything/), [CNAME flattening](https://developers.cloudflare.com/dns/cname-flattening/), [HTTP headers](https://developers.cloudflare.com/fundamentals/reference/http-headers/) (`CF-Connecting-IP`), [GraphQL Analytics API](https://developers.cloudflare.com/analytics/graphql-api/), [IP ranges](https://www.cloudflare.com/ips/)
- [IndexNow documentation](https://www.indexnow.org/documentation), [llms.txt](https://llmstxt.org), [content signals](https://contentsignals.org)

## Change history

- 2026-10-26: Step 8: the stats script now counts local days, midnight to midnight, instead of UTC days.
- 2026-10-26: The visitor stats now run every hour in Home Assistant and are kept past Cloudflare's 31 days, in their own guide.
- 2026-10-26: First published.

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Credit "HomeLab Guides (blog.jacq.cc)" with a link to https://blog.jacq.cc/guides/static-blog-raspberry-pi-nginx-proxy-manager-cloudflare/. Code downloads: MIT. Details: https://blog.jacq.cc/licence/
