21 May 2020

Goodbye, WordPress: hello Hugo + nginx with fast builds and sane deploys today

You know how it goes - you’re trying to publish a blog post, the WordPress editor is crawling, you paste in some code and the WYSIWYG editor mangles it completely, and you end up manually editing HTML at midnight just to get syntax highlighting working. Again.

That was my life for 4 years.

I kept telling myself “it’s fine” - until I actually measured it. 2.5MB transfer, 47 network requests, and 40 seconds to load a single page on a slow connection. My plain HTML homepage? 32KB and 0.3 seconds.

This post shows you how I ditched WordPress for Hugo, a static site generator that turns markdown into blazing-fast HTML - and deployed it with GitHub Actions and nginx. No more database, no more PHP, no more 2 AM debugging sessions.

Working migration scripts and deployment pipeline: All the code you need is in this post.


Why this matters

Look, I’m not a web developer. I picked WordPress 4 years ago because it was the only CMS I knew. It worked… until it didn’t.

Here’s what I was fighting:

The technical pain:

  • 2.5MB transfer and 47 network requests per page load
  • 40-second load times on slow connections (yes, really)
  • PHP + MariaDB + nginx stack just to serve some blog posts
  • Security vulnerabilities every few months
  • No version control for content

The workflow pain (and this was the real killer):

  1. Write content locally in vim (because the online editor is unusable)
  2. Copy-paste into WordPress and watch it mangle the formatting
  3. Manually fix broken code blocks with a syntax highlighter plugin
  4. Edit the generated HTML by hand
  5. Cry and drink
  6. Repeat this every 3 months

I accept judgment for this workflow. It’s worse than writing Confluence pages on dial-up.

And here’s the thing - my plain HTML homepage loads 32KB in 0.3 seconds on the same connection. The blog? 2.5MB and 40 seconds. That’s an 8,000% size increase for… a blog.

AWS published this architecture diagram for running WordPress at scale. It’s horrifying. Load balancers, RDS instances, ElastiCache, S3, CloudFront - all to serve markdown content.

There had to be a better way.


What we’ll build

Here’s the migration plan:

  1. Export all WordPress posts to markdown
  2. Set up Hugo as the static site generator
  3. Customize a theme (remove all external CDN dependencies, add TOC, improve syntax highlighting)
  4. Migrate content with scripts to fix broken formatting
  5. Deploy with GitHub Actions and nginx
  6. Secure deployment with restricted SSH access

The result? Write in markdown locally, push to Git, auto-deploy to production. No database, no PHP, no security updates, no 40-second page loads.

Let’s build it.


Part 1 - The WordPress nightmare: why I had to leave

The WordPress pain points weren’t just about performance. They were about the entire development experience.

What drove me crazy:

  • No offline work - Everything happens in a web browser connected to the server
  • WYSIWYG hell - No markdown support, no vim bindings, everything is mouse-driven
  • Slow everything - Navigation, updates, previews all crawl
  • Plugin roulette - Syntax highlighting plugins fight with each other
  • No collaboration - Accepting feedback requires manual work, no pull requests
  • Bot spam - Constant comment spam to clean up
  • Template limitations - WordPress templates are hard to customize without breaking things

But the workflow was what finally broke me. Writing locally, copying to WordPress, watching it destroy my formatting, manually fixing HTML… this isn’t 2005.

I wanted: Write markdown in vim → push to Git → see it live. That’s it.


Part 2 - Static site generators: the mental model

Here’s the key insight: most blogs don’t need a database and server-side rendering for every page load. The content doesn’t change between requests.

How static site generators work:

  1. You write content in markdown files
  2. The generator reads the files and applies templates
  3. It outputs plain HTML/CSS/JS
  4. You serve the static files (no database, no PHP)

Why this works:

  • Fast - No database queries, no PHP execution, just static files
  • Secure - No login pages, no SQL injection, no PHP vulnerabilities
  • Version control - Everything is text files in Git
  • Portable - Works on any web server (nginx, Apache, S3, GitHub Pages)
  • Offline work - Write locally, preview locally, push when ready

The workflow becomes:

1
Write markdown → Run generator → Deploy static HTML

Instead of:

1
Log into WordPress → Fight WYSIWYG → Manually fix HTML → Hope it works

