How This Site Is Built
This site is a static Hugo blog, hand-built rather than hosted on a managed platform, served from a small home server with no inbound ports opened on the router. Here’s the full stack, front to back.
The generator
The site is built with Hugo (extended edition, for the Sass/SCSS pipeline some themes need), using the PaperMod theme as a git submodule rather than a vendored copy — that keeps theme updates a simple git submodule update --remote away from the actual content. Site-specific look-and-feel changes (typography, spacing, color palette, small structural tweaks) live entirely in a layouts/ and assets/css/extended/ override directory alongside the theme, so nothing is hand-edited inside the theme’s own files. That’s Hugo’s standard override mechanism: anything you drop in the site’s own layouts/_partials/ shadows the theme’s version of that same partial, and any CSS files under assets/css/extended/ get concatenated in automatically after the theme’s core stylesheet — so a later rule with equal specificity wins the cascade without needing !important anywhere.
Content is plain Markdown with TOML front matter. Building the site is a single command, hugo --minify, which compiles everything down to plain HTML/CSS/JS in a public/ directory — no server-side runtime, no database, no PHP/Node process keeping anything alive.
Serving it
The built public/ directory is deployed to a directory managed by nginx, which serves it as static files on a private, LAN-only address. nginx here is about as simple as it gets — no reverse proxying, no app server behind it, just root pointed at a directory of pre-built files with a try_files fallback to a 404 page. There is deliberately no dynamic request handling anywhere in this chain; every page a visitor gets was already fully rendered at build time.
Getting it onto the public internet without opening a port
This is the part that differs most from a typical VPS-hosted blog. The server has no public IP and no ports forwarded through the home router/firewall — inbound access from the internet is entirely blocked at the network edge, by design. Instead, a Cloudflare Tunnel (cloudflared) runs as a background service on the box and makes an outbound connection to Cloudflare’s edge network. Cloudflare then proxies public requests for the domain through that tunnel back to the local nginx instance.
A few things fall out of that setup that are worth calling out:
- No open inbound ports, anywhere. The only traffic the server initiates is outbound to Cloudflare; nothing needs a hole punched in the firewall. If the tunnel process dies, the site simply becomes unreachable — there’s no fallback path that quietly exposes the box directly.
- TLS is terminated at Cloudflare’s edge, not on the origin server. The public-facing connection is HTTPS end to end (required for a
.appdomain, which is on the HSTS preload list), but the connection betweencloudflaredand nginx is plain HTTP over the tunnel itself — which is fine, since that leg never touches the open internet. Running a self-signed cert on the nginx side would add complexity for no real security benefit. - QUIC/HTTP3 is blocked on this network’s firewall, so
cloudflaredis explicitly pinned to HTTP/2 (--protocol http2) rather than letting it try to negotiate QUIC and fall back — that avoids a slow-start timeout on every connection attempt. - The tunnel is registered and its public hostname routing is configured entirely from Cloudflare’s dashboard (token-based auth for the local service), rather than a hand-maintained local
config.yml— one less file to keep in sync by hand.
Publishing workflow
Site source (content, theme submodule reference, layout/CSS overrides) lives in a private git repository. Publishing a new post is a two-step local loop: write the Markdown, then run a small shell script that rebuilds the site with Hugo and syncs the output to nginx’s web root. There’s no CI/CD pipeline wired up yet — deploys are triggered manually, on purpose, while the site is this simple. A self-hosted GitHub Actions runner (which would let a git push trigger the deploy automatically, still without opening any inbound ports, since the runner would poll GitHub rather than being polled) is the natural next step if this needs to scale beyond a single person publishing occasionally.
Why this shape
The whole point of the setup is to host something on hardware physically sitting on a home network, reachable from anywhere on the public internet over HTTPS, without trusting that network’s firewall to have zero mistakes in it. A static site generator means there’s no application server or database to patch and no attack surface beyond “serve files.” An outbound-only tunnel means there’s no listening service on the public internet to begin with — the server can’t be port-scanned because, from the internet’s point of view, it isn’t there. Anything that reaches the site has to go through Cloudflare’s edge first.
Doing this yourself
If you want to reproduce this setup on your own hardware, here’s the actual sequence, start to finish.
Provision the box. Any always-on Linux machine on your home network works — a NUC, a Raspberry Pi, an old laptop. Install a recent Ubuntu/Debian, keep it patched, and don’t bother opening anything in your router’s port forwarding — you won’t need it.
Install Hugo and scaffold the site.
sudo apt install hugo hugo new site my-site cd my-site git init git submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/PaperModSet
theme = "PaperMod"(or whatever theme you picked) inhugo.toml, add a post withhugo new content posts/first-post.md, and confirm it builds withhugo --minify.Install and configure nginx to serve the build output.
sudo apt install nginx-lightPoint a server block’s
rootat wherever you sync the builtpublic/directory (not inside your home directory unless you loosen its permissions — nginx runs aswww-dataand needs to be able to read it). A minimal config is justroot,index index.html, and atry_files $uri $uri/ =404;block. Have nginx listen on a private address/port — no need to expose it to0.0.0.0beyond your LAN.Create a Cloudflare Tunnel. You need a domain already on Cloudflare’s DNS. In the Zero Trust dashboard, create a tunnel, which gives you a connector token. Install
cloudflaredon the box and run it as a systemd service using that token — this is what makes the outbound-only connection back to Cloudflare, so nothing needs to listen publicly.Point the tunnel’s public hostname at nginx. In the tunnel’s dashboard config, add a public hostname rule: your domain →
http://<local-address>:<nginx-port>. Usehttp://, nothttps://— Cloudflare’s edge already terminates TLS for the public connection, so the local leg over the tunnel doesn’t need its own certificate.If your network blocks UDP/QUIC (common on stricter firewalls), force
cloudflaredonto HTTP/2 instead of letting it try QUIC first and time out: run it with--protocol http2.Write a one-command publish script. Something like: build with Hugo (
--cleanDestinationDir, so deleted posts don’t linger as stale files),rsync --deletethe output to nginx’s web root, fix ownership. Run it by hand after writing a post, or wire it to a self-hosted GitHub Actions runner later if you wantgit pushto deploy automatically.Keep the source in git, theme as a submodule, built output (
public/) gitignored — the build artifact doesn’t belong in version control, only the inputs to it do.
That’s the whole stack — no cloud hosting bill, no exposed attack surface, and the only thing keeping the site up is the box staying on and cloudflared staying connected.