Building a Free Blog with Markdown Using GitHub Pages and Hugo

A step-by-step guide to creating a free blog with Hugo and GitHub Pages, covering installation, themes, Google Analytics, and sitemap setup.

Introduction

With GitHub Pages, you can publish websites for free. I learned that combining it with a static site generator (Hugo in this case) makes it easy to create a blog, so I decided to try it. This is a summary of that process.

What is Hugo?

When building a blog, Content Management Systems (CMS) like WordPress are commonly used for their ease of content creation and editing. However, using a CMS requires installing and configuring the CMS itself and setting up a database, which can be complex. For small blogs, creating a static site with HTML files can be more cost-effective. That said, manually creating HTML files is tedious. This is where static site generators come in, and Hugo is one of them.

Hugo is a static site generator built with Go. With Hugo, you can create a blog consisting of static HTML and CSS files without needing a database. Content is written in Markdown format, and building generates static HTML files.

Hugo logo

Advantages of Hugo:

  • Fast build and rendering
  • No database required, making management simple

How static site generation (SSG) works: how it differs from SSR and CSR

The phrase “building generates static HTML files” matters more than it looks — it points to a fundamentally different rendering model from the other two common approaches used on the web today.

  1. SSG (Static Site Generation) — Hugo’s approach. Content files (Markdown + front matter) and layout templates are combined by Hugo’s Go template engine exactly once, at build time, producing a complete set of pre-rendered HTML files. After that, the server just serves those files as-is.
  2. SSR (Server-Side Rendering) — an application server (Node.js, Ruby on Rails, etc.) runs the template engine on every incoming request, typically querying a database each time.
  3. CSR (Client-Side Rendering) — used by SPAs (React, etc.). The server returns a near-empty HTML shell plus a JavaScript bundle, and the browser’s JS engine renders the page after it loads.

This distinction produces two concrete, practical consequences:

  • Performance: with SSG, the templating cost is paid exactly once at build time. Whether a page gets 1 visitor or 100,000, what’s served is the same pre-generated HTML file — there’s no per-request templating engine invocation or database round-trip like SSR, and no client-side rendering wait like CSR.
  • Hosting simplicity: the build output (the public/ directory) is nothing but plain static files — HTML, CSS, JS, images. No application server, database, or runtime is required, so it can be hosted anywhere files can be served from a static path. GitHub Pages, covered later in this post, is exactly that: a free host that only serves static files.

This blog’s own deployment pipeline (.github/workflows/deploy.yml) reflects this directly: CI runs hugo --gc --minify once per push, and the resulting public/ directory is deployed as-is to GitHub Pages. There is no step anywhere in the request path where a server computes anything per visitor.

Creating a Site with Hugo

Installing Hugo

# With Homebrew (macOS)
brew install hugo

For other installation methods, see Install Hugo .

Creating a Site

hugo new site test # 'test' can be any name
cd test
hugo # This command generates the site

Previewing on Local Server

hugo server

Preview at http://localhost:1313 .

Directory Structure

.
├── archetypes # Files processed by Hugo Pipes
├── config.toml # Hugo configuration file
├── content # Article files go here
├── data # Data files referenced across all pages
├── layouts # For customizing theme files or adding layout partials
├── public # Generated HTML code (this gets published)
├── static # Static files for the site
└── themes # Theme files

Using a Theme

Choose a theme from Hugo Themes and clone it into the themes folder.

cd themes
git clone <theme-repo-url>

Add the following line to config.toml:

theme = "chosen-theme-name"

Hugo themes gallery

Creating Blog Posts

cd test
hugo new post/new-post.md

Contents of new-post.md:

---
title: "Title"
date: 2020-08-16T15:17:23+09:00
draft: false # Set to true to hide the post
tags: ["python", "ros"]
---
Write your article in Markdown here
  • Run the hugo command to generate static files under the public directory:
cd test
hugo

GitHub Pages

Creating a GitHub Repository

Click the “+” icon in the top right of GitHub and select “New repository.” Name the repository “username.github.io.” The published URL will be https://username.github.io .

GitHub new repository creation screen

Upload the public Folder

Since GitHub Pages only serves HTML files from the repository root, upload the public folder from your Hugo project to the “username.github.io” repository. Manage the Hugo project (test folder) itself in a separate repository or branch.

Additional Tips

Running Hugo

Preview locally:

hugo server

Preview including drafts:

hugo server -D

Enabling LaTeX Math Formulas

Create /layouts/partials/add_mathjax.html:

