In this post, I’ll walk through how I deployed 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.

This is the setup I use for my own blog:

The final Cloudflare build and deploy commands are:

1
git submodule update --init --recursive && hugo --gc --minify
1
npx wrangler deploy --assets=public --name personal-blog --compatibility-date 2026-06-21

No wrangler.jsonc file is required in the repository for this setup.

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 is a good fit for my use case because I mostly write posts that contain:

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

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

Why PaperMod?

I chose PaperMod because it is clean, fast, and works well for technical writing.

Some features I wanted:

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

For a technical blog, PaperMod is a practical choice because it stays out of the way and lets the content stand out.

Create the Hugo site

Create a new Hugo project. By default Hugo generates 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

Because PaperMod is added as a submodule, the repository will include a .gitmodules file.

You can 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 most important values are:

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

The baseURL should match the production domain.

A few PaperMod-specific options are worth highlighting:

  • mainSections: [posts] makes the home page (/) list posts from content/posts/, so the landing page acts as the posts archive.
  • homeInfoParams renders an intro block (title plus content) at the top of the home page, followed by the post list.
  • 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 above. PaperMod ships with Fuse.js and performs 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 has:

1
draft: false

If draft is set to true, Hugo will not publish it in 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:

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

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

Then I define the 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 and brighter dark-mode text, plus a larger line height for more comfortable reading.

All colors are driven by CSS variables (including PaperMod’s own --theme, --content, and --border) and 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 through .md-content a:not(.anchor). Because that selector has a higher specificity than a plain .post-content a, the override matches it with .post-content a:not(.anchor) so the default underline is actually removed.

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, go to Workers & Pages, create a new Worker, connect the GitHub repository, and configure the project with these 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 part is important because PaperMod is stored as a Git submodule.

Deploy command

1
npx wrangler deploy --assets=public --name personal-blog --compatibility-date 2026-06-21

This deploys the generated public/ directory as static assets.

In this setup, I do not need a wrangler.jsonc file in the repository because the deploy command provides the required Workers deployment options directly:

1
2
3
--assets=public
--name personal-blog
--compatibility-date 2026-06-21

Environment variables

I also recommend setting HUGO_VERSION in Cloudflare:

1
HUGO_VERSION = 0.163.3

Using a fixed Hugo version helps avoid unexpected build differences between local and Cloudflare environments.

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 will automatically rebuild and redeploy the site.

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:

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 that Hugo built successfully but deployment failed, check the deploy command.

For this setup, use:

1
npx wrangler deploy --assets=public --name personal-blog --compatibility-date 2026-06-21

not just:

1
npx wrangler deploy

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 --assets=public --name personal-blog --compatibility-date 2026-06-21

No wrangler.jsonc required