Switching the Raspberry Pi WordPress Site to Hugo

I already wrote about migrating the article data from WordPress to Hugo in Migrating from WordPress to Hugo.

This article covers the next step: switching the actual WordPress environment running on my Raspberry Pi to Hugo.

Even when I built the environment myself, I forget the details after enough time passes. Looking back at my older notes, this site was not running WordPress directly on the host with PHP and MySQL. It was running through Docker.

The server has also changed since then. It is now a Raspberry Pi 5.

Before the Switch

The rough structure looked like this:

Internet
  |
  v
Raspberry Pi 5 / Debian
  |
  +-- Docker Compose
        |
        +-- wordpress-web-1       : nginx, listening on 80 / 443
        +-- wordpress-wordpress-1 : WordPress
        +-- wordpress-db-1        : MySQL
        +-- wordpress-certbot-1   : Certbot

The old article described a Raspberry Pi 4 / Ubuntu 20.10 setup, but the current machine was different:

Hostname: rpi5
Raspberry Pi 5 Model B Rev 1.0
Debian GNU/Linux 12 (bookworm)
Linux 6.12.93+rpt-rpi-2712 aarch64
Memory: 7.9GiB
Disk: NVMe 953.9GB
Docker 29.6.0
Docker Compose v5.1.4

The public ports 80 and 443 were handled by the nginx container:

wordpress-web-1  nginx  Up 12 days  0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp

So the part to replace was mainly this path:

wordpress-web-1 -> wordpress-wordpress-1

If nginx can serve Hugo’s static files directly, the WordPress container, MySQL container, and PHP runtime are no longer needed for normal publishing.

Confirming the WordPress Site

Before switching, I checked that the public site was still WordPress.

The response headers from https://nosubject.io/ included:

Server: nginx/1.27.1
X-Powered-By: PHP/8.2.23
Link: <https://nosubject.io/wp-json/>; rel="https://api.w.org/"

The RSS feed also contained a WordPress generator:

<generator>https://wordpress.org/?v=7.0</generator>

The HTML showed Cocoon theme paths:

wp-content/themes/cocoon-master
wp-content/themes/cocoon-child-master

At this point, the current production stack was clear:

  • Debian 12 on Raspberry Pi 5
  • Public traffic handled by an nginx Docker container
  • WordPress running in Docker
  • MySQL running in Docker
  • HTTPS handled by Let’s Encrypt / Certbot

Preparing the Hugo Public Directory

The WordPress files were under the Compose management directory:

~/wordpress/html
~/wordpress/html/wp-content

I did not want to overwrite ./html, because that is the WordPress installation. So I created a separate directory for Hugo output:

cd ~/wordpress
mkdir -p hugo-public

Later, after renaming the management directory, the Hugo output path became:

~/nosubject-docker/hugo-public

Changing docker-compose.yml

The final Compose configuration uses ./hugo-public as the nginx document root. WordPress and MySQL are kept, but moved behind a profile so they do not start during normal operation.

name: wordpress

services:
  web:
    image: nginx
    restart: always
    volumes:
      - ./templates:/etc/nginx/templates
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./sites-enabled:/etc/nginx/sites-enabled
      - ./letsencrypt:/etc/letsencrypt
      - ./hugo-public:/var/www/html:ro
    ports:
      - "80:80"
      - "443:443"

  certbot:
    image: certbot/certbot:latest
    restart: always
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/html; sleep 12h & wait $${!}; done;'"
    volumes:
      - ./letsencrypt:/etc/letsencrypt
      - ./hugo-public:/var/www/html

  wordpress:
    image: wordpress
    profiles:
      - wordpress
    restart: "no"

  db:
    image: mysql:8.0
    profiles:
      - wordpress
    restart: "no"

For normal operation, only nginx and certbot are needed:

docker compose up -d web certbot

If I need to temporarily bring WordPress back:

docker compose --profile wordpress up -d wordpress db

Fixing the certbot Restart Loop

During the switch, I noticed that the certbot container was restarting repeatedly.

docker compose ps
docker compose logs --tail 80 certbot

The log said:

Certbot doesn't know how to automatically configure the web server on this system.
Please run "certbot certonly" to do so.

The reason was that the certbot/certbot image had been started without a command. For a long-running container, I made it run certbot renew --webroot every 12 hours.

After that, the log showed that the certificate was not yet due for renewal:

Certificate not yet due for renewal
/etc/letsencrypt/live/nosubject.io/fullchain.pem expires on 2026-08-09

Switching nginx to Static Hugo Files

The nginx location / block had been proxying to WordPress. I changed it to serve static files from /var/www/html.

root /var/www/html;
index index.html index.htm;

location ^~ /.well-known/acme-challenge/ {
    root /var/www/html;
}

location = / {
    try_files /index.html =404;
}

location / {
    try_files $uri/index.html $uri =404;
}

The /.well-known/acme-challenge/ location is kept for Let’s Encrypt webroot validation.

Why I Got 403 Forbidden

Right after switching nginx, https://nosubject.io/ returned 403 Forbidden.

The nginx log showed:

directory index of "/var/www/html/" is forbidden

The cause was simple: ./hugo-public was empty, so there was no index.html.

Building Hugo into the public directory fixed it:

cd ~/nosubject-hugo
hugo --destination <home>/nosubject-docker/hugo-public --cleanDestinationDir

Updating Hugo

The Raspberry Pi initially had Hugo v0.140.0 installed.

hugo v0.140.0+extended linux/arm64

Instead of adapting templates to the old version, I updated Hugo itself. Since the Raspberry Pi 5 is linux/arm64, I downloaded the extended arm64 build from GitHub Releases.

curl -L -o /tmp/hugo_extended_0.164.0_linux-arm64.tar.gz \
  https://github.com/gohugoio/hugo/releases/download/v0.164.0/hugo_extended_0.164.0_linux-arm64.tar.gz

mkdir -p /tmp/hugo-0.164.0
tar -xzf /tmp/hugo_extended_0.164.0_linux-arm64.tar.gz -C /tmp/hugo-0.164.0
/tmp/hugo-0.164.0/hugo version

sudo cp /usr/local/bin/hugo /tmp/hugo-v0.140.0.backup
sudo install -m 0755 /tmp/hugo-0.164.0/hugo /usr/local/bin/hugo
hugo version

After the update:

hugo v0.164.0+extended linux/arm64

Renaming the Management Directory

The Compose management directory was still named ~/wordpress, but after switching to Hugo it was no longer a WordPress-only directory.

I renamed it to ~/nosubject-docker.

Before renaming, I added this to docker-compose.yml so that the Compose project name would stay the same:

name: wordpress

Then I renamed the directory:

cd ~
mv wordpress nosubject-docker
cd ~/nosubject-docker
docker compose config

I recreated only web and certbot so their bind mounts would point to the new path.

docker compose up -d --force-recreate web certbot

I did not use --remove-orphans, because I wanted to keep the stopped wordpress and db containers.

After recreating the containers, I checked their mounts:

<home>/nosubject-docker/hugo-public -> /var/www/html
<home>/nosubject-docker/letsencrypt -> /etc/letsencrypt

The Compose project had no extra containers:

wordpress-web-1         Up
wordpress-certbot-1     Up
wordpress-wordpress-1   Exited
wordpress-db-1          Exited

Checking the Result

I checked nginx syntax first:

docker compose exec web nginx -t

Then I checked the site through nginx with the correct Host header:

curl -k -I https://localhost/ -H 'Host: nosubject.io'

The migrated posts also returned 200 OK:

curl -k -I https://localhost/wordpress-to-hugo-migration/ -H 'Host: nosubject.io'
curl -k -I https://localhost/en/wordpress-to-hugo-migration/ -H 'Host: nosubject.io'
HTTP/1.1 200 OK

On the public web, I also confirmed that the site was accessible normally.

Stopping WordPress and MySQL

After confirming that Hugo was working, I stopped WordPress and MySQL without deleting them.

docker compose stop wordpress db

For now, I am keeping the WordPress html directory and the database volume. If everything stays stable for a while, I can back them up and clean them up later.

Summary

The switch from WordPress to Hugo on the Raspberry Pi went like this:

  1. Check the existing WordPress / Docker / nginx setup
  2. Migrate posts and images to Hugo
  3. Build Hugo into hugo-public
  4. Change nginx from WordPress proxying to static file serving
  5. Fix certbot so renewal keeps working
  6. Stop WordPress and MySQL without deleting them
  7. Rename the management directory from wordpress to nosubject-docker
  8. Recreate only the running nginx/certbot containers and confirm no extra Docker containers were left behind

By keeping the WordPress containers and data around, the switch can be rolled back if needed. But the public site is now served as static Hugo files.