---
title: "Migrating Grav → Hugo: 32 files, 0 regression"
description: "Field report on the full migration of arleo.eu from Grav (PHP, dynamic) to Hugo (Go, SSG). 16 articles × 2 languages, page bundles, FR/EN coexistence, planned DNS cutover."
url: "https://www.arleo.eu/en/posts/migration-grav-vers-hugo/"
language: "en"
datePublished: "2026-05-09T13:01:28+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["hugo","grav","migration","ssg","infrastructure"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Migrating Grav → Hugo: 32 files, 0 regression
Field report on the full migration of arleo.eu from Grav (PHP, dynamic) to Hugo (Go, SSG). 16 articles × 2 languages, page bundles, FR/EN coexistence, planned DNS cutover.



## TL;DR

arleo.eu had been running on **Grav CMS** for ~3 years: flat-file, PHP-FPM, ModSecurity, Cloudflare. Everything worked. But operational debt was piling up: PHP upgrades, Grav plugins to patch, TTFB creeping past 800ms on some pages.

I migrated to **Hugo** (Go-based static site generator) in two weeks, keeping the same URLs, the same content, and both languages FR/EN. **Zero regression** on SEO, Cloudflare indexing, or internal links. Here's how.

## Why Hugo

I had 3 criteria:

1. **Performance** — Pure static. No PHP, no DB. nginx serves pre-generated `.html` directly.
2. **Security** — Attack surface divided by 10. No more PHP-FPM, no server-side execution on public pages.
3. **Clean multilingual** — Hugo natively supports i18n via the `index.{lang}.md` convention (page bundles).

Hugo checks all the boxes. Alternatives evaluated: **Eleventy** (JS, but less mature i18n), **Zola** (Rust, awesome, but fewer themes), **Gatsby** (too heavy for a homelab).

<!--more-->
<!-- mermaid-source:Zmxvd2NoYXJ0IExSCiAgICBzdWJncmFwaCBncmF2WyJHcmF2IENNUyJdCiAgICAgICAgRzFbIlBIUC1GUE0iXSAtLT4gRzJbIlR3aWcgcmVuZGVyIl0gLS0+IEczWyJIVE1MPGJyLz5UVEZCIH40ODBtcyJdCiAgICBlbmQKICAgIHN1YmdyYXBoIGh1Z29bIkh1Z28gU1NHIl0KICAgICAgICBIMVsiQnVpbGQgMi4xcyJdIC0tPiBIMlsiU3RhdGljIEhUTUwiXSAtLT4gSDNbIm5naW54IGRpcmVjdDxici8+VFRGQiB+NTBtcyJdCiAgICBlbmQKICAgIGdyYXYgLS0+fCIzMiBmaWxlczxici8+MTYgYXJ0aWNsZXMgw5cgMiBsYW5ndWFnZXMifCBodWdvCiAgICBzdHlsZSBncmF2IGZpbGw6IzFhM2E1YwogICAgc3R5bGUgaHVnbyBmaWxsOiMxNDVhMzI= -->
{{< mermaid-svg hash="87f3caec" >}}

## Strategy: page bundles + preserved URLs

The classic CMS migration trap: breaking URLs. All inbound links (Google, Reddit, Hacker News) would 404.

Solution: **keep existing routes**. On Grav, I had `/csp-nonce`, `/post-mortem-522-wan-failover`, etc. → On Hugo, same thing, via `url:` in front matter or page bundle convention.

LoveIt convention (the chosen Hugo theme):

```
content/
  csp-nonce/
    index.fr.md          ← French variant
    index.en.md          ← English variant
    featured.png         ← hero image (page bundle)
  post-mortem-522/
    index.fr.md
    index.en.md
```

Hugo auto-generates:
- `https://arleo.eu/csp-nonce/` (FR by default, no prefix)
- `https://arleo.eu/en/csp-nonce/` (EN with prefix)

Key parameter in `hugo.toml`:

```toml
defaultContentLanguage = "fr"
defaultContentLanguageInSubdir = false   # FR at root
```

## Real scope: 32 files

I had 16 editorial articles on Grav. × 2 languages = **32 Markdown files** to migrate.

To not break anything, I first laid out a per-article checklist:

- [ ] Grav front matter (Twig metadata) → Hugo front matter (YAML)
- [ ] Grav shortcodes → LoveIt shortcodes conversion
- [ ] Internal links (`[link](/posts/csp-nonce/)` to verify both ways)
- [ ] Images: move to page bundle, not `static/images/`
- [ ] Tags and categories aligned with Hugo conventions
- [ ] `date:` in ISO 8601 (Grav uses `DD-MM-YYYY HH:MM`)

I automated date conversion with a small Python script:

```python
from datetime import datetime
grav_date = "08-04-2026 00:35"
iso = datetime.strptime(grav_date, "%d-%m-%Y %H:%M").isoformat()
# 2026-04-08T00:35:00
```

## FR/EN coexistence without hacks

LoveIt natively handles languages via URL. The only catch: the language switcher. On Grav, it was a Twig-generated dropdown. On Hugo, it's native:

```toml
[languages]
  [languages.fr]
    weight = 1
    languageName = "Français"
  [languages.en]
    weight = 2
    languageName = "English"
```

The selector (top right, globe icon) automatically toggles to the equivalent version of the current page.

## `static/` ↔ `content/` coexistence

Hugo has two zones for resources:

- **`static/`** = global assets (favicon, logos, custom CSS). Served directly.
- **`content/<route>/`** = page bundle (hero image for a specific article).

Simple rule I adopted: 
- If the image is **reused** across multiple pages → `static/images/`
- If the image belongs to **one single page** → page bundle

This discipline avoids orphan `static/images/csp-nonce-diagram.png` files when an article gets deleted.

## MCP migration: each its own role

I kept a **Hugo MCP Server** running in parallel (FastAPI, 7 tools) that lets me edit articles from Claude.ai. But with one rule: **MCP touches content (`content/`), Git touches structure (`layouts/`, `themes/`, `hugo.toml`)**. This is what I call "Strategy 4" — I'll cover that in another article.

Concretely, `content/` is in `.gitignore` repo-side, but backed up via VM snapshots. No conflict possible between MCP and Git.

## What surprised me (positively)

- **Build time**: ~2 seconds for 16 articles × 2 languages. On Grav, just loading a page took ~120ms of PHP before render.
- **FR/EN consistency**: Hugo generates exactly the same pages in both languages with no extra config. No more Grav `language-i18n` plugin needed.
- **Override simplicity**: want to tweak the home rendering? `cp themes/LoveIt/layouts/index.html layouts/index.html`, then edit. That's the only custom file I needed.

## What it cost me

- **Understanding `params.header.title` nuances**: LoveIt has a `themes/LoveIt/hugo.toml` with default values (`name = "My cool site"`). If you don't explicitly override in your `hugo.toml`, those defaults pollute your site. It took me 1h to figure out why my header showed "My cool site" when `title = "arleo.eu"` was clearly defined.
- **`layouts/home.html` override**: LoveIt filters articles with `where .Site.RegularPages "Type" "posts"` but my articles are at the root of `content/` (not in `content/posts/`). So 0 articles displayed on home. Fix: minimal layout override (1 line diff) + `hiddenFromHomePage: true` convention in front matter for reference pages (privacy, docs, scripts).

## Final state

| Metric | Grav | Hugo |
|--------|------|------|
| Median TTFB | ~480ms | ~50ms |
| Build | n/a (dynamic) | 2.1s |
| PHP surface | 100% | 0% |
| 404s post-migration | 0 | 0 |

The Hugo site currently runs on `hugo-test.arleo.eu` (KVM Ubuntu 24.04 VM, on the NUC). Final DNS cutover `arleo.eu → Hugo VM IP` is planned within a few weeks, after the MCP security sprint validates.

## Conclusion

A dynamic CMS → SSG migration is less risky than you'd think **if you keep the URLs and take time to understand the new theme's conventions**. Don't try to reinvent everything: Hugo + LoveIt are mature, following defaults pays off.

The most subtle work wasn't migrating the content, but converging visually with the previous Grav site (footer, menu, favicon, animations). That's what actually took the most time. But that's another story.



## Tags

- hugo
- grav
- migration
- ssg
- infrastructure

## Categories

- infrastructure