<script>
    MathJax = {
        tex: {
        inlineMath: [['$', '$'], ['\\(', '\\)']]
        }
    };
</script>

<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>

Add to /layouts/partials/site-header.html:

{{ partial "add_mathjax.html" . }}

Google Analytics

Add the following to config.toml:

googleAnalytics = "Measurement-ID"

Since the built-in template for Google Analytics integration is outdated, edit layouts/partials/head.html:

- {{ template "_internal/google_analytics_async.html" . }}
+ {{- partial "analytics" . -}}

Create layouts/partials/analytics.html:

{{ if not .Site.IsServer }}
{{ with .Site.GoogleAnalytics }}
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ . }}"></script>
<script>
   window.dataLayer = window.dataLayer || [];
   function gtag(){dataLayer.push(arguments);}
   gtag('js', new Date());
   gtag('config', '{{ . }}');
</script>
{{ end }}
{{ end }}

Creating a Sitemap

Create layouts/sitemap.xml:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{{ range .Data.Pages }}{{ if .IsPage }}
<url>
  <loc>{{ .Permalink }}</loc>
  {{ if not .Lastmod.IsZero }}
  <lastmod>{{ safeHTML ( .Lastmod.Format "2006-01-02T15:04:05-07:00" ) }}</lastmod>
  <changefreq>weekly</changefreq>
  {{ end }}
</url>
{{ end }}{{ end }}
</urlset>

Create layouts/robots.txt:

Sitemap : {{ $.Site.BaseURL }}sitemap.xml

Edit config.toml:

enableRobotsTXT = true

The sitemap is available at /sitemap.xml.

Execution verification: actually building and measuring

Claims that “Hugo builds are fast” are easy to make and hard to verify — but since this very blog runs on Hugo, we can just build it and check the real numbers.

Real measurement on the production site

We ran a production-equivalent build against this blog (apps/blog):

time hugo --gc --minify -d /tmp/hugo-build-test-public

Result (a read-only measurement — no content was changed, only the output directory was redirected to a temp path):

Total in 4674 ms
hugo --gc --minify ...  19.67s user 1.09s system 439% cpu 4.721 total

This build covers 542 Japanese pages and 563 English pages — 1,105 pages total, including posts, tag lists, and pagination — through full Markdown parsing, template application, syntax highlighting, CSS/JS/HTML minification, and garbage collection. Hugo’s own internal timer (Total in) reports 4.674 seconds; the shell’s time command, measuring wall-clock time for the whole process, reports 4.721 seconds. The 439% cpu figure shows the build parallelizing across cores (19.67s of user CPU time divided by 4.721s wall-clock time works out to roughly 4.4 cores of effective parallelism).

Scaling verification: measuring build time against page count

The production site bundles a theme, multiple languages, and affiliate features, which makes it hard to isolate the effect of page count alone. So, in a scratch directory completely separate from this repository, we created a fresh Hugo site with only minimal single.html/list.html templates, then generated 10, 100, and 1,000 dummy posts and measured real build times at each step.

Generated pages (posts + lists, etc.)Build time (Hugo’s own timer)
21 (10 posts)12 ms
111 (100 posts)24 ms
1,011 (1,000 posts)154 ms

Hugo build time grows roughly linearly with page count (measured)

Going from 10 to 100 posts (10x) only doubled the build time (12ms → 24ms), because at small scale, fixed costs unrelated to page count — process startup, module resolution, and so on — dominate. Going from 100 to 1,000 posts (10x) increased build time 6.4x (24ms → 154ms), which matches the intuition that as page count grows, the per-page processing cost becomes the dominant factor.

By contrast, the production site (1,105 pages, full theme, multilingual, minification) took 4,674ms — a much higher per-page cost than the scratch site’s 1,011-page data point (154ms). This is because a real theme adds layout inheritance, partial invocation, shortcode expansion, syntax highlighting, and asset minification per page — build time depends not just on page count but on how much work each page actually does. Still, building a real 1,000+ page site in under 5 seconds is a concrete data point backing up Hugo’s reputation for speed.

Edge cases worth knowing

Content organization: leaf bundles, branch bundles, and plain content files

