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:
- Check the existing WordPress / Docker / nginx setup
- Migrate posts and images to Hugo
- Build Hugo into
hugo-public - Change nginx from WordPress proxying to static file serving
- Fix certbot so renewal keeps working
- Stop WordPress and MySQL without deleting them
- Rename the management directory from
wordpresstonosubject-docker - 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.