Skip to main content
  1. Posts/

Migrating from Jekyll to Hugo Part 3: Deployment and Lessons Learned

·1099 words·6 mins
Chris Ayers
Author
Chris Ayers
I am a father, nerd, gamer, and speaker.

In the final part of this series, I cover deploying Hugo to GitHub Pages and share the challenges I encountered.

GitHub Actions Workflow
#

Here’s the workflow I use to deploy Hugo to GitHub Pages:

name: Deploy Hugo site to Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: "pages"
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pages: write
      id-token: write
    env:
      HUGO_VERSION: 0.154.5
    steps:
      - name: Checkout
        uses: actions/checkout@8e8c483db84b4bee # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false
          submodules: recursive

      - name: Setup Hugo
        uses: peaceiris/actions-hugo@75d2e847 # v3.0.0
        with:
          hugo-version: ${{ env.HUGO_VERSION }}
          extended: true

      - name: Setup Pages
        id: pages
        uses: actions/configure-pages@983d7736 # v5.0.0

      - name: Cache Hugo resources
        uses: actions/cache@8b402f58 # v5.0.3
        with:
          path: |
            ${{ runner.temp }}/hugo_cache
            resources/_gen
          key: hugo-${{ runner.os }}-${{ hashFiles('content/**', 'config/**', 'assets/**') }}
          restore-keys: |
            hugo-${{ runner.os }}-

      - name: Build with Hugo
        env:
          HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache
          HUGO_ENVIRONMENT: production
          TZ: America/New_York
        run: |
          hugo \
            --gc \
            --minify \
            --baseURL "${{ steps.pages.outputs.base_url }}/"

      - name: Upload artifact
        uses: actions/upload-pages-artifact@7b1f4a76 # v4.0.0
        with:
          path: ./public

  deploy:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@d6db9016 # v4.0.5

Key points:

  • SHA-pinned actions - Every action is pinned to a commit SHA, not a mutable tag — critical for supply chain security
  • Scoped permissions - Minimal permissions declared per-job, not at the workflow level
  • submodules: recursive - Required for the theme submodule
  • fetch-depth: 0 - Needed for .GitInfo and .Lastmod
  • persist-credentials: false - Security best practice for checkout
  • Pinned Hugo version - HUGO_VERSION env var ensures reproducible builds
  • Caching - Both hugo_cache and resources/_gen are cached to speed up builds
  • --gc --minify - Clean up unused cache entries and optimize output

Linting with Super-Linter
#

In addition to the deploy workflow, I added a Super-Linter workflow that runs on every PR:

name: Super-Linter

on:
  pull_request: null

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    name: Lint
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: read
      statuses: write
    steps:
      - name: Checkout code
        uses: actions/checkout@8e8c483db84b4bee # v6.0.1
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Super-linter
        uses: super-linter/super-linter@d5b0a2ab # v8.3.2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          VALIDATE_ALL_CODEBASE: 'false'
          # Auto-fix formatting on PR
          FIX_CSS_PRETTIER: 'true'
          FIX_HTML_PRETTIER: 'true'
          FIX_JSON_PRETTIER: 'true'
          FIX_MARKDOWN_PRETTIER: 'true'
          FIX_YAML_PRETTIER: 'true'
          # Disable linters that don't apply
          VALIDATE_JSCPD: 'false'
          VALIDATE_PYTHON: 'false'
          # Use repo markdownlint config
          MARKDOWN_CONFIG_FILE: '.markdownlint.yml'
          # Don't lint the theme submodule
          FILTER_REGEX_EXCLUDE: 'themes/.*'

This catches markdown issues, YAML errors, and formatting problems before they hit main. The FIX_* options automatically commit formatting corrections back to the PR branch, which saves a lot of manual cleanup. I exclude the themes/ directory since that’s third-party code.

I also keep linting config files in the repo root:

  • .markdownlint.yml - Disables rules like MD013 (line length) and MD033 (inline HTML — needed for Hugo shortcodes)
  • .yaml-lint.yml - Warns on formatting issues without blocking
  • .textlintrc - Terminology checks
  • .eslintrc.yml - JavaScript linting for any custom scripts

Challenges and Solutions
#

Challenge 1: Preserving URLs
#

Jekyll and Hugo generate different URL structures. To avoid breaking existing links, I used Hugo aliases:

# In front matter
aliases:
  - /category/development/my-old-post-url/

For bulk redirects, I created static/_redirects for Netlify-style redirects (works with some hosts).

Challenge 2: Theme Version Compatibility
#

I hit this warning early on:

WARN Module "blowfish" is not compatible with this Hugo version

Solution: Pin both Hugo and theme versions:

# In GitHub Actions
env:
  HUGO_VERSION: 0.154.5
# Pin theme to specific tag
cd themes/blowfish
git checkout v2.97.0