There are several options: Jekyll (Ruby), Hugo (Go), Gatsby (React), 11ty (JavaScript). I chose Hugo because it’s a single binary with no dependencies, it’s fast, and it has good themes.


Part 3 - Hugo setup: getting started

Hugo is a static site generator written in Go. It’s a single binary with no dependencies. No Ruby gems, no Node modules, no Python packages. Just download and run.

Why Hugo over Jekyll or Gatsby? Simple:

  • One binary - No runtime dependencies
  • Fast builds - Go is compiled, not interpreted
  • Good templates - Nice MIT-licensed themes available
  • Simple syntax - Easy to learn even for non-web-devs

I saw it on HackerNews years ago and bookmarked it. Finally got around to using it.

Installing Hugo

Grab the latest binary for your platform from GitHub:

1
2
wget https://github.com/gohugoio/hugo/releases/download/v0.70.0/hugo_0.70.0_Linux-64bit.tar.gz
tar xvf hugo_0.70.0_Linux-64bit.tar.gz

Put it somewhere on your $PATH and you’re done. No gem install, no npm install, no dependency hell.

Creating the site

1
hugo new site vitthalmirji.com

This creates the basic structure. Now edit config.toml:

1
2
3
4
baseURL = "https://vitthalmirji.com/"
languageCode = "en-us"
title = "Vitthal Mirji"
theme = "ink-free"

That’s it for basic setup. Now we need content and a theme.

Local testing

Hugo has a built-in dev server with auto-reload:

1
hugo server -D

This starts a local server on localhost:1313. Every time you save a file, the browser refreshes automatically. No build step, no manual refresh.

The -D flag includes drafts. Super useful for previewing work-in-progress posts.


Part 4 - Migrating WordPress content

Now comes the fun part: getting all those old WordPress posts out.

Exporting WordPress posts to markdown

I need to export all old blog posts to markdown. There’s a great little tool by lonekorean on GitHub that does exactly this.

First, export your WordPress content (Tools → Export in WordPress admin). You’ll get an XML file.

Then run the converter:

1
2
3
4
git clone https://github.com/lonekorean/wordpress-export-to-markdown
cd wordpress-export-to-markdown
npm install
node index.js

It’ll prompt you for the WordPress XML file and output directory. The tool generates markdown files with front matter.

Hugo has other migration tools available, but since I’d customized WordPress heavily, this one worked best for me. Your mileage may vary.

After that, copy the generated files to Hugo’s content/posts/ directory.

The problems with exported content

Due to my “interesting” WordPress configuration, the exported posts needed help:

  • GitHub Gists not rendered
  • Internal syntax formatting caused everything to be escaped with \, breaking code
  • Headlines missing
  • Tags missing
  • Descriptions missing

Yeah, it was a mess.


Part 5 - Theme customization

We also need a theme. Hugo’s default is… minimal.

My requirements:

  • No external CDNs - No googleapis, no Google Analytics, no trackers
  • Simple interface - Clean, without hiding information
  • Fast on slow connections - Remember those 40-second WordPress pages?

I chose hugo-ink by knadh, but customized it heavily. This became a fork called ink-free .

What I changed

Removed privacy invasions:

  • All references to Google’s font CDN (fonts are now local)
  • Analytics code (even if it was toggled off)

Fixed usability issues:

  • Tags display inline instead of as a list
  • Syntax highlighting background changed (was grey on grey - unreadable)
  • Added a back button to all posts
  • Added TOC (table of contents) controlled by a variable
  • Added word count, tags, and read time to post previews

Added random footer messages: This was the fun part. Hugo’s templating language (based on Go) lets you pipe commands. I wanted random silly messages at the end of posts:

1
2
3
4
5
6
7
<div class="back">
  {{ if isset .Site.Params "footers" }}
    {{ if ne .Type "page" }}
      Next time, we'll talk about <i>"{{ range .Site.Params.footers | shuffle | first 1 }}{{ . }}"</i>{{ end }}
    {{ end }}
  {{ end }}
</div>

This picks a random message from config.toml and displays it. Messages like “What Tiger King can teach us about x86 Assembly” or “Why Spark’s lazy evaluation is basically procrastination with a PhD”.

Most theme changes were simple HTML and CSS. Even as someone who’s not a web dev, Hugo’s templates are straightforward:

1
2
3
4
5
6
7
8
.tag-li {
  display:inline !important;
}

