This post starts with why I picked Hugo and GitHub Pages for Build Notes, then walks through every step of the setup. If you’ve already settled on Hugo, skip to the setup.
Why I chose Hugo
I looked at WordPress first. It’s the obvious choice for a blog, but it comes with a server, a database, and a pile of plugins that all need updating and watching. For a side project, I’d rather spend my time writing than maintaining. Markdown is not a problem for me, since I already use it at work.
Why I went with Hugo:
- Speed. Hugo builds pages fast, so I can preview a change locally and have it live about a minute after I push. The result is plain HTML, so pages load quickly too.
- Little to maintain. No database, no server-side code, no plugins. The posts live in a GitHub repo and GitHub Pages serves them, so there’s nothing to back up except the repo, and far less to patch.
- Free hosting. GitHub Pages hosts the site for free from a public repo, custom domain included.
- Everything is a Git commit. I can review any change before it goes live, and roll it back if something breaks.
- An easy exit. Everything is plain Markdown. If I ever outgrow Hugo, moving the posts to another framework should be far simpler than exporting them from a database. The front matter and any Hugo shortcodes would need some cleanup, but the writing itself carries over.
Trade-offs
The downsides showed up quickly:
- Hugo moves fast. PaperMod required a newer Hugo than the one I had installed, so I had to upgrade before going further (step 6 covers it).
- Anything interactive needs another service: comments, contact forms, a newsletter signup.
- There’s no visual editor. You write Markdown and publish with Git, which is fine if you know Git; otherwise it’s one more thing to learn.
- Going beyond a theme’s settings means editing Go templates, and their error messages aren’t friendly.
- GitHub Pages is basic hosting. No real redirects, no custom security headers, a 1 GB limit on the published site, and GitHub doesn’t allow it as free hosting for an online business.
I haven’t used the alternatives myself, but if those downsides put you off, here are the other options I looked at: WordPress or Ghost give you a visual editor and built-in extras, at the cost of a server to run or pay for. Astro or Eleventy suit people who already write JavaScript. And if your problem is GitHub Pages rather than Hugo, Cloudflare Pages and Netlify can host the same Hugo site with real redirects, security headers and a preview link for each pull request.
The setup
This setup uses:
- Hugo: a static site generator that turns Markdown into HTML.
- PaperMod: a fast, minimal theme. I picked it for the clean look and might switch later.
- GitHub Pages: free hosting, deployed by GitHub Actions on every push.
- A custom domain: optional; the free
<username>.github.ioaddress works too.
1. Install Hugo
On macOS, Homebrew is the easiest way:
brew install hugo
hugo version
Make sure the version string includes extended. Many themes need the extended edition to process SCSS, and Homebrew installs it by default.
2. Create the site
From an empty folder:
hugo new site . --format toml
git init -b main
This creates the standard Hugo layout:
hugo.toml: the site configuration.content/: your posts and pages, written in Markdown.archetypes/: templates for new content.layouts/,assets/,static/: overrides and extra files.themes/: where the theme lives.
Add a .gitignore so build output stays out of the repo:
/public/
/resources/_gen/
/assets/jsconfig.json
.hugo_build.lock
.DS_Store
3. Add the GitHub Pages deploy workflow
GitHub Pages can deploy straight from a GitHub Actions workflow, so you never commit the built HTML. This is based on Hugo’s official recipe, with two hardening changes explained below.
The versions in this workflow are placeholders. Actions and Hugo release new versions every few weeks, so any version I printed here would be out of date soon after you read it. Fill in
<hugo-version>,<sha256-of-the-deb>and each<commit-sha>with the current values; Find the current versions below shows where to get them. If you leave a placeholder in, the build fails instead of running something you didn’t choose.Prefer the simpler setup without commit SHAs or a hash check? Skip to Alternative: the simpler official workflow.
Save it as .github/workflows/hugo.yml:
name: Deploy Hugo site to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: <hugo-version>
# SHA-256 of hugo_extended_<hugo-version>_linux-amd64.deb, from the release's checksums file
HUGO_SHA256: <sha256-of-the-deb>
HUGO_ENVIRONMENT: production
TZ: UTC
steps:
- name: Install Hugo CLI
run: |
wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb
echo "${HUGO_SHA256} ${{ runner.temp }}/hugo.deb" | sha256sum --check --strict
sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Checkout
uses: actions/checkout@<commit-sha> # v<version>
with:
submodules: recursive
fetch-depth: 0
- name: Setup Pages
id: pages
uses: actions/configure-pages@<commit-sha> # v<version>
- name: Build with Hugo
run: |
hugo --gc --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
uses: actions/upload-pages-artifact@<commit-sha> # v<version>
with:
path: ./public
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@<commit-sha> # v<version>
A few details worth knowing:
HUGO_VERSIONpins the CI build to the same Hugo version you run locally (the numberhugo versionprints), so the live site matches what you previewed.submodules: recursiveis needed because the theme is installed as a git submodule (step 6).--baseURLcomes from the Pages settings, so the build always uses the right address, whether that’s<username>.github.io/<repo>or a custom domain.- Actions are pinned to commit SHAs, not tags. A tag like
@v<major>can be moved to point at different code by whoever controls it, and your build would run that code with permission to deploy your site. A full commit SHA can’t be moved. The comment after each SHA records which version it is. Dependabot keeps them up to date for you (next section). - The Hugo download is checked against a pinned hash.
HUGO_SHA256is the SHA-256 published in the release’shugo_<version>_checksums.txtfile. If the downloaded file doesn’t match,sha256sum --checkfails the build before anything is installed. When you bumpHUGO_VERSION, copy the new hash forhugo_extended_<version>_linux-amd64.debfrom that file. - Hidden files are left out of the published site. Recent versions of
upload-pages-artifactskip files and folders whose names start with a dot. If you add one on purpose, such asstatic/.well-known/security.txtfor telling people how to report security issues, newer versions let you include it withinclude-hidden-files: trueunder that step’swith:. Without that, it silently won’t deploy.
Find the current versions
Look these up when you first set up the workflow. After that, Dependabot (next section) keeps the actions current for you, and you only come back here when you update Hugo.
| Placeholder | Where to find it |
|---|---|
<hugo-version> | The latest release on Hugo’s releases page, or the version hugo version prints locally. Use the same one in both places. |
<sha256-of-the-deb> | The hugo_<version>_checksums.txt file in that release’s assets: copy the hash on the line for hugo_extended_<version>_linux-amd64.deb. |
<commit-sha> for each action | The latest release of checkout, configure-pages, upload-pages-artifact and deploy-pages. |
To get an action’s full commit SHA, open the release, click the short commit hash shown next to the tag, and copy the full SHA from the commit page. Or ask git directly, replacing <tag> with the release tag:
git ls-remote https://github.com/actions/checkout.git 'refs/tags/<tag>' 'refs/tags/<tag>^{}'
If it prints two lines, use the SHA on the line ending in ^{}: that’s the commit the tag points to. Put the release’s version in the comment after the SHA, so you can tell later which version you’re on.
For the Hugo hash, you can also print just the line you need:
curl -sL https://github.com/gohugoio/hugo/releases/download/v<version>/hugo_<version>_checksums.txt \
| grep 'hugo_extended_<version>_linux-amd64.deb'
Keep the pins up to date with Dependabot
Pinned SHAs don’t update on their own, which means security fixes in the actions won’t reach you either unless something bumps them. Dependabot does that. Save this as .github/dependabot.yml:
# Opens a pull request when a pinned GitHub Action or the PaperMod theme submodule
# has a new version. Review each one before merging.
# Not covered: HUGO_VERSION and HUGO_SHA256 in .github/workflows/hugo.yml. Update those by hand.
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "weekly"
Once it’s pushed, GitHub checks every week and opens a pull request when a pinned action or the theme submodule (step 6) has a new version, with the SHA and the version comment already updated. Your part is to read the release notes, look at the diff and merge. A few things to know:
- Dependabot also proposes major version bumps (for example
v4tov5). Read those release notes more carefully, because inputs sometimes change. - It doesn’t touch
HUGO_VERSIONorHUGO_SHA256. Update those two together by hand. - Its pull requests go through the same
mainruleset as your own changes (step 9), so nothing merges without you looking at it.
Alternative: the simpler official workflow
If looking up SHAs and hashes feels like too much, use the workflow from Hugo’s own guide, Deploy to GitHub Pages, instead of the one above. Hugo’s maintainers keep it current, so you can copy it as published. It refers to actions by tag (@v<major>) rather than commit SHA, and downloads Hugo without checking its hash. Tags for GitHub’s own actions/ pick up fixes within a major version automatically, and Dependabot still opens pull requests for major version bumps.
Before you use it, check it against this guide: keep submodules: recursive on the checkout step (the theme is a submodule), make sure the trigger branch matches yours (main here), and follow any extra setup steps that guide lists.
Warning: a tag is a pointer, and it can be moved. If the account behind an action is compromised, or a tag is repointed at malicious code, your next build runs that code with permission to deploy your site, and nothing changes in your repo to tip you off. This happened to the widely used third-party
tj-actions/changed-filesaction in 2025. GitHub’s ownactions/organization is a much harder target, which is why tags are a tolerable shortcut there, but never use tags for third-party actions. The bigger risk is the habit: once tags are normal in your workflow, the next action you add tends to go in the same way.
4. Create the GitHub repo and push
Create an empty repository on GitHub.
Public or private? On the GitHub Free plan, Pages only publishes from a public repository, so anyone can browse your source and its full git history, including anything you commit and later delete. To publish from a private repository you need GitHub Pro or higher. Either way, the website itself is public. If you go public, treat every commit as published (see the drafts note in step 8).
Connect your local folder to it and push:
git add -A
git commit -m "Set up Hugo site with GitHub Pages deploy workflow"
git remote add origin https://github.com/<username>/<repo>.git
git push -u origin main
Replace <username> with your GitHub username and <repo> with your repository name, here and everywhere else they appear in this guide.
Then, in the repo, go to Settings → Pages and set Source to GitHub Actions. Every push to main now builds and deploys the site. It will look blank until you add the theme in step 6.
Without a custom domain, the site is served at https://<username>.github.io/<repo>/. If you name the repo <username>.github.io, it’s served at the root instead.
5. Optional: use a custom domain
Using the default GitHub domain? Skip this section and go to step 6. Your site is already live at
https://<username>.github.io/<repo>/(or at the root, if the repo is named<username>.github.io), and GitHub handles DNS and HTTPS for you. Nothing here applies.
This section is only for a domain you own. I bought buildnotes.net through Cloudflare Registrar, so the DNS steps are written for Cloudflare. The other steps are the same whichever registrar you use.
Set baseURL in hugo.toml
baseURL = 'https://buildnotes.net/'
Add the DNS records (Cloudflare-specific)
These steps are specific to Cloudflare. With another registrar, add the same records in its DNS settings, remove any parking or forwarding records it added by default, and ignore the proxy setting.
In the Cloudflare dashboard, open your domain and go to DNS → Records. Add these records. The apex domain (@) points at GitHub’s Pages servers, and www points at your GitHub user domain:
| Type | Name | Value | Proxy status |
|---|---|---|---|
| A | @ | 185.199.108.153 | DNS only |
| A | @ | 185.199.109.153 | DNS only |
| A | @ | 185.199.110.153 | DNS only |
| A | @ | 185.199.111.153 | DNS only |
| AAAA | @ | 2606:50c0:8000::153 | DNS only |
| AAAA | @ | 2606:50c0:8001::153 | DNS only |
| AAAA | @ | 2606:50c0:8002::153 | DNS only |
| AAAA | @ | 2606:50c0:8003::153 | DNS only |
| CNAME | www | <username>.github.io | DNS only |
Delete any existing @ or www records that conflict with these.
Set every record to DNS only (grey cloud), not Proxied (orange cloud). While Cloudflare’s proxy sits in front of the site, GitHub can’t issue its certificate, so Enforce HTTPS stays unavailable. I hit exactly this, and switching to DNS only fixed it.
Verify the domain with GitHub
In your profile settings, go to Settings → Pages → Add a domain and add the TXT record GitHub gives you (on Cloudflare, in the same DNS → Records page). This stops anyone else from claiming your domain for their own Pages site.
Set the custom domain on the repo
In the repo’s Settings → Pages, enter the domain under Custom domain and save. Once the DNS check passes and GitHub issues a certificate, turn on Enforce HTTPS. DNS changes can take anywhere from minutes to a few hours to spread.
You may have seen guides that tell you to add a CNAME file to static/. That’s only needed for branch-based deploys. With a GitHub Actions deploy, the custom domain lives in the repo settings and the file is ignored.
Don’t leave DNS pointing at nothing
If DNS points at GitHub but no repo claims the domain, someone else can create a Pages site and claim it, then serve their content on your domain. This is called a subdomain or domain takeover. Three habits prevent it:
- Keep the verification TXT record from the step above for as long as you own the domain. Verification is what stops another account from claiming it.
- Take DNS down with the site. If you ever delete the repo, turn off Pages or remove the custom domain, delete the A, AAAA and
wwwCNAME records at the same time. - Never add a wildcard (
*) record pointing at GitHub. It makes every subdomain claimable.
6. Install the PaperMod theme
Add the theme as a git submodule:
git submodule add --depth=1 https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
A submodule keeps the theme’s code out of your repo but pinned to a specific commit.
Review theme updates before you commit them. The theme’s templates and JavaScript run on every page of your site, so an update is code you’re choosing to publish. git submodule update --remote pulls whatever the theme author pushed last, so look before you commit:
git submodule update --remote themes/PaperMod
git diff --submodule=log # list the new theme commits
git diff --submodule=diff # show the actual code changes
Look closely at changes to JavaScript, anything that loads scripts or fonts from another domain, and analytics or tracking snippets. If something looks off, roll back with git submodule update themes/PaperMod and don’t commit.
If you added the Dependabot config in step 3, it opens a pull request for each theme update instead. Review it the same way before merging.
Watch the minimum Hugo version. The theme’s theme.toml declares one:
grep min_version themes/PaperMod/theme.toml
# min_version = "<version>"
My local Hugo was older than that, so I upgraded it and bumped HUGO_VERSION in the workflow to match:
brew upgrade hugo
If you’d rather not upgrade, you can check out an older theme commit that supports your Hugo version instead.
7. Configure the theme
Here’s this site’s hugo.toml, minus the About and Privacy links (those need their own pages):
baseURL = 'https://buildnotes.net/'
locale = 'en-us'
title = 'Build Notes'
theme = 'PaperMod'
enableRobotsTXT = true
enableEmoji = true
[pagination]
pagerSize = 10
[outputs]
home = ['HTML', 'RSS', 'JSON'] # JSON powers the search page
[params]
env = 'production'
description = 'Build logs and tutorials on using AI and automation safely, for solo builders and small teams.'
author = '<username>'
defaultTheme = 'auto'
ShowReadingTime = true
ShowShareButtons = false
ShowPostNavLinks = true
ShowBreadCrumbs = true
ShowCodeCopyButtons = true
ShowToc = true
TocOpen = false
mainSections = ['posts']
[params.homeInfoParams]
Title = 'Build Notes'
Content = 'Notes on things I build.'
[[params.socialIcons]]
name = 'github'
url = 'https://github.com/<username>'
[params.fuseOpts]
keys = ['title', 'permalink', 'summary', 'content']
[menu]
[[menu.main]]
identifier = 'archives'
name = 'Archives'
url = '/archives/'
weight = 10
[[menu.main]]
identifier = 'tags'
name = 'Tags'
url = '/tags/'
weight = 20
[[menu.main]]
identifier = 'search'
name = 'Search'
url = '/search/'
weight = 30
[markup.highlight]
noClasses = false
Replace <username> with your GitHub username, and swap in your own domain, title and description.
The settings that matter most:
defaultTheme = 'auto': light or dark mode follows the reader’s system setting.description: the site’s meta description, which search engines show for your homepage. Make it a real sentence about what the site covers.homeInfoParams: the short intro shown at the top of the homepage.mainSections = ['posts']: the homepage lists posts only, so a localcontent/drafts/folder doesn’t take over the list when you preview.outputs.homewithJSON: builds the search index that PaperMod’s search page uses.ShowToc,ShowCodeCopyButtons,ShowReadingTime: per-post extras.menu.main: the links in the header.noClasses = false: code highlighting uses CSS classes, so it adapts to light and dark mode.
Add the Archives and Search pages. PaperMod only creates these if a content file asks for them.
content/archives.md:
+++
title = 'Archives'
layout = 'archives'
summary = 'archives'
+++
content/search.md:
+++
title = 'Search'
layout = 'search'
summary = 'search'
placeholder = 'Search posts'
+++
8. Preview and write
To preview locally, including drafts:
hugo server -D
Then open http://localhost:1313. The page reloads as you edit.
To start a new post:
hugo new content posts/my-post.md
New posts start with draft = true in the front matter. Change it to false when the post is ready, then commit and push. The workflow builds and deploys it in about a minute.
draft = true hides a post from the site, not from the repo. If your repo is public, anyone can read a committed draft on GitHub, and it stays in the git history even after you delete it. Don’t commit drafts you aren’t ready to share, or anything you wouldn’t publish: internal notes, real hostnames, screenshots with account details. I keep unfinished drafts in a content/drafts/ folder that’s listed in .gitignore, and only move a post into content/posts/ when it’s ready.
9. Lock it down
With this setup, a push to main is live within a minute. That makes your GitHub account the real target: anyone who gets into it, or into the account that controls your DNS, can deface or redirect the site without touching Hugo at all. Do these once:
- Turn on two-factor authentication for GitHub and for your registrar or DNS provider (Cloudflare here). Use a passkey or hardware security key if you can, rather than SMS.
- Protect
mainwith a ruleset. In the repo, go to Settings → Rules → Rulesets, create a branch ruleset that targetsmain, and turn on Require a pull request before merging, Block force pushes and Restrict deletions. Working solo, set required approvals to 0; the point is that every change goes through a pull request you look at before it ships. Rulesets work on public repositories on GitHub Free, but on a private repository they need GitHub Pro or higher; on Free, a private repo’s rules aren’t enforced. - Limit deployments to
main. Go to Settings → Environments → github-pages and make sure Deployment branches and tags only allowsmain, so a workflow running from any other branch can’t publish. - Keep the default workflow token read-only. Under Settings → Actions → General → Workflow permissions, choose Read repository contents and packages permissions. The deploy workflow asks for the extra permissions it needs itself.
- Check the domain is locked against transfers at your registrar, so it can’t be moved to another account without you.
Recap
- Install Hugo (extended edition).
- Run
hugo new siteandgit init, and add a.gitignore. - Add the GitHub Actions workflow for Pages, filling in the current versions: actions pinned to commit SHAs, the Hugo download checked against its hash, and Dependabot to keep the pins current. Or use Hugo’s official workflow, which uses tags (acceptable only for GitHub’s own
actions/, never for third-party actions). - Push to GitHub and set Pages Source to GitHub Actions.
- (Custom domain only; skip on the default GitHub domain) Set
baseURL, add the DNS records in Cloudflare as DNS only, verify the domain, set the custom domain, enforce HTTPS, and remove the DNS records if you ever take the site down. - Add PaperMod as a submodule, making sure your Hugo version is new enough, and review theme updates before committing them.
- Configure
hugo.tomland add the Archives and Search pages. - Write in Markdown, preview with
hugo server -D, keep unfinished drafts out of the public repo, and push to publish. - Turn on 2FA, protect
main, limit deployments tomain, and keep the workflow token read-only.
That’s the whole setup: no servers, no database, and free hosting.