How I Built This Blog — Hugo, Custom Theme, and Auto-Deploy to Azure
Table of contents
This blog runs completely for free — no managed server, no database, no runtime. I push a new markdown file to GitHub, and about 30 seconds later it goes live at khangnghiem.com. No SSH, no restarts, no monthly hosting bill.
This post explains the full stack: what Hugo is and how it works, how content is organized, a custom theme built from scratch, multilingual support (Vietnamese, English, Japanese), and an automated CI/CD pipeline using GitHub Actions and Azure Static Web Apps.
What is Hugo and why I chose it
Hugo is a static site generator — a program that takes markdown files as input and outputs plain HTML, CSS, and JS. No server-side rendering, no database, no runtime dependencies. The result is a public/ folder full of static files that can be served from any CDN or web server.
I chose Hugo for three reasons:
Fast builds. Hugo builds the entire site in seconds, no matter how many posts. This blog with over 10 posts builds in under 500ms. Jekyll (Ruby) and Next.js are both much slower at scale.
No JavaScript runtime. No Node.js, no npm install, no node_modules. Installing Hugo is a single binary — it just works.
Page bundles. Hugo lets each post live in its own folder containing both the markdown and its images — much easier than putting images somewhere else and referencing them with absolute paths.
How Hugo works — from markdown to website
content/ hugo.toml
post/ (config)
java-threads/ │
index.md ──────────────────┤
main.jpg │
page/ │
about/index.md ──────────────┤
▼
themes/blog/ Hugo build engine
layouts/ │
_default/ │ 1. Read config
baseof.html ────────────────┤ 2. Walk content tree
single.html ────────────────┤ 3. Parse markdown → HTML
list.html ──────────────────┤ 4. Apply templates
assets/ │ 5. Process assets (CSS, JS)
css/main.css ──────────────────┤ 6. Write output
js/main.js ────────────────────┘
│
▼
public/
post/
java-threads/
index.html ← Complete HTML, ready to serve
page/
about/index.html
index.html
index.json ← Search index
When you run hugo --minify, the engine:
- Reads
hugo.tomlfor config (base URL, theme, pagination size…) - Walks the entire
content/folder and parses front matter (TOML between+++) - Converts markdown to HTML using the Goldmark renderer
- Finds the right layout template in
themes/blog/layouts/and renders it - Processes CSS/JS through Hugo Pipes (minify, fingerprint)
- Writes all output to
public/
No step needs the internet or a database. Everything happens locally.
How content is organized
Page bundles — each post is a folder
content/
├── post/
│ ├── java-threads-05-2026/
│ │ ├── index.vi.md ← Vietnamese content
│ │ ├── index.en.md ← English translation (if exists)
│ │ └── main.jpg ← Cover image (same folder)
│ ├── database-optimization-java-06-2026/
│ │ └── index.vi.md
│ └── the-wind-rises-10-2025/
│ ├── index.vi.md
│ ├── main.jpg
│ └── gallery-1.jpg ← Image used in gallery shortcode
└── page/
├── about/
│ ├── index.vi.md
│ ├── index.en.md
│ └── index.ja.md
├── archives/
│ ├── index.vi.md
│ ├── index.en.md
│ └── index.ja.md
└── search/
├── index.vi.md
├── index.en.md
└── index.ja.md
The benefit of page bundles: images live right next to the post, referenced with a simple filename like . Hugo resolves the path correctly at build time. The language suffix in the filename (index.vi.md, index.en.md, index.ja.md) lets multiple translations live in one bundle — Hugo knows which file belongs to which language automatically.
Front matter — metadata for each post
Every post starts with TOML front matter between +++:
+++
author = "Khang Nghiem"
title = "Everything About Threads in Java"
date = "2026-05-31"
description = "Explaining threads from a senior engineer's perspective..."
categories = ["Engineering"]
tags = ["java", "concurrency", "deep-dive"]
image = "main.jpg"
draft = false
+++
Hugo uses the front matter to:
- Sort posts by
dateon list pages - Build taxonomy pages (
/categories/engineering/,/tags/java/) - Render
descriptioninto<meta>tags for SEO - Display the cover image from
image
draft = true lets you write a post without publishing it — Hugo skips drafts in production builds, but hugo server --buildDrafts still shows them locally.
Hugo taxonomies — category and tag pages for free
When a post has categories = ["Engineering"], Hugo automatically:
- Creates
/categories/— a page listing all categories - Creates
/categories/engineering/— a page listing all Engineering posts
No extra work needed. Just create the matching layout templates in themes/blog/layouts/categories/:
list.html— for/categories/(tag cloud)term.html— for/categories/<name>/(post list)
Same pattern for /tags/.
Shortcodes — extend markdown with custom HTML
Plain markdown does not support things like YouTube embeds or image grids. Hugo lets you write shortcodes — custom template tags you can use inside markdown.
YouTube shortcode — privacy-friendly embed:
{{< youtube dQw4w9WgXcQ >}}
Template at themes/blog/layouts/shortcodes/youtube.html:
<div class="video-wrapper">
<iframe
src="https://www.youtube-nocookie.com/embed/{{ .Get 0 }}"
loading="lazy"
allowfullscreen>
</iframe>
</div>
Using youtube-nocookie.com instead of youtube.com means the video does not track visitors until they actually press play.
Gallery shortcode — image grid:
{{< gallery cols="3" >}}