.back {
  padding-top: 1em;
  font-size: 1em;
}

The result:

After theme customization

Check out the theme here: ink-free on GitHub


Part 6 - Fixing exported content with scripts

Remember those export problems? Time to fix them.

The WordPress exporter gave me markdown files, but they were broken:

  • GitHub Gists embedded as <script> tags (Hugo needs shortcodes)
  • Code blocks escaped with \ everywhere
  • Missing metadata

Fixing GitHub Gists

WordPress had this:

1
<script src="https://gist.github.com/vim89/8dcc79ab16daf5fcdda3df1a4ccc183a.js"></script>

Hugo needs this:

1
{{< gist vim89 8dcc79ab16daf5fcdda3df1a4ccc183a >}}

Doing this by hand for dozens of posts? No thanks. Let’s script it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
grep "gist.github.com" *.md | while read -r line ; do
  guser=$(echo "${line}" | cut -d'/' -f4)
  gist=$(echo "${line}" | cut -d'/' -f5 | sed -E 's/(\.js).+//g')
  repl="{{/* gist "${guser}" "${gist}" */>}}"

  # Get line number
  ln=$(grep -n "$line" *.md | cut -d : -f 1)

  # Replace
  echo "Replace line $ln with ${repl}"
  sed -Ei "$ln s#.*#${repl}#g" *.md
done

This finds all Gist URLs, extracts the username and Gist ID, and replaces them with Hugo shortcodes. Saved me hours of manual editing.

VS Code shortcuts for Hugo syntax

I added keyboard shortcuts in VS Code to quickly insert Hugo’s syntax highlighter blocks. In keybindings.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[
  {
    "key": "ctrl+1",
    "command": "editor.action.insertSnippet",
    "when": "editorTextFocus",
    "args": {
      "snippet": "{< highlight bash \"linenos=table\" >}"
    }
  },
  {
    "key": "ctrl+2",
    "command": "editor.action.insertSnippet",
    "when": "editorTextFocus",
    "args": {
      "snippet": "{< / highlight >}"
    }
  }
]

Now Ctrl+1 opens a syntax block, Ctrl+2 closes it. Small productivity win.

Preserving URLs (SEO matters)

I don’t want to break existing links. People have bookmarked posts, shared them, indexed them in search engines.

Hugo needs to generate the same URLs WordPress used. In config.toml:

1
2
[permalinks]
  posts = "/:year/:month/:title"

This matches WordPress’s default permalink structure. No 301 redirects needed.

Verifying the migration

How do I know all URLs match? I wrote a quick Go script to compare RSS feeds - WordPress’s export vs Hugo’s generated feed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
  "os"
  "fmt"
  "github.com/mmcdole/gofeed"
)

func mapToUrl(feed *gofeed.Feed) map[string]string {
  output := map[string]string{}
  for _, post := range feed.Items {
    output[post.Link] = post.Title
  }
  return output
}

func readFeed(path string) *gofeed.Feed {
  fp := gofeed.NewParser()
  file, _ := os.Open(path)
  defer file.Close()
  feed, _ := fp.Parse(file)
  return feed
}

func compareUrls(local, remote map[string]string) bool {
  allFound := true
  for k, _ := range local {
    if title, ok := remote[k]; ok {
      fmt.Printf("✓ Found %s\n", title)
    } else {
      fmt.Printf("✗ Missing: %s\n", k)
      allFound = false
    }
  }
  return allFound
}

func main() {
  remoteFeed := readFeed("./wordpress.xml")
  localFeed := readFeed("../public/index.xml")

  remote := mapToUrl(remoteFeed)
  local := mapToUrl(localFeed)

  fmt.Println("All URLs match:", compareUrls(local, remote))
}

Running this found a couple of posts with mismatched URLs:

1
2
✗ Missing: https://vitthalmirji.com/blog/2020/02/how-a-broken-memory-module-hid-in-plain-sight/
✗ Missing: https://vitthalmirji.com/blog/2019/12/tensorflow-on-edge-building-a-smart-security-camera/

Fixed those manually. (Psst, don’t check the Git commits.)

There were more adjustments - re-embedding videos, fixing image paths - but this covered the bulk of it. I’ll spare you the tedious details.


Part 7 - Deployment with GitHub Actions

Now for the fun part - automated deployment.

