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:
- GitHub repository: vancanhuit/personal-blog
- Live site: blog.canhdinh.com
- Static site generator: Hugo
- Theme: PaperMod
- Hosting/deployment: Cloudflare Workers (Static Assets)
The Cloudflare build and deploy commands are:
| |
| |
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:
| |
Then add PaperMod as a Git submodule:
| |
Adding PaperMod as a submodule creates a .gitmodules file in the repository.
Check it with:
| |
Example:
| |
Configure Hugo
Here is a simplified hugo.yaml configuration:
| |
The two most important values are the production domain and the theme:
| |
A few PaperMod-specific options are worth highlighting:
mainSections: [posts]makes the home page (/) list posts fromcontent/posts/, so the landing page doubles as the posts archive.homeInfoParamsrenders an intro block (title plus content) above the post list on the home page.outputs.homeadds aJSONoutput, 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:
| |
| |
Create the search page:
| |
| |
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:
| |
Example post:
| |
Make sure the post is publishable:
| |
If draft is set to true, Hugo excludes the post from production builds.
Run locally
For local development, run:
| |
Then open:
| |
The -D flag includes draft posts.
If the site looks stale after changing CSS, fonts, or theme files, clean the generated output and rebuild:
| |
Push to GitHub
Commit and push the project:
| |
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
| |
This does two things:
- Fetches the PaperMod theme submodule.
- 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
| |
The deploy command stays minimal because the Workers options live in a wrangler.jsonc file at the repository root:
| |
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": falseturns off the per-version preview URLs Cloudflare generates for each deployment."workers_dev": falsedisables the default*.workers.devsubdomain, so the Worker is reachable only through my custom domain.
Environment variables
I also recommend setting HUGO_VERSION in Cloudflare:
| |
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:
- Inter for body and UI text.
- Maple Mono NL for code blocks.
Font files are stored under:
| |
For example:
| |
I then define the code font in:
| |
Example:
| |
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:
| |
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:
| |
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:
| |
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:
| |
Preview locally:
| |
When ready, commit and 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:
| |
Also make sure the build command includes:
| |
Code blocks look broken
Clean Hugo’s generated files locally, then rebuild:
| |
Also check the Markdown code fence format:
| |
New font does not appear
Hard refresh the browser:
| |
On macOS:
| |
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:
| |
And make sure wrangler.jsonc points the assets directory at the Hugo output:
| |
If wrangler.jsonc is missing or assets.directory is wrong, Wrangler has nothing to upload and the deploy fails even though the build succeeded.
Useful links
- Hugo official website
- Hugo documentation
- PaperMod GitHub repository
- PaperMod installation guide
- Cloudflare Workers
- Cloudflare Workers static assets
- Migrate from Pages to Workers
- Wrangler deploy command
Final setup
The final setup is:
| |