Mình xây dựng blog này như thế nào — Hugo, custom theme, và tự động deploy lên Azure
Mục lục
Blog này chạy hoàn toàn miễn phí — không có managed server, không có database, không có runtime. Một file markdown mới được push lên GitHub, vài chục giây sau nó xuất hiện trực tiếp tại khangnghiem.com. Không cần SSH vào server, không cần restart gì, không có bill hàng tháng cho hosting.
Bài này giải thích toàn bộ stack: Hugo là gì và hoạt động thế nào, cách tổ chức content, custom theme được viết từ đầu, hỗ trợ đa ngôn ngữ (Tiếng Việt, English, 日本語), và pipeline CI/CD tự động qua GitHub Actions + Azure Static Web Apps.
Hugo là gì và tại sao mình chọn nó
Hugo là static site generator — một chương trình nhận markdown files làm input và output ra HTML, CSS, JS thuần. Không có server-side rendering, không có database, không có runtime dependencies. Kết quả là một thư mục public/ chứa các file tĩnh có thể serve từ bất kỳ CDN hoặc web server nào.
mình chọn Hugo vì ba lý do:
Tốc độ build. Hugo build toàn bộ site trong vài giây, bất kể số lượng bài. Blog hiện tại với hơn 10 bài post build xong trong < 500ms. Jekyll (Ruby) hay Next.js đều chậm hơn đáng kể ở scale lớn.
Không có JavaScript runtime. Không có Node.js, không có npm install, không có node_modules. Cài Hugo là một binary duy nhất, chạy được ngay.
Page bundles. Hugo cho phép mỗi bài post là một thư mục riêng chứa cả markdown lẫn ảnh — tiện lợi hơn nhiều so với đặt ảnh riêng và reference bằng đường dẫn tuyệt đối.
Cách Hugo hoạt động — từ markdown đến website
content/ hugo.toml
post/ (config)
java-threads/ │
index.md ──────────────────┤
main.jpg │
page/ │
about/index.md ──────────────┤
▼
themes/blog/ Hugo build engine
layouts/ │
_default/ │ 1. Đọc 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 ← HTML hoàn chỉnh, sẵn sàng serve
page/
about/index.html
index.html
index.json ← Search index
Khi chạy hugo --minify, engine:
- Đọc
hugo.tomlđể lấy config (base URL, theme, pagination size…) - Walk toàn bộ thư mục
content/và parse front matter (TOML giữa+++) - Chuyển đổi markdown thành HTML với Goldmark renderer
- Tìm layout template phù hợp trong
themes/blog/layouts/và render - Process CSS/JS qua Hugo Pipes (minify, fingerprint)
- Ghi toàn bộ output ra
public/
Không có bước nào cần internet hay database. Mọi thứ xảy ra locally.
Cách tổ chức content
Page bundles — mỗi bài post là một thư mục
content/
├── post/
│ ├── java-threads-05-2026/
│ │ ├── index.vi.md ← Nội dung tiếng Việt
│ │ ├── index.en.md ← Bản dịch tiếng Anh (nếu có)
│ │ └── main.jpg ← Ảnh cover (cùng thư mục)
│ ├── database-optimization-java-06-2026/
│ │ └── index.vi.md
│ └── the-wind-rises-10-2025/
│ ├── index.vi.md
│ ├── main.jpg
│ └── gallery-1.jpg ← Ảnh trong 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
Ưu điểm của page bundle: ảnh nằm cùng bài, reference bằng tên file đơn giản như . Hugo tự resolve path đúng khi build. Suffix ngôn ngữ trong tên file (index.vi.md, index.en.md, index.ja.md) cho phép nhiều bản dịch cùng nằm trong một bundle — Hugo tự biết file nào thuộc ngôn ngữ nào.
Front matter — metadata của mỗi bài
Mỗi bài post bắt đầu bằng TOML front matter giữa +++:
+++
author = "Khang Nghiem"
title = "Tất cả về Thread trong Java"
date = "2026-05-31"
description = "Giải thích Thread từ góc nhìn senior engineer..."
categories = ["Engineering"]
tags = ["java", "concurrency", "deep-dive"]
image = "main.jpg"
draft = false
+++
Hugo đọc front matter để:
- Sắp xếp bài theo
datetrong list pages - Build taxonomy pages (
/categories/engineering/,/tags/java/) - Render
descriptionvào<meta>tags cho SEO - Hiển thị ảnh cover từ
image
draft = true giúp viết bài mà không publish — Hugo skip draft khi build production, nhưng hugo server --buildDrafts vẫn hiển thị locally.
Hugo taxonomies — tự động tạo category và tag pages
Khi có bài post với categories = ["Engineering"], Hugo tự động:
- Tạo
/categories/— trang danh sách tất cả categories - Tạo
/categories/engineering/— trang danh sách bài thuộc Engineering
Không cần viết gì thêm. Chỉ cần tạo layout template tương ứng trong themes/blog/layouts/categories/:
list.html—/categories/(tag cloud)term.html—/categories/<name>/(danh sách bài)
Tương tự cho /tags/.
Shortcodes — extend markdown với HTML tùy chỉnh
Markdown thuần không support một số thứ như YouTube embed hay gallery grid. Hugo cho phép viết shortcodes — custom template tags dùng được trong markdown.
YouTube shortcode — privacy-friendly embed:
{{< youtube dQw4w9WgXcQ >}}
Template tại 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>
Dùng youtube-nocookie.com thay vì youtube.com để không track user khi chưa play.
Gallery shortcode — grid ảnh:
{{< gallery cols="3" >}}



