Why Hugo Over Gatsby or Next.js#
Gatsby had a good run, but the ecosystem has shifted. The project was acquired, maintenance slowed, and most teams that were using it have migrated to Next.js or a static generator. Hugo fills a different niche: it does not care about React, has no Node.js dependency, and builds thousands of pages in under a second. If you are building a blog or documentation site and not a full React application, Hugo is the right tool.
This blog itself runs on Hugo deployed to GitHub Pages via GitHub Actions. Everything described here is the actual setup, not a hypothetical.
flowchart LR
A([Write Markdown]) --> B[git push to main]
B --> C[GitHub Actions workflow]
C --> D[hugo --minify]
D --> E[Deploy to GitHub Pages]
E --> F([Live site])
Installing Hugo#
Hugo ships as a single binary. The recommended approach on macOS is Homebrew:
brew install hugoOn Linux, download the extended binary from the releases page. The extended version is required for themes that use SCSS.
hugo version
# hugo v0.155.3+extended darwin/arm64Always install the extended variant. Many themes depend on SCSS compilation, which is only available in the extended build.
Creating a New Site#
hugo new site my-blog
cd my-blogThis creates the basic directory structure:
my-blog/
archetypes/ # content templates
content/ # your markdown files go here
layouts/ # template overrides
static/ # files copied as-is (favicon, CSS, images)
themes/ # theme submodules live here
config.toml # site configurationAdding a Theme as a Git Submodule#
Themes in Hugo are added as git submodules so they can be updated independently from your content. This is the critical step that CI pipelines often get wrong.
git init
git submodule add https://github.com/nunocoracao/blowfish.git themes/blowfishThen reference the theme in config.toml:
baseURL = "https://yourusername.github.io/"
theme = "blowfish"The baseURL must match your actual deployment URL exactly. Getting this wrong causes broken links in the production build.
Writing Posts#
Directory Structure#
All blog posts live in content/posts/. One Markdown file per post.
hugo new posts/my-first-post.mdFront Matter#
Hugo reads metadata from the front matter block at the top of each file:
---
title: "My First Post"
date: 2025-06-01T10:00:00+01:00
draft: false
tags: ["go", "aws", "devops"]
---
Your content here.Set draft: true while writing. Draft posts are excluded from production builds by default. Use hugo server -D locally to preview drafts.
Local Development#
hugo server -DThis starts the development server at http://localhost:1313 with live reload. Changes to content or templates are reflected in the browser instantly without a refresh.
The GitHub Actions Workflow#
This is the workflow this blog uses. It triggers on push to main, builds the site with Hugo extended, and deploys to a separate GitHub Pages repository using a personal access token.
name: Publish github page
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true # required to pull theme submodules
fetch-depth: 0 # required for .GitInfo and .Lastmod
- name: Setup Hugo
run: |
wget -q https://github.com/gohugoio/hugo/releases/download/v0.147.6/hugo_extended_0.147.6_linux-amd64.deb
sudo dpkg -i hugo_extended_0.147.6_linux-amd64.deb
- name: Build
run: hugo --minify
- name: Deploy
uses: peaceiris/actions-gh-pages@v4
if: github.ref == 'refs/heads/main'
with:
personal_token: ${{ secrets.PERSONAL_TOKEN }}
external_repository: yourusername/yourusername.github.io
publish_branch: main
publish_dir: ./publicA few things worth noting here.
submodules: true is non-negotiable. Without it, the theme directory is empty and Hugo fails silently or with a confusing error.
fetch-depth: 0 fetches the full git history. This is needed if your theme uses .GitInfo or .Lastmod to show last-modified dates.
hugo --minify reduces HTML, CSS, and JavaScript output size. Always use this for production.
Deploying to a Separate Repository#
The pattern above uses external_repository and personal_token. This is useful when your content repository is private or when you want to keep build artifacts separate from source.
Deploy to a gh-pages branch in the same repository. Simpler setup, works well for project sites.
- uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./publicDeploy to a separate yourusername.github.io repository. This is required for GitHub user pages (which must live in a repo named after your username).
- uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.PERSONAL_TOKEN }}
external_repository: yourusername/yourusername.github.io
publish_branch: main
publish_dir: ./publicCreate a personal access token with repo scope and store it as PERSONAL_TOKEN in the source repository’s secrets.
Custom Domain#
To use a custom domain, create a CNAME file in static/:
blog.yourdomain.comThen configure your DNS with a CNAME record pointing blog.yourdomain.com to yourusername.github.io. GitHub handles HTTPS automatically via Let’s Encrypt.
Hugo Configuration#
A minimal config.toml for this setup:
baseURL = "https://yourusername.github.io/"
defaultContentLanguage = "en"
theme = "blowfish"
pagination.pagerSize = 10
enableRobotsTXT = true
buildDrafts = false
buildFuture = false
googleAnalytics = "G-XXXXXXXXXX"
[markup.goldmark.renderer]
unsafe = true
[markup.highlight]
noClasses = true
style = "nord"Forgetting --minify on the build step
hugo command from a local workflow, you will deploy unminified assets. The difference in page size can be significant when you have many posts. Always use hugo --minify in CI.Setting the wrong baseURL
baseURL does not match your deployment URL, all links and assets will be broken in production. The local server does not catch this because it overrides baseURL automatically. Test by running hugo --minify locally and inspecting public/index.html to verify the links.Missing submodule checkout in CI
actions/checkout@v4 does not fetch submodules by default. Without submodules: true, the themes/ directory is empty. Hugo will either fail or produce a blank site depending on your configuration.Draft posts deployed to production
draft: true posts are excluded only if you do not pass -D to the build command. The problem is when you forget to flip the flag before pushing. Get in the habit of reviewing hugo list drafts before merging to main.Not setting fetch-depth: 0
actions/checkout performs a shallow clone. This means .GitInfo and .Lastmod return empty values in templates. If your theme shows last-modified dates, set fetch-depth: 0.If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.