TL;DR: Self-hosted Hugo blog, built by a GitHub Actions self-hosted runner inside Docker, served by nginx, routed through Nginx Proxy Manager and Cloudflare. No managed hosting and no WordPress.
I came from WordPress, which works fine until you actually want to use it. Logging into a dashboard to write a simple post, dealing with plugin updates, the whole song and dance. Overkill for what I needed. I wanted something I could write markdown in and have it just work, without giving up control of where it lives.
I’d used Bear Blog before and liked it. It’s clean, fast, no nonsense. But I wanted something self-hosted, so I started looking at alternatives in that space.
After poking at Writefreely I landed on Hugo with the PaperMod theme.
It’s markdown-native, git-based workflow, and I can run it on my own server.
This post walks through the full setup. If you want to skip to a specific part, use the table of contents.
The Stack
- Hugo + PaperMod - static site generator and theme
- nginx:alpine - serves the built static files
- GitHub Actions + self-hosted runner - builds and deploys on push
- Nginx Proxy Manager - reverse proxy with TLS, already running on my server
- Cloudflare - DNS, TLS termination, security headers and proxying
The blog is a static site served directly from disk. There’s no database because Hugo builds the site into a folder, nginx serves that folder. That’s it, simple and it just works.
Why Not Just Use a VPS and SSH Deploy?
I don’t expose SSH to the outside world. My server is only reachable via a WireGuard vpn to my home network. So the standard “push to git, CI SSHes into server and pulls” pattern doesn’t work for me without punching a hole I don’t want to punch.
My go-to solution for this, is a self-hosted GitHub Actions runner running on the server
itself, inside Docker. When I push to master, GitHub notifies the runner, the
runner pulls the repo, builds the site, and writes the output directly to disk.
No inbound SSH required :)
Directory Layout on the Server
Everything lives under /home/jeppe/blog/:
| |
Hugo Setup
Install Hugo extended locally (the extended version is required for PaperMod’s SCSS processing):
| |
Create the site and add PaperMod as a git submodule:
| |
Below is the hugo.toml config I ended up with after some iteration:
| |
A couple of things are worth pointing out:
pagination.pagerSizeinstead ofpaginate. Hugo v0.128+ removed the old key. You’ll get a build error if you use the old one (learned that one from experience).profileModereplaceshomeInfoParams. This enables the centered profile layout on the homepage instead of a post feed. I much prefer this.disableKinds = ["RSS"]. I don’t want an RSS feed.disableXML = truesounds like it should work but didn’t actually suppress the feed in my testing.
Content Structure
I organize content into two top-level sections under posts/ (This is truncated. There’s more under writeups/ but you get the idea):
| |
Hugo picks up the structure automatically. The _index.md files act as section
landing pages. Individual posts go anywhere under posts/ with standard front
matter:
| |
No Hugo CLI commands needed to create posts. Just create the file, add front
matter, write content, set draft: false, and push. That being said, hugo new does
make it a bit easier by pre-filling the front matter from a template, so it’s
worth knowing about.
Docker Setup
The blog runs in two containers managed by Docker Compose:
blog-nginx:alpineserving the static files from a bind mountrunner-myoung34/github-runnerself-hosted Actions runner
Both are on my existing npm_default network so Nginx Proxy Manager can route
to them by container name.
| |
A few things I learned the hard way here:
Pin the runner image version. myoung34/github-runner:latest was running
v2.332.0, which GitHub had deprecated. The runner would register successfully,
then immediately die with Runner version v2.332.0 is deprecated and cannot receive messages. Pinning to 2.335.1 (the version GitHub shows in the runner
setup page) fixed it.
Use bind mounts, not named volumes. This is more of a personal preference thing.
./data/blog is a plain directory on the host.
The GitHub Action writes the built site there directly, and nginx
serves it. No container restarts needed when content updates, nginx just serves
whatever is on disk. I’m generally a fan of bind mounts over named volumes. I
like knowing exactly where my data lives on the host. Yes, you have to manage
permissions yourself instead of letting Docker handle it, but that tradeoff is
worth it to me.
The runner token is one-time. It’s only used for initial registration. After
the runner is registered, its credentials are stored in ./data/runner/. If you
wipe that directory, you’ll need a fresh token to re-register.
GitHub Actions - The Deploy Pipeline
The workflow lives at .github/workflows/deploy.yml in the repo:
| |
The build step runs Hugo inside a temporary Docker container, mounting the checked-out repo as the source and the nginx serve directory as the destination. I’ve deliberately kept this part simple. It’s just Hugo running and writing files to disk. If you’ve seen setups where Hugo builds into a custom nginx image, that works too, but it’s unnecessary overhead when you can just write directly to a bind mount.
The path /home/jeppe/blog/data/runner/blog/blog/ looks weird. It’s not a
typo. The myoung34/github-runner image checks out repos into
$RUNNER_WORKDIR/<repo_owner>/<repo_name>/, which in my case is
/tmp/runner/blog/blog/. Since /tmp/runner is bind-mounted to
./data/runner, the full host path ends up being
/home/jeppe/blog/data/runner/blog/blog/. I found this by running
find /home/jeppe/blog/data/runner -name "hugo.toml" after the first failed
build.
submodules: recursive is important. Using just submodules: true did a
shallow submodule checkout that left the PaperMod _partials folder empty,
which meant Hugo built the site without any theme templates. The output was
technically valid HTML but had no JavaScript, including the theme toggle (which you can try right now by pressing the button next to the site title or pressing Alt + T).
Anyway, recursive forces a full pull and fixes this issue.
The PaperMod _partials Rename
Speaking of _partials - this caught me out and it’s worth documenting because
it’ll catch other people too.
Hugo v0.146.0 renamed the partials lookup path from layouts/partials/ to
layouts/_partials/. PaperMod has been updated to match. If you override any
PaperMod partials (like the footer), your override must go in
layouts/_partials/, not layouts/partials/.
I overrode the footer to remove the “Powered by Hugo & PaperMod” attribution.
My override lived in layouts/partials/footer.html, which Hugo ignored entirely.
Instead it used PaperMod’s version from layouts/_partials/. Except the PaperMod
version includes all the JavaScript (theme toggle, scroll-to-top, code copy
buttons) at the bottom of the file.
End result was as follows: the theme toggle button was in the HTML but had no event listener attached. Clicking it did nothing.
My fix was to move my override to layouts/_partials/footer.html and make
sure it included the full original JavaScript from PaperMod’s footer, with just
the copyright text changed:
| |
If you’re customizing PaperMod and things look off make sure to check whether your overrides are in the right folder for your Hugo version.
Nginx Proxy Manager
I already run NPM as my reverse proxy for everything on the server. Adding the blog is straightforward:
- Domain:
jeppebs.xyz,www.jeppebs.xyz - Forward hostname:
blog(the container name) - Forward port:
80 - Enable SSL + Force HTTPS
NPM and the blog container share the npm_default Docker network, so NPM
reaches it by container name directly, no IP addresses involved.
Cloudflare Security Headers
My domain is proxied through Cloudflare, which sits in front of the origin and handles TLS termination. Custom response headers set in Nginx Proxy Manager’s “Advanced” tab don’t always make it through to the client since Cloudflare strips or ignores them.
The fix is to set headers at the Cloudflare edge using a Response Header Transform Rule.
I created one scoped to just jeppebs.xyz and www.jeppebs.xyz:
| |
With four headers set statically:
| Header | Value |
|---|---|
Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'; |
X-Frame-Options | DENY |
X-Content-Type-Options | nosniff |
Referrer-Policy | strict-origin-when-cross-origin |
You can verify with:
| |
Should yield all four headers in the response.
The Publishing Workflow
New we get to the exciting part. Once everything is running, publishing a new post is:
| |
The push triggers the Action. The runner picks it up, Hugo builds the site into
/home/jeppe/blog/data/blog/, and nginx immediately serves the updated files.
End-to-end it takes about 30 seconds.
Other things to consider
A few things I’ve considered doing:
Add Umami or Plausible for analytics. Both are self-hostable, privacy-friendly, and would fit into the existing Docker stack. Right now I have no idea if anyone reads this, so analytics aren’t important to me as of now.
Add a favicon. Hugo expects favicon.ico, favicon-16x16.png, and
favicon-32x32.png in the static/ folder. I got 404s for all three from day
one and just haven’t gotten around to it.
Consider a staged deploy. Right now a push to master goes straight to
production. A staging branch that deploys to a preview URL would let me check
posts before they go live, especially for anything with complex formatting.
Why I like this setup
Beyond the technical bits, there’s something satisfying about a blog that’s just
files. The entire content of this site lives in a content/ folder. Zip it up
and you have an offline copy.
The git-based workflow also means I get version control for free. Every post, every edit, every config change is tracked. If I ever make a mess of something, I can roll back to any previous state of the blog with a single command.
In general, I like the “keep it simple” philosophy, and I hope the blog reflects that. If you’re building something similar and get stuck on any of the above, the links on the homepage are the best way to reach me.