How to Host a Personal Blog with Hugo on GitHub Pages
Introduction
I run EKS clusters and edge infrastructure for a living, and I still don’t want to run or pay for any of it on my own time. That’s a rule I keep for side projects: no self-hosting, no Kubernetes, no Cloudflare Workers, nothing with a bill attached. When I started this blog in 2020 that rule meant two decisions: what to write it in, and where to put it. I landed on Hugo and GitHub Pages, and this post is the whole path from an empty folder to a live site, the same setup omegion.dev runs on today.
Prerequisites
- Hugo Extended
,
0.146.0or newer. On macOS:brew install hugo. - Git.
- A GitHub account, and an empty repository created there to push this site into.
What is Hugo
Hugo is a static site generator written in Go. You write content in Markdown, Hugo runs it through a set of templates, and the output is plain HTML files, nothing server-side, no database, no request-time rendering. It ships as a single binary, no Node toolchain, no Ruby, nothing to install beyond the binary itself. I’m a Go person, so that was an easy sell on its own, but the part that actually matters for “no infrastructure” is the output: a folder of HTML files is the cheapest possible thing to host, because it doesn’t need a running process at all.
Create a new site
❯ hugo new site myblog
Congratulations! Your new Hugo project was created in /Users/hakan/myblog.
Just a few more steps...
1. Change the current directory to /Users/hakan/myblog.
2. Create or install a theme:
- Create a new theme with the command "hugo new theme <THEMENAME>"
- Or, install a theme from https://themes.gohugo.io/
3. Edit hugo.toml, setting the "theme" property to the theme name.
4. Create new content with the command "hugo new content <SECTIONNAME>/<FILENAME>.<FORMAT>".
5. Start the embedded web server with the command "hugo server --buildDrafts".
See documentation at https://gohugo.io/.That scaffolds content/, layouts/, static/, archetypes/, and a
hugo.toml with just a baseURL, title, and locale. cd myblog and
turn it into a git repo:
git init
git add .
git commit -m "Initial commit"Write your first post
hugo new content uses the default archetype to stamp out a new page with
frontmatter already filled in:
❯ hugo new content posts/hello-world.md
Content "/Users/hakan/myblog/content/posts/hello-world.md" created+++
date = '2026-08-27T08:56:30+02:00'
draft = true
title = 'Hello World'
+++Add a line or two of body text under that frontmatter, and flip draft to
false once you actually want it live, Hugo skips draft content by default
outside of local preview.
Build it locally
hugo server --buildDrafts runs a local dev server with live reload:
❯ hugo server --buildDrafts
Watching for changes in /Users/hakan/myblog/{archetypes,assets,content,data,i18n,layouts,static}
Watching for config changes in /Users/hakan/myblog/hugo.toml
Start building sites …
WARN found no layout file for "html" for kind "home": You should create a template file which matches Hugo Layouts Lookup Rules for this combination.
WARN found no layout file for "html" for kind "page": You should create a template file which matches Hugo Layouts Lookup Rules for this combination.
│ EN
──────────────────┼────
Pages │ 5
Web Server is available at http://localhost:1313/
Press Ctrl+C to stopThose WARN lines are expected right now. A freshly scaffolded site has no
theme, so there’s nothing telling Hugo how to turn a post into a page. It’ll
build, the server will start, and the browser tab will be blank. That gets
fixed in the last section, once the deploy pipeline is in place.
For a real build instead of the dev server:
❯ hugo --gc --minifyThat writes the finished site to public/, the same directory the deploy
workflow below picks up and publishes.
Deploy it to GitHub Pages with GitHub Actions
Push the repo to GitHub, then add a workflow that builds the site and deploys it on every push, this is the exact file I run for this blog:
# .github/workflows/deploy.yaml
name: Deploy Hugo site to Pages
on:
push:
branches:
- master
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
env:
HUGO_VERSION: 0.165.0
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
sudo dpkg -i ${{ runner.temp }}/hugo.deb
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Pages
id: pages
uses: actions/configure-pages@v6
- name: Build with Hugo
env:
HUGO_ENVIRONMENT: production
HUGO_ENV: production
run: |
hugo --gc --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
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@v5fetch-depth: 0 in the checkout step matters if you turn on
enableGitInfo later. Without full history Hugo can’t read a page’s last
commit date. One setting has to happen by hand: in the repo’s Settings >
Pages, set Source to GitHub Actions, not Deploy from a branch. That’s
what lets actions/deploy-pages publish without a gh-pages branch or a
committed CNAME file at all. A custom domain is set in that same Pages
screen. GitHub writes and manages the CNAME file for you from there.
Push, and the Actions tab shows the build and deploy jobs running. A few minutes later the site is live at your GitHub Pages URL, or your custom domain if you set one, no server, no SSH key, no FTP, just Actions minutes GitHub already gives away for free on a public repo.
Add a theme
This is the piece that fixes the blank page and WARN lines from earlier.
Hugo doesn’t ship with a default look, a theme is what supplies the actual
templates. themes.gohugo.io
has a few hundred
to pick from, or you can build your own the way I did:
hugo-omegion
, the theme this
blog runs. Sidebar-first layout, dark/light mode, client-side search with
no backend, Mermaid diagrams, syntax-highlighted code blocks with a copy
button, RSS, and the SEO/OpenGraph/JSON-LD metadata a blog actually needs.
MIT licensed, free to take.
Whichever theme you pick, I’d recommend installing it as a Hugo Module
instead of the classic git submodule under themes/, it’s one version
pinned in go.mod like any other dependency, instead of a submodule
pointer nobody remembers to update:
hugo mod init github.com/<you>/myblog# hugo.toml
enableEmoji = true
enableGitInfo = true
[[module.imports]]
path = 'github.com/omegion/hugo-omegion'❯ hugo mod tidy
go: added github.com/omegion/hugo-omegion v1.0.16
hugo: collected modules in 607 msRebuilding after that pulls in the theme’s layouts/, assets/, and
static/, the WARN lines are gone and the page count jumps once there’s
an actual template rendering each post:
❯ hugo --buildDrafts
│ EN
──────────────────┼────
Pages │ 11What a theme doesn’t inherit automatically is the site’s own config.
enableGitInfo, the searchindex output format a search box needs, and
goldmark.renderer.unsafe = true for raw HTML in posts all have to be set
in your own hugo.toml, not the theme’s. hugo-omegion’s README spells out
the exact block to copy. Skip one and the symptom is quiet: a blank search
box, no “Updated” date on a post, nothing that looks like an error.
Bumping the theme later is hugo mod get -u github.com/omegion/hugo-omegion,
a normal diff in go.mod and go.sum, the same motion as updating any
other Go module.
Conclusion
That’s the whole setup: a new Hugo site, a first post, a GitHub Actions workflow that builds and deploys it on every push, and a theme on top. No server to run, no database, nothing to patch, and no bill at the end of the month. Push a commit and the site is live a few minutes later. For a personal blog, that’s exactly the amount of infrastructure I wanted: none.