{{< /gallery >}}
Cytoscape shortcode — interactive graph diagrams, used in technical posts:
{{< cytoscape height="420" >}}
{
"nodes": [
{ "id": "n1", "label": "Producer", "type": "producer" },
{ "id": "n2", "label": "Kafka Topic", "type": "topic" },
{ "id": "n3", "label": "Consumer", "type": "consumer" }
],
"edges": [
{ "source": "n1", "target": "n2" },
{ "source": "n2", "target": "n3" }
]
}
{{< /cytoscape >}}
Cytoscape.js only loads on pages that use this shortcode — it does not affect the performance of any other page.
Custom Theme — Built from Scratch
I did not use an existing theme. The entire themes/blog/ was written from zero.
Why not use an existing theme?
Pre-built themes are usually bloated (lots of JS you never use), hard to customize deeply, and rely on many dependencies. Building from scratch gives full control — no CSS that isn’t needed, no JS that I can’t explain.
Template structure
themes/blog/layouts/
├── _default/
│ ├── baseof.html ← Shared shell for all pages
│ ├── single.html ← Single post layout
│ ├── list.html ← Post list layout (categories/tags)
│ └── _markup/
│ ├── render-image.html ← Hook: auto-converts images to WebP
│ └── render-codeblock-mermaid.html
├── index.html ← Homepage
├── 404.html
├── categories/
│ ├── list.html ← /categories/ (tag cloud)
│ └── term.html ← /categories/<name>/ (post list)
├── tags/
│ ├── list.html
│ └── term.html
├── page/
│ └── archives.html ← /page/archives/ (search + browse tabs)
├── partials/
│ ├── head.html ← <head> with meta tags, fonts
│ ├── header.html ← Navigation
│ ├── footer.html ← Footer with websitecarbon badge
│ ├── post-card.html ← Card component for list view
│ ├── toc.html ← Table of contents
│ └── pagination.html
└── shortcodes/
├── youtube.html
├── gallery.html
└── cytoscape.html
baseof.html — shared shell
baseof.html is the root template that all pages inherit from. It defines the basic structure and the blocks that child templates can override:
<!DOCTYPE html>
<html lang="{{ .Site.Language.Lang }}">
<head>
{{- partial "head.html" . -}}
{{- block "head-extra" . }}{{- end }}
</head>
<body data-theme="light">
{{- partial "header.html" . -}}
<main>
{{- block "main" . }}{{- end }}
</main>
{{- partial "footer.html" . -}}
<script src="{{ .Site.BaseURL }}js/main.js" defer></script>
</body>
</html>
The head-extra block lets single.html inject a preload link for the cover image into <head>:
{{- define "head-extra" -}}
{{ with .Params.image }}
<link rel="preload" as="image"
href="{{ ($.Page.Resources.GetMatch .) | images.Process "1200x675 webp q85" | .RelPermalink }}"
fetchpriority="high">
{{ end }}
{{- end -}}
fetchpriority="high" combined with preload ensures the cover image loads as early as possible, improving LCP (Largest Contentful Paint).
Render hook — automatic image conversion to WebP
This is a lesser-known Hugo feature: render hooks. When Hugo encounters a markdown image , it uses the template at _markup/render-image.html to render it instead of the default <img> tag.
The template automatically:
- Processes the image through Hugo’s image pipeline → WebP
- Creates a
srcsetwith two sizes (660w and 1320w) - Adds
loading="lazy"anddecoding="async" - Adds explicit width/height to prevent layout shift
{{- $img := .Page.Resources.GetMatch .Destination -}}
{{- if $img -}}
{{- $small := $img | images.Process "660x webp q85" -}}
{{- $large := $img | images.Process "1320x webp q85" -}}
<figure>
<img
src="{{ $small.RelPermalink }}"
srcset="{{ $small.RelPermalink }} 660w, {{ $large.RelPermalink }} 1320w"
sizes="(max-width: 720px) 660px, 1320px"
alt="{{ .Text }}"
width="{{ $small.Width }}"
height="{{ $small.Height }}"
loading="lazy"
decoding="async">
{{ with .Text }}<figcaption>{{ . }}</figcaption>{{ end }}
</figure>
{{- end -}}
This means I write normal markdown, and Hugo handles all image optimization — no Squoosh, no manual ImageMagick, no remembering to convert to WebP.
CSS — one file with CSS custom properties
All styling lives in assets/css/main.css. No preprocessor, no PostCSS, no framework — just plain CSS with custom properties (CSS variables) for theming.
/* Light mode tokens */
:root {
--bg: #FAFAFA;
--surface: #FFFFFF;
--fg: #111111;
--fg-2: #52525B;
--fg-3: #A1A1AA;
--accent: #7C3AED; /* Violet — links, active states */
--wide-w: 980px; /* Container width */
--prose-w: 660px; /* Article reading width */
--font-serif: 'Newsreader', Georgia, serif;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
/* Dark mode — only override what changes */
[data-theme="dark"] {
--bg: #0F0F0F;
--surface: #1A1A1A;
--fg: #F4F4F5;
--fg-2: #A1A1AA;
--fg-3: #52525B;
}
Dark mode works by toggling data-theme="dark" on <body>. Every component adapts automatically because they all use custom properties. No CSS rules need to be duplicated.
Preventing FOUC (Flash Of Unstyled Content) on page load:
<!-- In <head>, before any CSS -->
<script>
// This script runs synchronously, before the browser renders anything.
// It reads the saved preference from localStorage and sets the theme immediately.
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
</script>
Without this script, dark mode users would see a white flash when the page loads — because CSS loads after the browser has already rendered the first frame.
JavaScript — no framework
assets/js/main.js is vanilla JS, around 300 lines, handling:
- Theme toggle — reads/writes localStorage, sets
data-themeattribute - Mobile navigation — toggles the menu on small screens
- View toggle — switches between list/grid view on the homepage, persisted in localStorage
- Scroll-to-top button — appears after scrolling down
- Code copy buttons — injects a copy button into every code block, handles the clipboard API
- TOC tracker — highlights the current heading in the table of contents while scrolling
- Progress-based header — the text in the header changes based on reading progress: “Enjoy the read.” → “Almost there.” → “Thank you for reading.”
No jQuery, no Alpine.js, nothing outside standard Web APIs. Hugo serves the JS file separately with defer, keeping the main thread free while parsing.
Multilingual — Hugo Multilingual Mode
The blog supports three languages (Vietnamese /vi/, English /en/, Japanese /ja/) using Hugo’s built-in multilingual mode — no plugins or extra libraries needed.
Configuration in hugo.toml:
defaultContentLanguage = "vi"
defaultContentLanguageInSubdir = true # All languages get a URL prefix
[languages]
[languages.vi]
languageCode = "vi"
languageName = "Tiếng Việt"
weight = 1
[languages.en]
languageCode = "en"
languageName = "English"
weight = 2
[languages.ja]
languageCode = "ja"
languageName = "日本語"
weight = 3
defaultContentLanguageInSubdir = true ensures no language is hidden at / — all content has a clear prefix.
UI strings are extracted from templates into i18n/vi.toml, i18n/en.toml, i18n/ja.toml. Templates use {{ i18n "key" }} instead of hardcoded text:
# i18n/vi.toml
[recent_posts]
other = "Bài viết gần đây"
[min_read]
other = "phút đọc"
The language switcher in the header shows VI/EN/JA. It uses Hugo’s .AllTranslations to know which translations exist — languages with a translation get a link, languages without one are grayed out:
{{ range slice "vi" "en" "ja" }}
{{ $match := index (where $.AllTranslations "Lang" .) 0 }}
{{ if eq . $.Site.Language.Lang }}
<span class="lang-btn is-active">{{ upper . }}</span>
{{ else if $match }}
<a href="{{ $match.RelPermalink }}" class="lang-btn">{{ upper . }}</a>
{{ else }}
<span class="lang-btn is-disabled">{{ upper . }}</span>
{{ end }}
{{ end }}
To add a translation for a post, just create index.en.md in the same folder as index.vi.md — the switcher picks it up automatically.
Search — fully client-side
No server, no Algolia, no ElasticSearch. Search works like this:
- Hugo generates
/vi/page/search/index.json,/en/page/search/index.json,/ja/page/search/index.json— each language gets its own JSON file containing only that language’s posts - When the user opens the search page,
search.jsfetches the URL from adata-search-urlattribute that Hugo pre-renders (language-aware) - Filtering by title, tags, and categories happens entirely in the browser
// search.js — core logic
async function initSearch() {
const root = document.getElementById('search-root');
const searchUrl = root?.dataset.searchUrl; // Hugo renders the correct URL per language
const response = await fetch(searchUrl);
const posts = await response.json();
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase().trim();
const results = posts.filter(post =>
post.title.toLowerCase().includes(query) ||
post.tags?.some(t => t.toLowerCase().includes(query)) ||
post.categories?.some(c => c.toLowerCase().includes(query))
);
renderResults(results);
});
}
With the current number of posts, the search JSON is under 10KB and filtering in the browser is instant. If there were thousands of posts, a different solution (like Pagefind) would make sense — but not at this scale.
Automated Deploy Pipeline
Overview
Write a post (markdown)
│
▼
git push origin main
│
▼
GitHub Actions triggers
│
┌─────▼──────────────┐
│ 1. Checkout code │
│ 2. Install Hugo │
│ 0.152.2 extended│
│ 3. hugo --minify │
│ (build → public/)│
│ 4. Upload public/ │
│ to Azure │
└─────────────────────┘
│
▼
Azure Static Web Apps
(CDN global distribution)
│
▼
khangnghiem.com live
(30–60 seconds from push to live)
GitHub Actions workflow
File .github/workflows/azure-static-web-apps-kind-field-012958600.yml:
name: Azure Static Web Apps CI/CD
on:
push:
branches:
- main # Trigger on every push to main
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main # Preview deployment for pull requests
jobs:
build_and_deploy_job:
runs-on: ubuntu-latest
steps:
# 1. Clone the repo
- uses: actions/checkout@v3
# 2. Install the correct Hugo version
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: '0.152.2'
extended: true # Extended version needed for image processing
# 3. Build the site
- name: Build Hugo site
run: hugo --minify # Minify HTML, CSS, JS output
# 4. Upload public/ to Azure
- name: Deploy to Azure Static Web Apps
uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: "upload"
app_location: "public" # Build output folder
skip_app_build: true # Important: prevents Azure from re-building with Oryx
skip_api_build: true
Why is skip_app_build: true necessary?
Azure Static Web Apps has its own build engine called Oryx. Without this flag, Oryx would try to detect the framework and rebuild everything — but it does not know Hugo, so it would either fail or produce the wrong output. By building with Hugo in GitHub Actions first and uploading the already-built public/ folder, Azure only needs to serve the files without doing any additional building.
Preview deployments for Pull Requests:
Every pull request automatically gets its own preview URL (e.g. https://kind-field-012958600-123.westus2.azurestaticapps.net). Useful for reviewing new posts before publishing. When the PR is merged, the preview deployment is automatically deleted.
Hugo version pinned:
hugo-version: '0.152.2' ensures the build always uses the same version — avoids “works on my machine” problems when the GitHub runner has a different Hugo version than your local machine.
Azure Static Web Apps — why I chose it
Free for static sites. Azure Static Web Apps has a free tier that includes:
- Hosting static files (HTML, CSS, JS, images)
- Global CDN distribution
- Automatic HTTPS with custom domain
- Preview environments for pull requests
Custom domain and automatic HTTPS. After pointing the DNS for khangnghiem.com to Azure, Azure automatically provisions and renews an SSL certificate via Let’s Encrypt — no extra configuration needed.
Global CDN. Files are distributed across many Azure edge locations around the world. Visitors get files from the nearest edge, instead of waiting for a round-trip to the US.
Security headers and URL redirects — staticwebapp.config.json
Azure reads this file to inject headers and handle routing. In addition to security headers, there are redirect rules to forward old pre-i18n URLs to the correct new ones:
{
"routes": [
{ "route": "/", "redirect": "/vi/", "statusCode": 301 },
{ "route": "/post/*", "redirect": "/vi/post/*", "statusCode": 301 },
{ "route": "/page/*", "redirect": "/vi/page/*", "statusCode": 301 },
{ "route": "/categories/*","redirect": "/vi/categories/*", "statusCode": 301 },
{ "route": "/tags/*", "redirect": "/vi/tags/*", "statusCode": 301 }
],
"globalHeaders": {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "SAMEORIGIN",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "
default-src 'self';
script-src 'self' 'unsafe-inline'
https://www.googletagmanager.com
https://unpkg.com
https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline'
https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
frame-src
https://www.youtube-nocookie.com
https://www.youtube.com;
connect-src 'self'
https://www.google-analytics.com
https://analytics.google.com
https://api.websitecarbon.com
"
}
}
CSP (Content Security Policy) prevents XSS by only allowing scripts, styles, and fonts to load from whitelisted domains. Every external domain must be declared explicitly — Google Analytics, Google Fonts, Cytoscape from jsDelivr, the websitecarbon badge from unpkg.
Daily Writing Workflow
When I want to write a new post, the process looks like this:
# 1. Scaffold a new post with the Hugo CLI
hugo new post/post-name-06-2026/index.vi.md
# Hugo creates the file with a front matter template
# Add index.en.md or index.ja.md in the same folder when ready to translate
# 2. Start the dev server with live reload
hugo server --buildDrafts
# → localhost:1313
# Save a file → browser refreshes instantly
# 3. When the post is ready, set draft = false and commit
git add content/post/post-name-06-2026/
git commit -m "Add post about X"
git push
# 4. GitHub Actions runs automatically, post is live in ~30–60 seconds
Hugo’s dev server is extremely fast — markdown changes are rebuilt and the browser refreshes in under 100ms. Because there is no JavaScript bundler or TypeScript compilation, the feedback loop is almost instant.
What this stack does NOT have
No CMS. No WordPress, no Contentful, no Strapi. Content is plain text markdown files in a git repo. Pros: offline writing, version control, full diff history for every change. Cons: no WYSIWYG editor, no GUI for people who don’t know git or markdown.
No server-side rendering. All HTML is generated at build time. No Express.js, no Spring Boot, no request processing. Pros: no runtime failures, no server costs, near-zero TTFB (just CDN latency). Cons: no dynamic content (comments, user authentication, personalization).
No database. Content lives in markdown files. No PostgreSQL, no MongoDB, no Redis. Nothing to back up beyond the git repo.
No CI test suite. A failed build equals a failed deployment — good enough for a personal blog. A team project would need more.
Conclusion
This stack fits the use case perfectly: a personal technical blog, written in markdown, with no need for dynamic features. Hosting cost: $0. Maintenance time: nearly zero (no dependency updates except the Hugo version). Deployment: fully automated after the initial setup.
If you want to do something similar, here is the order of steps:
- Install Hugo — one binary, nothing else needed
- Create a GitHub repo — push your code
- Create an Azure Static Web Apps resource — free tier, connect it to your GitHub repo
- Azure auto-generates a workflow file — edit it to build Hugo before uploading
- Point your custom domain — Azure handles the SSL
The whole thing, from nothing to a live website, takes about 30–60 minutes.