This post walks through how I deploy my personal static blog using Hugo, the PaperMod theme, GitHub, and Cloudflare Workers.

A note on Workers vs. Pages: Cloudflare is consolidating static site hosting from Cloudflare Pages into Cloudflare Workers.

As of 2025, Workers Static Assets is the recommended way to host new static sites, and Pages is in maintenance mode.

In the dashboard both still live under the unified Workers & Pages section, which is why the deploy commands below use wrangler deploy (a Workers command) rather than the older Pages workflow.

Here is the setup this post builds toward:

The Cloudflare build and deploy commands are:

1
git submodule update --init --recursive && hugo --gc --minify
1
npx wrangler deploy

The Workers deployment options live in a wrangler.jsonc file committed to the repository (covered later).

Why Hugo?

Hugo is a static site generator written in Go. It is fast, simple, and works very well for technical blogs.

A static blog suits my use case because I mostly write posts made up of:

  • Markdown content
  • Source code examples
  • DevOps notes
  • Infrastructure experiments

Hugo generates plain HTML, CSS, JavaScript, images, and fonts, so the final site is easy to host on Cloudflare.

Why PaperMod?

I chose PaperMod because it is clean, fast, and well suited to technical writing.

The features I wanted are:

  • Clean blog layout
  • Dark/light mode
  • Table of contents
  • Reading time
  • Tags and categories
  • Good source code block rendering
  • Code copy button
  • Minimal maintenance

PaperMod is a practical choice for a technical blog: it stays out of the way and lets the content stand out.

Create the Hugo site

Create a new Hugo project. Hugo defaults to a TOML config file, so pass --format yaml to get hugo.yaml instead:

1
2
3
hugo new site personal-blog --format yaml
cd personal-blog
git init

Then add PaperMod as a Git submodule:

1
git submodule add https://github.com/adityatelange/hugo-PaperMod themes/PaperMod

Adding PaperMod as a submodule creates a .gitmodules file in the repository.

Check it with:

1
cat .gitmodules

Example:

1
2
3
[submodule "themes/PaperMod"]
  path = themes/PaperMod
  url = https://github.com/adityatelange/hugo-PaperMod

Configure Hugo

Here is a simplified hugo.yaml configuration:

 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
baseURL: "https://blog.canhdinh.com/"
locale: "en-us"
title: "Canh Dinh"
theme: "PaperMod"

pagination:
  pagerSize: 10

outputs:
  home:
    - HTML
    - RSS
    - JSON

params:
  env: "production"
  description: "Notes on DevOps practices, self-hosting and software delivery."
  author: "Canh Dinh"
  mainSections:
    - posts
  homeInfoParams:
    Title: "Hi there 👋"
    Content: >
      Welcome to my blog.
  defaultTheme: "auto"
  disableThemeToggle: false
  ShowReadingTime: true
  ShowShareButtons: false
  ShowPostNavLinks: true
  ShowBreadCrumbs: true
  ShowCodeCopyButtons: true
  ShowWordCount: true
  ShowRssButtonInSectionTermList: true
  ShowToc: true
  TocOpen: false
  UseHugoToc: true
  socialIcons:
    - name: "github"
      url: "https://github.com/vancanhuit"

menu:
  main:
    - identifier: "tags"
      name: "Tags"
      url: "/tags/"
      weight: 20
    - identifier: "archives"
      name: "Archives"
      url: "/archives/"
      weight: 30
    - identifier: "search"
      name: "Search"
      url: "/search/"
      weight: 40

markup:
  highlight:
    codeFences: true
    guessSyntax: true
    lineNos: true
    lineNumbersInTable: true
    noClasses: false

The two most important values are the production domain and the theme:

1
2
baseURL: "https://blog.canhdinh.com/"
theme: "PaperMod"

A few PaperMod-specific options are worth highlighting:

  • mainSections: [posts] makes the home page (/) list posts from content/posts/, so the landing page doubles as the posts archive.
  • homeInfoParams renders an intro block (title plus content) above the post list on the home page.
  • outputs.home adds a JSON output, which generates the search index used by the search page.