The goal: Every git push to master should automatically build and deploy the site to my nginx server. No manual steps, no FTP clients, no SSH sessions.

I could use GitHub Pages, but I want to keep everything on my own server. Plus, I’m already running nginx and Docker there.

The deployment flow:

  1. Git push triggers GitHub Actions
  2. Checkout code (including theme submodule)
  3. Download Hugo binary
  4. Build static HTML/CSS/JS
  5. Deploy via SCP to server (in a restricted chroot jail for security)
  6. Optional: Trigger any server-side updates (Docker container refreshes, etc.)

For CI/CD, I have two free options: Travis CI or GitHub Actions. I chose GitHub Actions to avoid yet another external service. Everything stays within GitHub.

Creating the workflow

Create .github/workflows/workflow.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
name: build, test, audit & deploy vitthalmirji.com site

on:
  push:
    branches: [ master, main ]
  pull_request:
    branches: [ master, main ]
  workflow_dispatch:

jobs:
  build-test-audit-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: 📥 Checkout code with submodules
        uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: ⚙️ Setup Hugo
        uses: peaceiris/actions-hugo@v3
        with:
          hugo-version: '0.148.1'
          extended: true

      - name: ✅ Validate taxonomies via taxlimits.yaml
        continue-on-error: true
        run: |
          valid_terms=$(yq e '.tags + .categories + .series' data/taxlimits.yaml | sed 's/- //g')
          find content -name "*.md" | while read -r md; do
            for term in $(grep -E '^(tags|categories|series):' -A3 "$md" | sed -n 's/- //p'); do
              if ! grep -qx "$term" <<< "$valid_terms"; then
                echo "❌ Invalid taxonomy term '$term' in $md"
                exit 1
              fi
            done
          done

      - name: 🏗️ Build Hugo site
        run: hugo --gc --minify

      - name: 🚦 Run Lighthouse audits
        uses: treosh/lighthouse-ci-action@v12
        continue-on-error: true
        with:
          urls: 'https://vitthalmirji.com'
          uploadArtifacts: true

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install Pa11y CLI
        run: npm install -g pa11y-ci

      - name: Cache Node.js dependencies
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      - name: ♿ Run accessibility checks (Pa11y)
        run: PUPPETEER_EXECUTABLE_PATH=$(which chromium-browser || which chromium || true) \
          npx pa11y-ci --chrome-launcher-flags="--no-sandbox" --config .github/workflows/.pa11yci.json
        continue-on-error: true
    
      - name: 📦 Upload to GitHub Pages (gh-pages)
        uses: peaceiris/actions-gh-pages@v3
        with:
          personal_token: ${{ secrets.PERSONAL_TOKEN }}
          publish_dir: ./public
          external_repository: vim89/vim89

      - name: 📤 Deploy to InfinityFree via FTP
        uses: SamKirkland/[email protected]
        with:
          server: ${{ secrets.FTP_HOST }}
          username: ${{ secrets.FTP_USERNAME }}
          password: ${{ secrets.FTP_PASSWORD }}
          local-dir: ./public/
          server-dir: /htdocs/
          dangerous-clean-slate: false

Successful GitHub Actions deployment

Note on SCP Actions: I tried using pre-configured SCP Actions from the GitHub marketplace. They either didn’t support key passphrases or had terrible debug logs. After fighting “Exit code 1” errors despite successful connections (verified in syslog and auth.log), I gave up and wrote the bash deployment myself. If you know what causes that error, let me know!


Part 8 - Security: locking down deployment

Here’s the problem: GitHub Actions needs SSH access to deploy files. But giving full SSH access to an automated system? That’s a security nightmare.

The solution: Create a restricted user that can only upload files via SCP. No shell, no commands, no lateral movement.

Setting up rssh (restricted shell)

rssh is a restricted shell that only allows SCP/SFTP. No bash, no command execution.

Create the deployment user:

1
2
useradd -m -d /home/github github
passwd github

Lock down permissions:

1
2
3
4
5
6
7
8
9
# Make home directory owned by root (prevents user from writing there)
sudo chown -R root:github /home/github

# Only allow writes to .ssh for key management
sudo chown github:github /home/github/.ssh

# Give write access only to the deployment target directory
sudo mkdir -p /var/www/vitthalmirji.com
sudo chown github:github /var/www/vitthalmirji.com

