返回资讯中心
外部精选
软件工程
#开发者实时流

Setup Home Lab Static Website: Rasp Pi, Astro, Nginx Proxy Mgmt, Cloudflare

Published 26 Oct 2026 · Updated 26 Oct 2026 · Discuss on Hacker News

Hacker News 最新xx__yy2026/10/26 · 10 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

Published 26 Oct 2026 · Updated 26 Oct 2026 · Discuss on Hacker News

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.

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. In progress: a Home Assistant dashboard for the stats, so they’re kept past Cloudflare’s 31 days (What next). The home IP is only somewhat private: see Step 6.

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

Section titled “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 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

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

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.

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:

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([]),

}),

}),

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.

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.

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.

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

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;

}

.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 bothlocation ~ ^/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 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:

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.

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.

Section titled “Step 5: One command publish (unattended)”

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:

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

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

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

Section titled “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:

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:

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

' -strip -quality 78 out.jpg">

Step 6: Cache everything with Cloudflare

Section titled “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 ora

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →