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.

Browserpublic internetCloudflareedge · TLS terminates herecloudflaredoutbound tunnel onlynginxhome server · no open portsserves prebuilt static filesHTTPSover tunnelplain HTTP(LAN-only leg)cloudflared holds the connection open outbound — nginx never listens publicly

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 .app domain, which is on the HSTS preload list), but the connection between cloudflared and 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 cloudflared is 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.

Write postMarkdown + TOMLhugo --minify--cleanDestinationDirrsync --deleteto nginx web rootLiveserved by nginxgit commit & push (source only, in parallel)

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.

  1. 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.

  2. 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/PaperMod
    

    Set theme = "PaperMod" (or whatever theme you picked) in hugo.toml, add a post with hugo new content posts/first-post.md, and confirm it builds with hugo --minify.

  3. Install and configure nginx to serve the build output.

    sudo apt install nginx-light
    

    Point a server block’s root at wherever you sync the built public/ directory (not inside your home directory unless you loosen its permissions — nginx runs as www-data and needs to be able to read it). A minimal config is just root, index index.html, and a try_files $uri $uri/ =404; block. Have nginx listen on a private address/port — no need to expose it to 0.0.0.0 beyond your LAN.

  4. 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 cloudflared on 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.

  5. 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>. Use http://, not https:// — Cloudflare’s edge already terminates TLS for the public connection, so the local leg over the tunnel doesn’t need its own certificate.

  6. If your network blocks UDP/QUIC (common on stricter firewalls), force cloudflared onto HTTP/2 instead of letting it try QUIC first and time out: run it with --protocol http2.

  7. Write a one-command publish script. Something like: build with Hugo (--cleanDestinationDir, so deleted posts don’t linger as stale files), rsync --delete the 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 want git push to deploy automatically.

  8. 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.