Home, archives, and search pages

PaperMod renders the post list on the home page automatically, but the Archives and Search pages each need a content file that selects the right layout. Without them, the menu links return 404.

Create the archives page:

1
hugo new content archives.md
1
2
3
4
5
6
---
title: "Archives"
layout: "archives"
url: "/archives/"
summary: "archives"
---

Create the search page:

1
hugo new content search.md
1
2
3
4
5
6
7
---
title: "Search"
layout: "search"
url: "/search/"
summary: "search"
placeholder: "Search posts..."
---

The search page relies on the JSON home output configured earlier. PaperMod ships with Fuse.js and runs fuzzy, client-side search over that index, so no server-side component is required.

Add a blog post

Create a new post:

1
hugo new content posts/hello-world.md

Example post:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
---
title: "Hello World"
date: 2026-06-21T10:00:00+07:00
draft: false
tags: ["hugo", "cloudflare", "devops"]
categories: ["devops"]
showToc: true
---

This is my first post using Hugo, PaperMod, GitHub, and Cloudflare.

```go
package main

import "fmt"

func main() {
    fmt.Println("Hello from my blog")
}
```

Make sure the post is publishable:

1
draft: false

If draft is set to true, Hugo excludes the post from production builds.

Run locally

For local development, run:

1
hugo server -D --disableFastRender

Then open:

1
http://localhost:1313

The -D flag includes draft posts.

If the site looks stale after changing CSS, fonts, or theme files, clean the generated output and rebuild:

1
2
3
rm -rf public resources/_gen
hugo --gc --minify
hugo server -D --disableFastRender

Push to GitHub

Commit and push the project:

1
2
3
4
5
git add .
git commit -m "Initial Hugo blog with PaperMod"
git branch -M main
git remote add origin https://github.com/vancanhuit/personal-blog.git
git push -u origin main

Configure Cloudflare deployment

In the Cloudflare dashboard, open Workers & Pages, create a new Worker, connect the GitHub repository, and set the following commands.

Build command

1
git submodule update --init --recursive && hugo --gc --minify

This does two things:

  1. Fetches the PaperMod theme submodule.
  2. Builds and minifies the Hugo site into the public/ directory.

The git submodule update --init --recursive step matters because PaperMod is stored as a Git submodule.

Deploy command

1
npx wrangler deploy

The deploy command stays minimal because the Workers options live in a wrangler.jsonc file at the repository root:

1
2
3
4
5
6
7
8
9
{
  "name": "personal-blog",
  "compatibility_date": "2026-06-21",
  "assets": {
    "directory": "public",
  },
  "preview_urls": false,
  "workers_dev": false
}

This file tells Wrangler the Worker name, the compatibility date, and the directory (public/) to serve as static assets.

Two settings disable Cloudflare’s automatic preview endpoints, since I only want the production domain to serve the site:

  • "preview_urls": false turns off the per-version preview URLs Cloudflare generates for each deployment.
  • "workers_dev": false disables the default *.workers.dev subdomain, so the Worker is reachable only through my custom domain.

Environment variables

I also recommend setting HUGO_VERSION in Cloudflare:

1
HUGO_VERSION = 0.163.3

Pinning the Hugo version avoids unexpected build differences between local and Cloudflare environments.

Self-host fonts

I self-host two fonts so the site does not depend on a third-party font CDN:

Font files are stored under:

1
2
static/fonts/inter/
static/fonts/maple-mono/

For example:

1
2
3
static/fonts/maple-mono/MapleMonoNL-Regular.ttf.woff2
static/fonts/maple-mono/MapleMonoNL-Bold.ttf.woff2
static/fonts/maple-mono/MapleMonoNL-Italic.ttf.woff2

I then define the code font in:

1
assets/css/extended/custom-fonts.css

Example:

 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
