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?

AspectWordPressStatic (Hugo)
SpeedDatabase queries on every page loadPre-built HTML, served instantly
SecurityConstant attack surface (PHP, plugins, admin panel)No server-side code, nothing to exploit
Hosting costNeeds PHP + MySQL (or managed WP hosting)Any cheap shared host, or free (GitHub Pages, Netlify)
MaintenancePlugin updates, DB backups, PHP upgradesZero — it is just files
Build timeN/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-body on the <article> tag tells Pagefind (our search engine) to index only the post content, not the header, sidebar, or footer.
  • $.Resources.GetMatch looks for the featured image inside the post’s page bundle folder. If found, Hugo automatically provides Width and Height — 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:

![Alt text describing the image](screenshot-1.png)

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."
---
  • description feeds into <meta name="description">, OpenGraph, and Twitter Cards. Always write one — it is the snippet Google shows in search results.
  • image is relative to the page bundle folder (not a full path).
  • categories and tags auto-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 ![alt](image.png) 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

  1. Open Obsidian and create a new vault pointing to your Hugo project root (e.g., ~/my-blog/).
  2. 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
  3. Create new posts as content/posts/my-post-slug/index.md.

Writing Workflow

Your daily workflow becomes:

  1. Open Obsidian — your vault is the Hugo project
  2. Create a new folder under content/posts/ with a URL-friendly slug
  3. Create index.md inside that folder
  4. Write your post in Markdown, paste images directly (Obsidian saves them to the same folder)
  5. Preview locally with hugo server
  6. 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 → SettingsSecrets and variablesActions and add:

  • FTP_HOST: Your hosting provider’s FTP hostname (e.g., ftp.yourdomain.com)
  • FTP_USER: FTP username
  • FTP_PASS: FTP password

What the Pipeline Does

  1. Checkout: Clones your repository.
  2. Setup Hugo: Installs the exact Hugo version you develop with (avoids “works on my machine” issues).
  3. Build: Runs hugo --minify --buildFuture to generate the site with minified HTML and include future-dated posts.
  4. Search index: Pagefind scans the built HTML and generates the search index files.
  5. 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

  1. After Hugo generates your HTML, Pagefind scans it and creates a compact search index.
  2. A small JavaScript library loads in the browser and queries this index client-side.
  3. 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">&times;</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.