Compile and install rssh:

1
2
3
4
5
6
7
wget http://prdownloads.sourceforge.net/rssh/rssh-2.3.4.tar.gz
tar -xvf rssh-2.3.4.tar.gz
cd rssh-2.3.4

./configure
make
sudo make install

Configure rssh:

Edit /usr/local/etc/rssh.conf:

1
user = "github:022:00001:/var/www/vitthalmirji.com"

This configuration means:

  • username: github (the restricted user)
  • umask: 022 (files created with rw-r–r– permissions)
  • access bits: 00001 (rsync=0, rdist=0, cvs=0, sftp=0, scp=1)
    • Only SCP is allowed
  • chroot path: /var/www/vitthalmirji.com (user can’t access anything outside this directory)

See the rssh.conf man page for details.

Setting up chroot is complex. You need to copy required libraries and binaries into the chroot directory. Read the CHROOT file in the rssh source directory carefully. This is a multi-step process that varies by distribution.

Update SSH configuration:

Edit /etc/ssh/sshd_config:

1
2
# Ensure github user is allowed
AllowUsers your-user github

Consider creating separate groups for rssh and regular ssh users if you’re doing this on a multi-user system.

Restart SSH:

1
service sshd restart

Set user shell to rssh:

1
usermod -s /usr/bin/rssh github

Now the github user can only SCP files to the deployment directory. They can’t:

  • Run shell commands
  • Access other parts of the filesystem
  • SSH in for interactive sessions

Generate SSH keys:

For GitHub Actions, generate keys in PEM format (the Ubuntu runner image uses an older OpenSSH version):

1
ssh-keygen -m PEM -t rsa -b 4096 -f github_deploy_key

Add the public key to /home/github/.ssh/authorized_keys, then add the private key as a GitHub secret.


The result

Well, if you’re reading this, judge for yourself!

Performance improvements:

  • Before (WordPress): 2.5MB transfer, 47 requests, 40 seconds on slow connections
  • After (Hugo): ~500KB transfer (mostly images), minimal JS/CSS, instant load

It’s not as flashy as the WordPress template. But it’s fast, it works, and I can actually write without fighting an editor.

The workflow now:

  1. Write markdown in vim (offline)
  2. git push
  3. GitHub Actions builds and deploys automatically
  4. Live in seconds

No database, no PHP, no manual HTML editing, no fighting with plugins.


Conclusion

I’m pretty happy with this change. For my sanity, if nothing else.

Look, I don’t get a ton of traffic here (I don’t run analytics, so I don’t actually know). But blogging is fun. Every time I write something, I learn something - whether it’s complex ML theory or, like today, getting back into web dev and deployment automation.

This new setup lets me blog more. I spend time writing code and posts, not fighting WordPress. Faster load times mean less frustration for readers. Version-controlled markdown means no more lock-in - anyone can contribute via pull requests.

If you’re thinking about migrating away from WordPress, or starting a blog, give Hugo (or any static site generator) a try. The workflow is so much cleaner than fighting a WYSIWYG editor.

And if you want to use GitHub + Hugo + nginx, hopefully this post helps.


TL;DR

  • Migrated from WordPress (40s page loads, horrible editor) to Hugo static site generator
  • Hugo: Single Go binary, markdown input, blazing-fast static HTML output
  • Exported WordPress posts to markdown with wordpress-export-to-markdown tool
  • Fixed broken content with bash/Go scripts (Gists, permalinks, metadata)
  • Customized hugo-ink theme: removed CDN dependencies, added TOC, fixed syntax highlighting
  • Automated deployment with GitHub Actions: push to Git → auto-build → deploy via SCP
  • Secured deployment with rssh (restricted shell): GitHub Actions can only SCP files, no shell access
  • Result: 500KB pages that load instantly, markdown workflow in vim, version control, collaborative via PRs
Vitthal Mirji profile photo

Vitthal Mirji

Staff Data Engineer @ Walmart

Mumbai, India

Staff Data Engineer & Architect from Mumbai, India. Sharing insights on Data Engineering, Functional programming, Scala, Open source, and life.

Expertise
  • Data Engineering
  • Scala
  • Apache Spark
  • Functional Programming
  • Cloud Architecture
  • GCP
  • Big Data
Next time, we'll talk about "Why Spark's lazy evaluation is basically procrastination with a PhD"