WordPress powers over 40% of the web. It is also slow, constantly targeted by bots, and requires regular maintenance just to keep the lights on. Plugin updates, database backups, PHP version conflicts, security patches — the overhead is real.
What if your blog was just a folder of HTML files? No database, no PHP, no login page for attackers to brute-force. That is what a static site generator gives you.
This guide walks through building a production-ready blog with Hugo, managing content with Obsidian, version-controlling everything with Git, and auto-deploying to a shared host like Hostinger via GitHub Actions. We will also cover search, speed optimization, and SEO — the things that actually matter once your site is live.
Why Static Over WordPress?
| Aspect | WordPress | Static (Hugo) |
|---|---|---|
| Speed | Database queries on every page load | Pre-built HTML, served instantly |
| Security | Constant attack surface (PHP, plugins, admin panel) | No server-side code, nothing to exploit |
| Hosting cost | Needs PHP + MySQL (or managed WP hosting) | Any cheap shared host, or free (GitHub Pages, Netlify) |
| Maintenance | Plugin updates, DB backups, PHP upgrades | Zero — it is just files |
| Build time | N/A (dynamic) | ~500ms for hundreds of pages |
The tradeoff: you lose the WordPress admin panel and plugin ecosystem. But if your site is primarily content (a blog, documentation, a portfolio), you do not need any of that.
Prerequisites
- Hugo (extended version) — the static site generator
- Git — version control
- A GitHub account — for the repository and CI/CD
- A text editor — we will use Obsidian, but VS Code or anything else works
- A web host — Hostinger, Netlify, GitHub Pages, or any host that serves static files
Step 1: Install Hugo
Hugo is a single binary. No dependencies, no runtime — just download and run.
Linux:
# Download Hugo extended (check https://github.com/gohugoio/hugo/releases for latest)
wget https://github.com/gohugoio/hugo/releases/download/v0.142.0/hugo_extended_0.142.0_linux-amd64.tar.gz
tar -xzf hugo_extended_0.142.0_linux-amd64.tar.gz
mv hugo ~/.local/bin/
# Verify
hugo version
macOS (Homebrew):
brew install hugo
Windows (Scoop):
scoop install hugo-extended
You need the extended version for SCSS/SASS support and Hugo Pipes (CSS processing). The standard version will not work with some themes and the inline CSS technique we cover later.
Step 2: Create the Site
hugo new site my-blog
cd my-blog
git init
This creates the Hugo project structure:
my-blog/
├── archetypes/ # Templates for new content
├── content/ # Your posts and pages live here
├── data/ # Data files (JSON, YAML, TOML)
├── layouts/ # HTML templates (overrides theme)
├── static/ # Static files (images, fonts, robots.txt)
├── themes/ # Downloaded or custom themes
└── hugo.toml # Main configuration file
Step 3: Configure Hugo
Edit hugo.toml — this is the brain of your site:
baseURL = 'https://yourdomain.com/'
languageCode = 'en'
title = 'My Tech Blog'
theme = 'my-theme'
defaultContentLanguage = 'en'
summaryLength = 40
[pagination]
pagerSize = 10
[params]
description = 'Tech notes and tutorials'
author = 'Your Name'
[menu]
[[menu.main]]
name = 'Linux'
url = '/categories/linux/'
weight = 1
[[menu.main]]
name = 'Web'
url = '/categories/web/'
weight = 2
[[menu.main]]
name = 'Contact'
url = '/page/contact/'
weight = 3
[taxonomies]
category = 'categories'
tag = 'tags'
[markup]
[markup.goldmark]
[markup.goldmark.renderer]
unsafe = true
[outputs]
home = ['HTML', 'RSS']
Key settings:
baseURL: Your production domain. Hugo uses this for canonical URLs and sitemap generation.taxonomies: Categories and tags are built-in. Hugo auto-generates listing pages for each.unsafe = true: Allows raw HTML in Markdown files (useful for embeds, custom elements).outputs: Generates both HTML pages and an RSS feed.
Step 4: Build a Custom Theme
You can grab a pre-built theme from themes.gohugo.io, but building your own gives full control and avoids dependency on third-party updates.
hugo new theme my-theme
The Base Layout
Every page on your site renders through themes/my-theme/layouts/_default/baseof.html:
<!DOCTYPE html>
<html lang="{{ .Site.LanguageCode }}">
<head>
{{ partial "head.html" . }}
</head>
<body>
{{ partial "header.html" . }}
<main class="site-main">
<div class="container layout">
<div class="content">
{{ block "main" . }}{{ end }}
</div>
{{ partial "sidebar.html" . }}
</div>
</main>
{{ partial "footer.html" . }}
</body>
</html>
Hugo uses partials (reusable template fragments) and blocks (overridable sections). The {{ block "main" . }} is replaced by each page type’s template.
The Single Post Template
themes/my-theme/layouts/_default/single.html renders individual blog posts:
{{ define "main" }}
<article class="post-single" data-pagefind-body>
<header class="post-header">
<div class="post-meta">
<time datetime="{{ .Date.Format "2006-01-02" }}">
{{ .Date.Format "Jan 02, 2006" }}
</time>
{{ with .Params.categories }}
{{ range . }}
<a href="{{ "categories/" | relURL }}{{ . | urlize }}/"
class="post-category">{{ . }}</a>
{{ end }}
{{ end }}
{{ with .Params.readingtime }}
<span class="reading-time">{{ . }} min read</span>
{{ end }}
</div>
<h1>{{ .Title }}</h1>
</header>
{{ with .Params.image }}
<div class="post-featured-image">
{{- $img := $.Resources.GetMatch . -}}
{{- if $img -}}
<img src="{{ $img.RelPermalink }}" alt="{{ $.Title }}"
width="{{ $img.Width }}" height="{{ $img.Height }}" loading="lazy">
{{- else -}}
<img src="{{ . | relURL }}" alt="{{ $.Title }}" loading="lazy">
{{- end -}}
</div>
{{ end }}
<div class="post-content">
{{ .Content }}
</div>
{{ with .Params.tags }}
<div class="post-tags">
{{ range . }}
<a href="{{ "tags/" | relURL }}{{ . | urlize }}/" class="tag">
#{{ . }}
</a>
{{ end }}
</div>
{{ end }}
</article>
{{ end }}
A few things to note:
data-pagefind-bodyon the<article>tag tells Pagefind (our search engine) to index only the post content, not the header, sidebar, or footer.$.Resources.GetMatchlooks for the featured image inside the post’s page bundle folder. If found, Hugo automatically providesWidthandHeight— critical for preventing layout shift (CLS).loading="lazy"defers image loading until the user scrolls near them.
The Head Partial — SEO and Performance Foundation
themes/my-theme/layouts/partials/head.html is where SEO and performance start:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{{ if .IsHome }}{{ .Site.Title }}
{{ else }}{{ .Title }} - {{ .Site.Title }}{{ end }}
</title>
<meta name="description"
content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
<link rel="canonical" href="{{ .Permalink }}">
<!-- OpenGraph -->
<meta property="og:title"
content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
<meta property="og:description"
content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
<meta property="og:site_name" content="{{ .Site.Title }}">
{{ if .IsPage }}
<meta property="og:type" content="article">
<meta property="article:published_time"
content="{{ .Date.Format "2006-01-02T15:04:05Z07:00" }}">
{{ with .Params.image }}
<meta property="og:image" content="{{ . | absURL }}">
{{ end }}
{{ else }}
<meta property="og:type" content="website">
{{ end }}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title"
content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }}{{ end }}">
<meta name="twitter:description"
content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}">
<!-- Preload critical fonts -->
<link rel="preload" href="{{ "fonts/inter-400.woff2" | relURL }}"
as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ "fonts/jetbrains-mono-600.woff2" | relURL }}"
as="font" type="font/woff2" crossorigin>
<!-- Inline CSS (no external stylesheet!) -->
<style>
{{ $css := resources.Get "css/style.css" | minify }}
{{ $css.Content | safeCSS }}
</style>
<link rel="icon" href="{{ "images/favicon.svg" | relURL }}" type="image/svg+xml">
{{ with .OutputFormats.Get "RSS" }}
<link rel="alternate" type="application/rss+xml"
title="{{ $.Site.Title }}" href="{{ .RelPermalink }}">
{{ end }}
The critical technique here is inlining CSS. Instead of linking to an external stylesheet, Hugo Pipes reads style.css, minifies it, and embeds it directly in the <style> tag. This eliminates a render-blocking network request and can save 200-500ms on first paint.
Step 5: Content as Page Bundles
Hugo supports page bundles — each post is a folder containing its Markdown file and all associated assets:
content/
└── posts/
└── my-first-post/
├── index.md # The post content
├── cover.jpg # Featured image
├── screenshot-1.png # Images used in the post
└── diagram.svg # Any other assets
This keeps everything self-contained. Images are referenced with simple relative paths in Markdown:

Front Matter
Every post starts with YAML front matter:
---
title: "How to Set Up SSH Key Authentication on Linux"
date: 2026-02-10
categories: ["Linux"]
tags: ["SSH", "Security", "Server"]
image: "cover.jpg"
readingtime: 5
description: "Step-by-step guide to setting up SSH key-based authentication, disabling password login, and hardening your server."
---
descriptionfeeds into<meta name="description">, OpenGraph, and Twitter Cards. Always write one — it is the snippet Google shows in search results.imageis relative to the page bundle folder (not a full path).categoriesandtagsauto-generate taxonomy pages.
Image Render Hook — Automatic Dimensions
To prevent Cumulative Layout Shift (CLS), every image needs explicit width and height attributes. Writing them manually is tedious. Instead, create a render hook at layouts/_default/_markup/render-image.html:
{{- $src := .Destination -}}
{{- $alt := .Text -}}
{{- $width := "" -}}
{{- $height := "" -}}
{{- /* Auto-detect dimensions from page bundle resources */ -}}
{{- if not (or (hasPrefix $src "http://") (hasPrefix $src "https://")) -}}
{{- with .Page.Resources.GetMatch $src -}}
{{- $width = .Width -}}
{{- $height = .Height -}}
{{- end -}}
{{- end -}}
{{- if and $width $height -}}
<img src="{{ $src | safeURL }}" alt="{{ $alt }}"
width="{{ $width }}" height="{{ $height }}" loading="lazy">
{{- else -}}
<img src="{{ $src | safeURL }}" alt="{{ $alt }}" loading="lazy">
{{- end -}}
Now every  in your Markdown automatically gets the correct dimensions at build time. External images (URLs starting with http) gracefully fall back to no dimensions.
Step 6: Managing Content With Obsidian
Obsidian is a Markdown editor that works directly with files on disk — no proprietary format, no database. Point it at your Hugo project and you have a full writing environment with live preview, backlinks, and graph view.
Setup
- Open Obsidian and create a new vault pointing to your Hugo project root (e.g.,
~/my-blog/). - In Obsidian settings, go to Files & Links and set:
- New link format: Relative path to file
- Default location for new attachments: Same folder as current file
- Create new posts as
content/posts/my-post-slug/index.md.
Writing Workflow
Your daily workflow becomes:
- Open Obsidian — your vault is the Hugo project
- Create a new folder under
content/posts/with a URL-friendly slug - Create
index.mdinside that folder - Write your post in Markdown, paste images directly (Obsidian saves them to the same folder)
- Preview locally with
hugo server - Commit and push — GitHub Actions handles the rest
Draft Management
For posts that are not ready yet, you have two options:
Option A: Hugo’s built-in draft flag
---
title: "Work in Progress"
draft: true
---
Draft posts are excluded from production builds but visible with hugo server --buildDrafts.
Option B: A separate drafts folder
Keep a drafts/ directory at the project root. Since it is outside content/, Hugo ignores it completely. Move files to content/posts/ when ready to publish.
Step 7: Version Control With Git
Initialize Git and create a .gitignore:
git init
.gitignore:
public/
resources/_gen/
.hugo_build.lock
node_modules/
.obsidian/
public/: The build output — regenerated every time, never committed.resources/_gen/: Hugo’s asset processing cache..obsidian/: Obsidian’s configuration — personal to each machine.
Typical Git Workflow
# Write a post, then:
git add content/posts/my-new-post/
git commit -m "Add post: How to Set Up SSH Keys on Linux"
git push
Push to main and your site auto-deploys. That is it.
Step 8: Automated Deployment With GitHub Actions
This is where the magic happens. Every push to main triggers a build and deploys the result to your web host via FTP.
Create .github/workflows/deploy.yml:
name: Deploy to Hostinger
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.142.0'
extended: true
- name: Build
run: hugo --minify --buildFuture
- name: Build search index
run: npx -y pagefind --site public
- name: Deploy via FTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_HOST }}
username: ${{ secrets.FTP_USER }}
password: ${{ secrets.FTP_PASS }}
local-dir: ./public/
server-dir: /public_html/
Setting Up GitHub Secrets
Go to your GitHub repository → Settings → Secrets and variables → Actions and add:
FTP_HOST: Your hosting provider’s FTP hostname (e.g.,ftp.yourdomain.com)FTP_USER: FTP usernameFTP_PASS: FTP password
What the Pipeline Does
- Checkout: Clones your repository.
- Setup Hugo: Installs the exact Hugo version you develop with (avoids “works on my machine” issues).
- Build: Runs
hugo --minify --buildFutureto generate the site with minified HTML and include future-dated posts. - Search index: Pagefind scans the built HTML and generates the search index files.
- FTP deploy: Uploads only changed files to your web host’s
public_html/directory.
Alternative: Deploy to Netlify or GitHub Pages
If you do not want to use FTP, Netlify and GitHub Pages are free alternatives:
Netlify — just connect your GitHub repo in the Netlify dashboard and set:
- Build command:
hugo --minify - Publish directory:
public
GitHub Pages — replace the FTP step with:
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
Local Deploy Script
For manual deployments or quick testing, a shell script is handy:
#!/bin/bash
export PATH="$HOME/.local/bin:$PATH"
case "$1" in
preview)
echo "Starting local preview at http://localhost:1313/"
hugo server --bind 0.0.0.0 --baseURL http://localhost:1313 \
--disableFastRender --buildFuture
;;
publish)
echo "Building site..."
hugo --minify --buildFuture
echo "Uploading changed files..."
python3 upload.py
;;
*)
echo "Usage:"
echo " ./deploy.sh preview - Local preview (localhost:1313)"
echo " ./deploy.sh publish - Build and upload"
;;
esac
Step 9: Add Search With Pagefind
Static sites have no server-side search. Pagefind solves this by building a search index at build time and running entirely in the browser — no API, no backend.
How It Works
- After Hugo generates your HTML, Pagefind scans it and creates a compact search index.
- A small JavaScript library loads in the browser and queries this index client-side.
- Results are instant because the index is pre-built and served as static files.
Installation
Pagefind runs as an npx command — no installation needed:
# Run after hugo build
hugo --minify
npx -y pagefind --site public
This creates a public/pagefind/ directory with the search index, CSS, and JS.
Search UI
Create a search modal in layouts/partials/search.html:
<div id="search-modal" class="search-modal" aria-hidden="true">
<div class="search-container">
<div class="search-header">
<span class="search-label">Search</span>
<button class="search-close" aria-label="Close search">×</button>
</div>
<div id="search"></div>
</div>
</div>
Lazy-Loading Pagefind (Critical for Performance)
By default, Pagefind’s CSS and JS load on every page. That adds ~100KB of render-blocking resources. Instead, load them only when the user opens the search modal:
(function() {
var modal = document.getElementById('search-modal');
var searchInit = false;
var pagefindLoaded = false;
var pagefindLoading = false;
function loadPagefind(callback) {
if (pagefindLoaded) { callback(); return; }
if (pagefindLoading) {
var check = setInterval(function() {
if (pagefindLoaded) { clearInterval(check); callback(); }
}, 50);
return;
}
pagefindLoading = true;
// Load CSS dynamically
var css = document.createElement('link');
css.rel = 'stylesheet';
css.href = '/pagefind/pagefind-ui.css';
document.head.appendChild(css);
// Load JS dynamically
var script = document.createElement('script');
script.src = '/pagefind/pagefind-ui.js';
script.onload = function() {
pagefindLoaded = true;
pagefindLoading = false;
callback();
};
document.head.appendChild(script);
}
function openSearch() {
loadPagefind(function() {
modal.classList.add('open');
document.body.style.overflow = 'hidden';
if (!searchInit) {
new PagefindUI({
element: '#search',
showSubResults: false,
resetStyles: false
});
searchInit = true;
}
setTimeout(function() {
var input = modal.querySelector('.pagefind-ui__search-input');
if (input) input.focus();
}, 100);
});
}
function closeSearch() {
modal.classList.remove('open');
document.body.style.overflow = '';
}
// Keyboard shortcut: Ctrl+K
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
modal.classList.contains('open') ? closeSearch() : openSearch();
}
if (e.key === 'Escape' && modal.classList.contains('open')) {
closeSearch();
}
});
})();
This lazy-loading approach means zero performance impact on pages where the user does not search. The Pagefind assets only load when actually needed.
Controlling What Gets Indexed
In your single.html template, the data-pagefind-body attribute on <article> tells Pagefind to only index post content — not headers, footers, sidebars, or navigation. This keeps search results relevant.
Step 10: Speed Optimization
A fast site is not just about user experience — Google uses Core Web Vitals as a ranking factor. Here are the optimizations that matter most:
1. Inline CSS
Instead of linking to an external stylesheet:
<!-- Slow: extra network request, render-blocking -->
<link rel="stylesheet" href="/css/style.css">
Use Hugo Pipes to inline it:
<!-- Fast: zero network requests, instant render -->
<style>
{{ $css := resources.Get "css/style.css" | minify }}
{{ $css.Content | safeCSS }}
</style>
This eliminates a render-blocking request. For a single-file CSS blog (typically 5-15KB minified), inlining is strictly better than an external file.
2. Self-Host Fonts
Google Fonts is convenient but adds external requests and tracking. Self-hosting is faster and more private.
Download the WOFF2 files and place them in static/fonts/:
static/fonts/
├── inter-400.woff2
├── inter-700.woff2
├── jetbrains-mono-400.woff2
├── jetbrains-mono-500.woff2
└── jetbrains-mono-600.woff2
Declare them in your CSS with font-display: swap (shows fallback font immediately, swaps when loaded):
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-400.woff2') format('woff2');
}
Preload the most critical fonts (the ones used above the fold):
<link rel="preload" href="/fonts/inter-400.woff2"
as="font" type="font/woff2" crossorigin>
3. Explicit Image Dimensions
Every <img> tag needs width and height to prevent layout shift:
<!-- Bad: browser doesn't know size until image loads -->
<img src="photo.jpg" alt="Description">
<!-- Good: browser reserves exact space immediately -->
<img src="photo.jpg" alt="Description" width="800" height="450" loading="lazy">
The image render hook we set up earlier handles this automatically for all Markdown images.
4. Server-Side Optimization (.htaccess)
For Apache-based hosts like Hostinger, an .htaccess file in your static/ directory (deployed to the web root) handles compression and caching:
# Gzip compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript
AddOutputFilterByType DEFLATE application/xml application/rss+xml
AddOutputFilterByType DEFLATE font/woff2 image/svg+xml
</IfModule>
# Browser caching
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
# Security headers
<IfModule mod_headers.c>
Header set X-Frame-Options "SAMEORIGIN"
Header set X-Content-Type-Options "nosniff"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>
# HTTPS redirect
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
This gives you gzip compression (50-70% smaller transfers), aggressive caching for static assets, and basic security headers — all without touching server configuration.
Step 11: SEO Essentials
Meta Tags (Already Done)
The head.html partial we built earlier already includes:
<title>with site name<meta name="description">from front matter<link rel="canonical">to prevent duplicate content- Full OpenGraph tags (Facebook, LinkedIn)
- Twitter Card tags
robots.txt
Create static/robots.txt:
User-agent: *
Allow: /
Sitemap: https://yourdomain.com/sitemap.xml
Sitemap
Hugo generates sitemap.xml automatically — no configuration needed. It is included in your robots.txt so search engines find it immediately.
RSS Feed
The [outputs] section in hugo.toml already enables RSS. Hugo generates a feed at /index.xml that readers and aggregators can subscribe to. The <link rel="alternate" type="application/rss+xml"> tag in head.html makes it auto-discoverable.
Structured Front Matter
Always include these fields in every post:
description: "A clear, compelling summary under 160 characters"
This single field populates:
- Google search result snippets
- Social media preview cards (Facebook, Twitter, LinkedIn)
- RSS feed descriptions
The Complete Workflow
Here is the end-to-end process once everything is set up:
1. Open Obsidian → your Hugo project vault
2. Create content/posts/new-post-slug/index.md
3. Write in Markdown, paste images into the folder
4. Preview: hugo server --buildFuture
5. git add content/posts/new-post-slug/
6. git commit -m "Add post: New Post Title"
7. git push
8. GitHub Actions: Hugo build → Pagefind index → FTP deploy
9. Live on your domain in ~60 seconds
No database migrations. No plugin conflicts. No “white screen of death” after an update. Just Markdown files, Git history, and a deployment pipeline that works every time.
Wrapping Up
A static blog is not the right choice for every project. If you need user accounts, comments, e-commerce, or a complex CMS — WordPress or a similar platform may genuinely be better.
But for a tech blog, portfolio, or documentation site? Hugo gives you a site that is faster, more secure, and cheaper to host than anything WordPress can deliver. The workflow of writing in Obsidian, committing with Git, and auto-deploying through GitHub Actions is hard to beat once you have experienced it.
The entire stack — Hugo, Git, GitHub Actions, Pagefind — is free and open source. The only cost is your domain name and basic hosting.