Challenge 3: Date Formatting
#

Hugo uses Go’s reference time format. This tripped me up — Go doesn’t use YYYY-MM-DD style format strings. Instead, it uses a specific reference time:

Go reference time: Mon Jan 2 15:04:05 MST 2006

So in Hugo templates, you format dates like this:

{{/* Long date */}}
{{ .Date.Format "January 2, 2006" }}
{{/* Output: January 25, 2026 */}}

{{/* ISO date */}}
{{ .Date.Format "2006-01-02" }}
{{/* Output: 2026-01-25 */}}

{{/* With time */}}
{{ .Date.Format "Jan 2, 2006 3:04 PM" }}
{{/* Output: Jan 25, 2026 12:00 AM */}}

The magic is that every component of the format is a specific number: month=1, day=2, hour=3, minute=4, second=5, year=6, timezone=7 (MST). Once you internalize that, it clicks.

Challenge 4: Custom Layouts
#

Some Jekyll layouts needed recreation. Hugo’s template lookup order:

  1. layouts/<type>/<layout>.html
  2. layouts/_default/<layout>.html
  3. Theme equivalents

I started by copying theme layouts to my layouts/ folder and customizing.

Challenge 5: Split Config Files
#

Hugo supports splitting configuration across multiple files. Rather than one monolithic config.toml, I use a config/_default/ directory:

config/_default/
├── hugo.toml        # Core site settings
├── languages.en.toml
├── markup.toml      # Goldmark, syntax highlighting
├── menus.en.toml
├── module.toml
└── params.toml      # Theme parameters

This keeps things organized — especially as Blowfish has many configurable params. One thing that helped: setting buildFuture = true in hugo.toml so scheduled posts show up locally during development.

Challenge 6: RSS Feed URLs
#

Jekyll’s feed was at /feed.xml, but Hugo defaults to /index.xml. To avoid breaking existing subscribers, I configured Hugo to output both:

# hugo.toml
[outputs]
  home = ["HTML", "RSS", "FEED", "JSON"]

# Legacy feed.xml for backward compatibility with Jekyll
[outputFormats.FEED]
  mediaType = "application/rss+xml"
  baseName = "feed"

The custom FEED output format needs a matching template, so I copied the theme’s rss.xml into my layouts:

cp themes/blowfish/layouts/_default/rss.xml layouts/_default/feed.xml

Now both /index.xml and /feed.xml are generated — existing subscribers keep working, and Hugo’s default feed works too.

Tips for Your Migration
#

  1. Start fresh - Create new Hugo site, don’t convert in place
  2. Migrate incrementally - Move posts in batches, test as you go
  3. Use hugo server -D - Shows drafts with hot reload
  4. Read theme docs - Blowfish has excellent documentation
  5. Test all pages - Especially taxonomy and archive pages
  6. Check mobile - Verify responsive design works
  7. Validate feeds - Test RSS/Atom with a feed reader

Before and After
#

MetricJekyllHugo
Build time30+ seconds< 1 second
DependenciesRuby, Bundler, gemsSingle binary
Hot reloadSlowInstant
Theme optionsLimitedExtensive

Conclusion
#

The migration took a weekend of focused work, but it was absolutely worth it. Hugo’s speed and flexibility have made maintaining this blog much more enjoyable.

The key is taking it step by step:

  1. Set up Hugo with your chosen theme
  2. Migrate content in batches
  3. Fix shortcodes and assets
  4. Set up deployment
  5. Test thoroughly before switching DNS

If you’re considering the switch, I hope this series helps. Feel free to reach out with questions!

Resources
#

Related

Two Incredible Years at Microsoft

·1375 words·7 mins
Two Incredible Years at Microsoft: A Journey of Growth, Connection, and Remote Collaboration # As I sit at my desk, keyboard beneath my fingertips, I’m reminded that it’s been two years since I first embarked on my journey with Microsoft. Joining during COVID meant that I did not get the onboarding experiences of a lot of Microsoft employees, a trip and onboarding in Redmond. Today, as I write this reflection, I’m filled with a mixture of nostalgia and pride over how much I, and the team I belong to, have grown-despite the miles that separate us.

Containerizing .NET - Part 2 - Considerations

·1976 words·10 mins
This is part 2 of the Containerizing .NET series. You can read the series of articles here: Containerizing .NET: Part 1 - A Guide to Containerizing .NET Applications Containerizing .NET: Part 2 - Considerations Considerations # Welcome to the second installment in our series on containerizing .NET applications. Building on the foundation laid in our first article-where we introduced Dockerfiles and the dotnet publish command-this piece delves into pivotal considerations for transitioning .NET applications into containers. As containers become a cornerstone of the ecosystem, understanding these factors is critical for developers aiming to enhance application deployment in containerized environments.