Hugo’s content directory supports three distinct organizational patterns, and they change how related files (like images) are resolved. We verified all three empirically on the scratch site.

  1. Leaf bundle — e.g. content/posts/foo/index.md, where the Markdown file inside the directory is literally named index.md. Any file placed alongside it becomes a page resource, accessible from templates via .Resources. In our test, a directory with index.md plus a sibling image reported {{ len .Resources }} as 1 in the template — enabling Hugo’s built-in image-processing pipeline ({{ with .Resources.GetMatch "fig.png" }}{{ .Resize "300x" }}{{ end }}, responsive images, etc.).
  2. Branch bundle — e.g. content/posts/foo/_index.md, where the file is named _index.md. This directory becomes a section (list) page, not a single post. In our test, a directory with _index.md was rendered with list.html instead of single.html, and could enumerate nested child pages (other leaf bundles) via .Pages.
  3. Plain content file — a Markdown file named neither index.md nor _index.md (e.g. 1.md), sitting in a directory. This is exactly the pattern this blog itself usescontent/posts/YYYYMMDD_topic/1.md — and this very article is no exception. When we tested this pattern, {{ len .Resources }} returned 0 in the template: page-resource features like image processing are not available. However, sibling files in the same directory are still copied by Hugo to the matching path in the output (we confirmed public/posts/20210204_hugo/fig1.png is actually generated). That’s exactly why plain absolute-path Markdown images like ![alt](/posts/20210204_hugo/fig1.png) work correctly throughout this blog even though these directories aren’t true leaf bundles. The tradeoff: to use Hugo’s built-in image processing (resize, responsive images, EXIF reading) on these images, you’d need to rename 1.md to index.md — but doing so would change the post’s URL, since 1.md itself currently becomes a URL segment (/posts/20210204_hugo/1/, the structure actually used across this site).

The draft: true gotcha: posts silently missing from production builds

The “Additional Tips” section above mentions hugo server -D, a command commonly used during local development — and it hides a gotcha that trips people up in practice. We added a post with draft: true on the scratch site and compared a production-equivalent build (no -D) against a drafts-included build (-D):

=== Production build (no -D) ===
Pages: 1011 (same as before adding the draft)
public/posts/draft-test/ → not generated

=== Build with -D ===
Pages: 1012 (+1)
public/posts/draft-test/index.html → generated

Because hugo server -D correctly shows the post locally, it’s easy to assume “I confirmed it works, so it’s fine” — but a production build without -D (exactly what CI runs via hugo --gc --minify) doesn’t just hide the draft page, it doesn’t count it in the page total at all, and generates no output file whatsoever. What appeared to work locally can vanish entirely in production, so always confirm draft: false before publishing, and test with a build that omits -D at least once before shipping.

Verifying sitemap.xml and the RSS feed

We inspected the actual production build output rather than assuming the sitemap and feed “probably” look right.

  • public/sitemap.xml turned out to be a sitemap index (<sitemapindex>), not a flat list of URLs. Since this site is bilingual (Japanese/English) and shares a domain with separate Next.js apps (DevToolBox, CalcBox, Pomodoro), it aggregates references to multiple sitemaps: https://yuhi-sa.github.io/ja/sitemap.xml, .../en/sitemap.xml, .../devtoolbox/sitemap.xml, and so on.
  • The actual per-language sitemaps live at public/ja/sitemap.xml and public/en/sitemap.xml. Inspecting the Japanese one, its URLs (e.g. https://yuhi-sa.github.io/tags/db/) correctly omit the /ja/ prefix that would suggest a subdirectory — even though the sitemap file itself lives at a /ja/ path that doesn’t correspond to any real served page. The URLs inside point at the real serving paths.
  • The RSS feed (public/index.xml) was also opened directly and confirmed to contain correct titles, links, and publish dates for recent posts.

Actually opening the build output — instead of assuming it’s “probably generated correctly” — is what catches SEO regressions before they ship.

Recent Hugo developments

Several years have passed since this post was originally written in 2021, so we checked in on Hugo’s current state.

  • The Hugo build used for this update was hugo v0.164.0+extended+withdeploy (released July 6, 2026), installed via Homebrew. This blog’s CI (.github/workflows/deploy.yml), however, pins 0.155.3 — nine minor versions behind the locally installed version.
  • v0.164.0 adds AVIF image encode/decode support, new template functions (encoding.HexEncode, encoding.HexDecode, crypto.Hash), Pandoc citation support, and a fix for a performance regression that had been present since v0.128.0 (most noticeable on larger sites).
  • Building this site today also surfaces several deprecation warnings: languages.ja.languageName was deprecated in Hugo v0.158.0 in favor of languages.ja.label, and .Language.LanguageCode, .Language.LanguageDirection, and .Language.LanguageName are deprecated in favor of .Language.Locale, .Language.Direction, and .Language.Label respectively. This site’s config.toml still uses the older keys today and will need to migrate before they’re removed in a future major release.