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

1
2
3
4
5
6
7
/home/jeppe/blog/
├── docker-compose.yml
├── .env                    - runner token
├── data/
│   ├── blog/               - built site, served by nginx
│   └── runner/             - runner workdir and registration state
└── gitroot/                - cloned repo (for reference or manual builds)

Hugo Setup

Install Hugo extended locally (the extended version is required for PaperMod’s SCSS processing):

1
2
3
# Windows - get the latest release from GitHub
# https://github.com/gohugoio/hugo/releases
# Extract and add to PATH

Create the site and add PaperMod as a git submodule:

1
2
3
4
hugo new site blog
cd blog
git init
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod themes/PaperMod

Below is the hugo.toml config I ended up with after some iteration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
baseURL = "https://jeppebs.xyz/"
title = "Jeppe Sørensen"
theme = "PaperMod"
languageCode = "en-us"
pagination.pagerSize = 10
enableRobotsTXT = true
enableEmoji = true
disableXML = true
disableKinds = ["RSS"]

# General Params

[params]
  author = "Jeppe Sørensen"
  description = "Security research, malware dev, red team & ctf writeups"
  defaultTheme = "dark"
  ShowReadingTime = true
  ShowShareButtons = false
  ShowPostNavLinks = true
  ShowBreadCrumbs = true
  ShowCodeCopyButtons = true
  ShowToc = true

# Homepage (currently profile mode)
[params.profileMode]
  enabled = true
  title = "Jeppe Sørensen"
  subtitle = "Cybersecurity student. I write about Windows internals, malware development, and other stuff I find interesting."
  imageUrl = "/images/avatar.png"  # uncomment and add static/images/avatar.jpg to enable profile picture
  imageWidth = 120
  imageHeight = 120

#Buttons shown on the homepage under the subtitle
[[params.profileMode.buttons]]
  name = "Blog"
  url = "/posts/blog/"

[[params.profileMode.buttons]]
  name = "Writeups"
  url = "/posts/writeups/"
  
[[params.profileMode.buttons]]
  name = "About"
  url = "/about/"
  
# Socials. 

[[params.socialIcons]]
  name = "github"
  url = "https://github.com/jeppebs02"

[[params.socialIcons]]
  name = "linkedin"
  url = "https://www.linkedin.com/in/jeppe-s%C3%B8rensen-a11b11293/"
  
[[params.socialIcons]]
  name = "discord"
  url = "https://discord.com/users/855430476918423593"
  
# # Navigation Menu

[menu]
	[[menu.main]]
	  name = "Blog"
	  url = "/posts/blog/"
	  weight = 1

	[[menu.main]]
	  name = "Writeups"
	  url = "/posts/writeups/"
	  weight = 2

	[[menu.main]]
	  name = "About"
	  url = "/about/"
	  weight = 3

	[[menu.main]]
	  name = "Tags"
	  url = "/tags/"
	  weight = 4


# Syntax Highlighting (Chroma)
[markup.highlight]
  style = "monokai"
  lineNos = true
  lineNumbersInTable = true
  noClasses = false
  guessSyntax = true

A couple of things are worth pointing out:

  • pagination.pagerSize instead of paginate. Hugo v0.128+ removed the old key. You’ll get a build error if you use the old one (learned that one from experience).
  • profileMode replaces homeInfoParams. 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 = true sounds 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
content/
└── posts/
    ├── _index.md
    ├── blog/
    │   └── _index.md
    └── writeups/
        ├── _index.md
        └── DDC-2026/
            ├── _index.md
            ├── qualifiers/
            └── fasttrack/

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:

1
2
3
4
5
6
7
8
---
title: "Post Title"
date: 2026-06-20
tags: ["tag1", "tag2"]
draft: false
---

Content here.

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:

  1. blog - nginx:alpine serving the static files from a bind mount
  2. runner - myoung34/github-runner self-hosted Actions runner

Both are on my existing npm_default network so Nginx Proxy Manager can route to them by container name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
services:
  blog:
    image: nginx:alpine
    container_name: blog
    restart: unless-stopped
    volumes:
      - ./data/blog:/usr/share/nginx/html:ro
    networks:
      - npm_default

  runner:
    image: myoung34/github-runner:2.335.1
    container_name: github-runner
    restart: unless-stopped
    environment:
      - RUNNER_NAME=blog-runner
      - REPO_URL=https://github.com/Jeppebs02/blog
      - RUNNER_TOKEN=${RUNNER_TOKEN}
      - RUNNER_WORKDIR=/tmp/runner
      - LABELS=blog-runner
      - RUNNER_SCOPE=repo
      - RUNNER_ALLOW_RUNASROOT=true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data/runner:/tmp/runner
    networks:
      - npm_default

networks:
  npm_default:
    external: true

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
name: Build and Deploy Blog

on:
  push:
    branches:
      - master

jobs:
  deploy:
    runs-on: [self-hosted, blog-runner]

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          submodules: recursive
          fetch-depth: 0

      - name: Build site
        run: |
          docker run --rm \
            -v /home/jeppe/blog/data/runner/blog/blog:/src \
            -w /src \
            -v /home/jeppe/blog/data/blog:/output \
            hugomods/hugo:exts \
            hugo --minify -d /output

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:

1
2
3
4
5
6
7
8
9
{{- if not (.Param "hideFooter") }}
<footer class="footer">
    <span>&copy; {{ now.Year }} <a href="{{ "" | absLangURL }}">{{ site.Title }}</a></span>
</footer>
{{- end }}

{{- partial "extend_footer.html" . }}

{{/* ... rest of PaperMod's footer JS unchanged ... */}}

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:

1
(http.host eq "jeppebs.xyz") or (http.host eq "www.jeppebs.xyz")

With four headers set statically:

HeaderValue
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none';
X-Frame-OptionsDENY
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin

You can verify with:

1
curl -I https://jeppebs.xyz

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:

1
2
3
4
5
6
7
8
9
# Create the file (or just create it manually in your editor)
hugo new content/posts/blog/my-post.md

# Write the post, then set draft: false in the front matter

# Commit and push
git add .
git commit -m "add: my post title"
git push

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.