@font-face {
  font-family: "Maple Mono NL";
  src: url("/fonts/maple-mono/MapleMonoNL-Regular.ttf.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Maple Mono NL";
  src: url("/fonts/maple-mono/MapleMonoNL-Bold.ttf.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

code,
pre,
kbd,
samp,
.highlight pre,
.highlight code,
.chroma,
.chroma code {
  font-family: "Maple Mono NL", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
  font-feature-settings: "liga" 0, "calt" 0;
  font-variant-ligatures: none;
}

I disable ligatures because I prefer code to display exactly as typed.

For body text, I load Inter as a variable font (two woff2 files cover every weight) and set it as the base font:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@font-face {
  font-family: "Inter";
  src: url("/fonts/inter/inter-latin-wght-normal.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

body {
  font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

Because the extended stylesheet loads after the theme’s CSS, this body rule overrides PaperMod’s default font stack.

Custom styling

PaperMod automatically bundles any CSS placed in assets/css/extended/, loaded after the theme styles. I use a custom.css file there to make the content stand out and improve reading comfort:

1
assets/css/extended/custom.css

The main tweaks are:

  • An accent color for in-content links, with the default underline removed and an underline that animates in on hover.
  • Styled blockquotes with an accent border, a tinted background, and a decorative quote mark.
  • Subtle borders on inline code, rounded tables with a shaded header row, and a slim centered horizontal rule.
  • A softer light palette, brighter dark-mode text, and a larger line height for more comfortable reading.

All colors are driven by CSS variables, including PaperMod’s own --theme, --content, and --border, plus an --accent variable defined separately for light and .dark modes, so the styling adapts to both themes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
:root {
  --accent: #2563eb;
}

.dark {
  --accent: #6ea8fe;
}

.post-content a:not(.anchor) {
  color: var(--accent);
  text-decoration: none;
}

.post-content blockquote {
  border-inline-start: 0.25rem solid var(--accent);
  background: var(--quote-bg);
}

PaperMod underlines content links via .md-content a:not(.anchor). That selector has higher specificity than a plain .post-content a, so the override matches it with .post-content a:not(.anchor) to actually remove the underline.

Normal publishing workflow

After the initial setup, publishing a new post is simple.

Create a post:

1
hugo new content posts/my-new-post.md

Preview locally:

1
hugo server -D --disableFastRender

When ready, commit and push:

1
2
3
git add .
git commit -m "Add new blog post"
git push

Cloudflare then rebuilds and redeploys the site automatically.

Troubleshooting

Theme is missing

If Cloudflare cannot find PaperMod, make sure the theme submodule is committed:

1
2
git submodule status
cat .gitmodules

Also make sure the build command includes:

1
git submodule update --init --recursive

Code blocks look broken

Clean Hugo’s generated files locally, then rebuild:

1
2
3
rm -rf public resources/_gen
hugo --gc --minify
hugo server -D --disableFastRender

Also check the Markdown code fence format:

1
2
3
```go
fmt.Println("hello")
```

New font does not appear

Hard refresh the browser:

1
Ctrl + Shift + R

On macOS:

1
Cmd + Shift + R

If fonts are cached aggressively, rename the font folder or font files and update the CSS paths.

Deployment fails after successful build

If the log shows Hugo built successfully but deployment failed, check the deploy command and the wrangler.jsonc file.

For this setup, use:

1
npx wrangler deploy

And make sure wrangler.jsonc points the assets directory at the Hugo output:

1
2
3
4
5
6
7
8
9
{
  "name": "personal-blog",
  "compatibility_date": "2026-06-21",
  "assets": {
    "directory": "public",
  },
  "preview_urls": false,
  "workers_dev": false
}

If wrangler.jsonc is missing or assets.directory is wrong, Wrangler has nothing to upload and the deploy fails even though the build succeeded.

Final setup

The final setup is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Hugo static site
PaperMod theme
GitHub repository
Cloudflare build command:
  git submodule update --init --recursive && hugo --gc --minify

Cloudflare deploy command:
  npx wrangler deploy

Workers options stored in wrangler.jsonc