{{< /gallery >}}
Cytoscape shortcode — interactive graph diagram, dùng cho các bài technical:
{{< 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 chỉ được load trên những trang dùng shortcode này — không ảnh hưởng performance của các trang khác.
Custom Theme — Viết từ Đầu
mình không dùng theme có sẵn. Toàn bộ themes/blog/ được viết từ đầu.
Tại sao không dùng theme có sẵn?
Theme có sẵn thường: nặng (nhiều JS không dùng), khó customize sâu, phụ thuộc vào nhiều dependencies. Viết từ đầu cho phép kiểm soát hoàn toàn — không có CSS nào không cần thiết, không có JS nào không biết nó làm gì.
Cấu trúc template
themes/blog/layouts/
├── _default/
│ ├── baseof.html ← Shell chung cho tất cả pages
│ ├── single.html ← Layout bài post đơn
│ ├── list.html ← Layout danh sách bài (categories/tags)
│ └── _markup/
│ ├── render-image.html ← Hook: tự động convert ảnh sang WebP
│ └── render-codeblock-mermaid.html
├── index.html ← Homepage
├── 404.html
├── categories/
│ ├── list.html ← /categories/ (tag cloud)
│ └── term.html ← /categories/<name>/ (danh sách bài)
├── tags/
│ ├── list.html
│ └── term.html
├── page/
│ └── archives.html ← /page/archives/ (search + browse tabs)
├── partials/
│ ├── head.html ← <head> với meta tags, fonts
│ ├── header.html ← Navigation
│ ├── footer.html ← Footer với websitecarbon badge
│ ├── post-card.html ← Card component cho list view
│ ├── toc.html ← Table of contents
│ └── pagination.html
└── shortcodes/
├── youtube.html
├── gallery.html
└── cytoscape.html
baseof.html — Shell chung
baseof.html là template gốc mà tất cả pages kế thừa. Nó define structure cơ bản và các block mà child templates có thể 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>
Block head-extra cho phép single.html inject preload link cho cover image vào <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" + preload đảm bảo ảnh cover được tải sớm nhất có thể, cải thiện LCP (Largest Contentful Paint).
Render hook — tự động convert ảnh sang WebP
Đây là tính năng Hugo ít được biết đến: render hooks. Khi Hugo gặp markdown image , nó dùng template tại _markup/render-image.html để render thay vì default <img> tag.
Template của mình tự động:
- Process ảnh qua Hugo image processing → WebP
- Tạo
srcsetvới 2 kích thước (660w và 1320w) - Thêm
loading="lazy"vàdecoding="async" - Thêm explicit width/height để tránh 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 -}}
Điều này nghĩa là mình viết markdown bình thường, Hugo tự lo optimize ảnh — không cần Squoosh, không cần ImageMagick thủ công, không cần nhớ convert sang WebP.
CSS — Một file duy nhất với CSS custom properties
Toàn bộ styling nằm trong assets/css/main.css. Không có preprocessor, không có PostCSS, không có framework — chỉ là CSS thuần với custom properties (CSS variables) cho 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 — chỉ override những gì thay đổi */
[data-theme="dark"] {
--bg: #0F0F0F;
--surface: #1A1A1A;
--fg: #F4F4F5;
--fg-2: #A1A1AA;
--fg-3: #52525B;
}
Dark mode hoạt động bằng cách toggle data-theme="dark" trên <body>. Tất cả component tự động adapt vì chúng dùng custom properties. Không cần duplicate CSS rules.
Tránh FOUC (Flash Of Unstyled Content) khi load trang:
<!-- Trong <head>, trước bất kỳ CSS nào -->
<script>
// Script này chạy đồng bộ, trước khi browser render bất kỳ thứ gì
// Đọc preference từ localStorage và set theme ngay lập tức
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>
Nếu không có script này, user thấy flash trắng trong dark mode khi page load — vì CSS loaded sau khi browser đã render frame đầu tiên.
JavaScript — Không có framework
assets/js/main.js là vanilla JS, khoảng 300 dòng, handle:
- Theme toggle — đọc/ghi localStorage, set
data-themeattribute - Mobile navigation — toggle menu trên màn hình nhỏ
- View toggle — chuyển đổi list/grid view trên homepage, persist vào localStorage
- Scroll-to-top button — hiển thị sau khi scroll xuống
- Code copy buttons — inject copy button vào mọi code block, handle clipboard API
- TOC tracker — highlight heading hiện tại trong table of contents khi scroll
- Progress-based header — text trong header thay đổi theo % đã đọc bài: “Enjoy the read.” → “Almost there.” → “Thank you for reading.”
Không có jQuery, không có Alpine.js, không có gì ngoài Web APIs chuẩn. Bundle size của JS là 0 bytes sau khi minify vì Hugo Pipes inline nó… thực ra Hugo serve JS file riêng với defer, giữ main thread free trong khi parse.
Đa ngôn ngữ — Hugo Multilingual Mode
Blog hỗ trợ ba ngôn ngữ (Tiếng Việt /vi/, English /en/, 日本語 /ja/) bằng Hugo built-in multilingual mode — không cần plugin hay thư viện nào thêm.
Cấu hình trong hugo.toml:
defaultContentLanguage = "vi"
defaultContentLanguageInSubdir = true # Tất cả ngôn ngữ đều có 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 đảm bảo không có ngôn ngữ nào “ẩn” tại / — mọi content đều có prefix rõ ràng.
UI strings được tách ra khỏi template vào i18n/vi.toml, i18n/en.toml, i18n/ja.toml. Trong template dùng {{ i18n "key" }} thay vì hardcode text:
# i18n/vi.toml
[recent_posts]
other = "Bài viết gần đây"
[min_read]
other = "phút đọc"
Language switcher trong header hiển thị VI/EN/JA. Dùng .AllTranslations của Hugo để biết bản dịch nào tồn tại — ngôn ngữ có bản dịch thì hiện link, không có thì 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 }}
Để thêm bản dịch cho một bài, chỉ cần tạo index.en.md cùng thư mục với index.vi.md — switcher tự động nhận ra và activate link.
Search — Hoạt động hoàn toàn client-side
Không có server, không có Algolia, không có ElasticSearch. Search hoạt động bằng cách:
- Hugo generate
/vi/page/search/index.json,/en/page/search/index.json,/ja/page/search/index.json— mỗi ngôn ngữ có file JSON riêng chỉ chứa bài của ngôn ngữ đó - Khi user mở trang search,
search.jsfetch URL từdata-search-urlattribute được Hugo render sẵn (language-aware) - Filter theo title, tags, categories hoàn toàn trong browser
// search.js — core logic
async function initSearch() {
const root = document.getElementById('search-root');
const searchUrl = root?.dataset.searchUrl; // Hugo đã render đúng URL theo ngôn ngữ
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);
});
}
Với số lượng bài hiện tại, search JSON < 10KB và filter trong browser là instant. Nếu có hàng nghìn bài thì cần giải pháp khác (Pagefind chẳng hạn), nhưng ở scale này không cần.
Pipeline Deploy Tự Động
Tổng quan flow
Viết bài (markdown)
│
▼
git push origin main
│
▼
GitHub Actions trigger
│
┌─────▼──────────────┐
│ 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 giây từ push đến 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 mỗi khi push lên main
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main # Preview deployment cho pull requests
jobs:
build_and_deploy_job:
runs-on: ubuntu-latest
steps:
# 1. Clone repo
- uses: actions/checkout@v3
# 2. Cài Hugo binary đúng version
- name: Setup Hugo
uses: peaceiris/actions-hugo@v2
with:
hugo-version: '0.152.2'
extended: true # Extended version cho image processing
# 3. Build site
- name: Build Hugo site
run: hugo --minify # Minify HTML, CSS, JS output
# 4. Upload public/ lên 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" # Thư mục build output
skip_app_build: true # Quan trọng: ngăn Azure build lại bằng Oryx
skip_api_build: true
skip_app_build: true — tại sao cần thiết?
Azure Static Web Apps có build engine riêng (Oryx). Nếu không set flag này, Oryx sẽ cố detect framework và build lại — nhưng nó không biết Hugo, sẽ fail hoặc produce output sai. Bằng cách build Hugo trong GitHub Actions trước và upload folder public/ đã build sẵn, Azure chỉ cần serve files mà không cần build thêm gì.
Preview deployments cho Pull Requests:
Mỗi pull request tự động có một preview URL riêng (ví dụ https://kind-field-012958600-123.westus2.azurestaticapps.net). Hữu ích để review bài viết mới trước khi publish. Khi PR được merge, preview deployment tự động bị xóa.
Hugo version pinned:
hugo-version: '0.152.2' đảm bảo build luôn dùng cùng version — tránh “works on my machine” khi GitHub runner có Hugo version khác với local.
Azure Static Web Apps — Tại sao chọn nó?
Miễn phí cho static sites. Azure Static Web Apps có free tier với:
- Hosting static files (HTML, CSS, JS, ảnh)
- Global CDN distribution
- Tự động HTTPS với custom domain
- Preview environments cho pull requests
Custom domain và HTTPS tự động. Sau khi trỏ DNS của khangnghiem.com về Azure, Azure tự cấp và renew SSL certificate qua Let’s Encrypt — không cần cấu hình gì thêm.
Global CDN. Files được distribute trên nhiều Azure edge locations trên thế giới. User ở Việt Nam lấy file từ edge gần nhất thay vì phải đợi round-trip về US.
Security headers và URL redirects — staticwebapp.config.json
File này được Azure đọc để inject headers và xử lý routing. Ngoài security headers, mình còn có redirect rules để forward các URL cũ (trước khi có i18n) sang đúng URL mới:
{
"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) ngăn XSS bằng cách chỉ cho phép load scripts, styles, fonts từ các domain được whitelist. Mỗi domain external phải được khai báo tường minh — Google Analytics, Google Fonts, Cytoscape từ jsDelivr, websitecarbon badge từ unpkg.
Workflow Viết Bài Hàng Ngày
Khi muốn viết bài mới, quy trình như sau:
# 1. Scaffold bài mới với Hugo CLI
hugo new post/ten-bai-06-2026/index.vi.md
# Hugo tạo file với front matter template
# Thêm index.en.md hoặc index.ja.md cùng thư mục khi muốn dịch
# 2. Chạy dev server với live reload
hugo server --buildDrafts
# → localhost:1313
# Lưu file → browser tự refresh ngay
# 3. Khi bài xong, set draft = false và commit
git add content/post/ten-bai-06-2026/
git commit -m "Add bài viết về X"
git push
# 4. GitHub Actions tự chạy, ~30-60 giây sau bài live
Dev server của Hugo cực kỳ nhanh — thay đổi markdown được rebuild và browser refreshed trong < 100ms. Vì không có JavaScript bundler hay TypeScript compilation, feedback loop gần như instant.
Những thứ không có trong stack này
Không có CMS. Không có WordPress, không có Contentful, không có Strapi. Content là plain text markdown files trong git repo. Ưu điểm: offline writing, version control, diff history cho mọi thay đổi. Nhược điểm: không có WYSIWYG editor, không có GUI cho người không biết git/markdown.
Không có server-side rendering. Tất cả HTML được generate tại build time. Không có Express.js, không có Spring Boot, không có request processing. Ưu điểm: không có runtime failure, không có server costs, TTFB gần như zero (chỉ CDN latency). Nhược điểm: không có dynamic content (comments, user authentication, personalization).
Không có database. Content sống trong markdown files. Không có PostgreSQL, không có MongoDB, không có Redis. Không có gì để backup ngoài git repo.
Không có CI test suite. Build fail = deployment fail, đủ rồi cho blog cá nhân. Với team thì cần thêm.
Kết luận
Stack này phù hợp hoàn toàn với use case: blog cá nhân kỹ thuật, viết bằng markdown, không cần dynamic features. Chi phí hosting: $0. Thời gian maintain: gần bằng 0 (không có dependency updates trừ Hugo version). Deployment: tự động hoàn toàn sau khi setup lần đầu.
Nếu bạn muốn làm tương tự, thứ tự setup:
- Cài Hugo — một binary, không cần gì khác
- Tạo repo GitHub — push code lên
- Tạo Azure Static Web Apps resource — free tier, connect với GitHub repo
- Azure tự generate workflow file — chỉnh lại để build Hugo trước khi upload
- Trỏ custom domain — Azure lo SSL
Toàn bộ từ không có gì đến có website live mất khoảng 30-60 phút.