Moving cinque.dk off WordPress in an afternoon (and two or three hairy gotchas)
From a WP multisite with Elementor, PHP-FPM, and MariaDB - to pure static nginx in one afternoon. The plan, the gotchas, and the 500 MB of RAM I got back.
I did a thing this afternoon. My family domain, cinque.dk, had been running a WordPress multisite for a while:
cinque.dk/- family landing (theoaknuttheme)cinque.dk/wedding/- our wedding site (Elementor, 8 pages, 354 attachments, 291 MB of photos)cinque.dk/bomba/- a blog placeholder that never became a blog
Served by nginx + PHP-FPM 8.3 + MariaDB on a 4 GB Ubuntu LXC. As a stack for something that at its most dynamic once a year runs “edit typo on wedding page” - it’s expensive. ~500 MB of RAM baseline, a WP-admin login attack surface, five figures’ worth of bot requests hitting /xmlrpc.php per month, and a PHP version number I was silently dreading having to upgrade one day.
So I moved all three sites to static. Nginx only. No PHP, no database, no application server. Took an afternoon. Here’s what went in, what came out, and the two or three places I almost wrote a bug report for something that was my own fault.
The plan (in one paragraph)
Stand up the static versions in parallel directories on the server (wedding-next/, bomba-next/, root-next/) without touching anything live. Once all three serve correctly at staging paths (/wedding-next/, /bomba-next/, /root-next/), rewrite the nginx config to swap the live paths to those directories in a single reload. Back up everything backup-able first: the WordPress database as a mysqldump.gz, the entire /var/www WP tree as a tarball, the nginx config as a .bak. If anything goes wrong, rolling back is a one-line cp + systemctl reload nginx.
Step 1: snapshot /wedding
The wedding site is static content - an event that happened, pages that won’t change. The right thing to do is not migrate it. The right thing to do is mirror it. wget -mkEp on the live site, with the --no-parent guard so it doesn’t wander into /bomba/ or the root:
wget -mkEp --no-parent --no-verbose \
--user-agent="Mozilla/5.0 BartMigration/1.0" \
https://cinque.dk/wedding/Forty seconds later I had 281 MB of static HTML + Elementor-generated CSS + 1,860 JPEG files. Rsynced into /var/www/cinque.dk/wedding-next/. All eight page routes worked as .html files served directly by nginx, zero PHP involved.
Step 2: Hugo for /bomba
The /bomba subsite was effectively empty (1 post, 1 page, the default WP “Hello world!”). So I didn’t migrate anything - I just built a new Hugo site to take its place. PaperMod theme, heavily overridden. Markdown source, rsync deploy. A handful of posts ready on day one.
Step 3: snapshot the root, same wget trick
The root site had one landing page in the oaknut theme. I temporarily reverted nginx to the pre-cutover config for 3 seconds, wget’d the root, then re-applied the cutover config. Saved the HTML to /var/www/cinque.dk/root-next/. Total downtime on the flip-and-flip-back: about five seconds. Good luck catching that.
Step 4: cut over nginx in one reload
The old config routed / → PHP-FPM (WordPress). The new config routes:
location ~ /(wp-admin|wp-login|wp-json|xmlrpc\.php|wp-cron\.php) {
return 444;
}
location / {
root /var/www/cinque.dk/root-next;
try_files $uri $uri/ /index.html =404;
}
location /wedding/ {
alias /var/www/cinque.dk/wedding-next/;
try_files $uri $uri/ $uri/index.html =404;
}
location /bomba/ {
alias /var/www/cinque.dk/bomba-next/;
try_files $uri $uri/ $uri/index.html =404;
}
# Missing trailing slash helpers
location = /wedding { return 301 /wedding/; }
location = /bomba { return 301 /bomba/; }return 444 on WP endpoints is nginx-specific - it drops the TCP connection without sending any response, not even a status code. Bot scanners that come looking for /wp-login.php get nothing back. Nmap reports their port as filtered. Shodan has nothing to index.
One sudo systemctl reload nginx. All three sites now serve from static files. Zero process restarts, zero downtime for any user with an open connection.
Step 5: eradicate WordPress
With the sites confirmed serving correctly:
# Kill all running state
sudo systemctl stop php8.3-fpm mariadb
sudo systemctl disable php8.3-fpm mariadb
# Drop the DB
sudo mysql -e "DROP DATABASE wordpress;"
# Delete the files
sudo rm -rf /var/www/cinque.dk/wp-admin
sudo rm -rf /var/www/cinque.dk/wp-includes
sudo rm -rf /var/www/cinque.dk/wp-content
sudo rm -f /var/www/cinque.dk/wp-*.php
sudo rm -f /var/www/cinque.dk/index.php
sudo rm -f /var/www/cinque.dk/xmlrpc.php
# Nuke the packages
sudo apt -y purge 'php8.3-*' mariadb-server
sudo apt -y autoremoveResource delta on the LXC, before vs after:
| Metric | Before (WP) | After (static) |
|---|---|---|
| RAM used | ~500 MB | ~40 MB |
| Disk used | 1,020 MB | 284 MB |
| Dynamic attack surface | PHP + MariaDB + wp-login brute force | 0 |
| Processes beyond nginx + sshd | ~12 | 0 |
The gotchas
Three things I almost blamed on nginx before catching that it was me.
1. wget-saved filenames with ?ver=X in them
WordPress likes to cache-bust CSS/JS with query strings: jquery.min.js?ver=3.7.1. When wget -k rewrites links for local viewing, it URL-encodes the ? as %3F in the HTML, and it saves the file to disk with a literal ? in the filename.
Nginx, when it receives a request for .../jquery.min.js%3Fver=3.7.1, decodes %3F back to ?, parses the request URI, strips everything after the ? as query string, and looks for a file called jquery.min.js. Which doesn’t exist on disk - the file on disk is called jquery.min.js?ver=3.7.1. Every asset 404s.
Fix was a one-shot rename of the 1,975 affected files and a sed pass through the HTML/CSS/JS to strip the %3Fver=X from src/href:
sudo find /var/www/cinque.dk/wedding-next -type f -name "*?*" -print0 | \
while IFS= read -r -d "" f; do
newname="${f%\?*}"
[ ! -e "$newname" ] && sudo mv -- "$f" "$newname"
done
sudo find /var/www/cinque.dk/wedding-next -type f \
\( -name "*.html" -o -name "*.css" -o -name "*.js" \) -print0 | \
while IFS= read -r -d "" f; do
sudo sed -i -E 's/%3F[A-Za-z0-9_.=&%-]+//g' "$f"
doneAn hour of my life. Worth knowing next time.
2. A regex location was shadowing my alias blocks
The original WP nginx config had this, very innocuously:
location ~* \.(css|js|gif|ico|jpeg|jpg|png|woff|woff2|ttf|svg|eot)$ {
expires max;
log_not_found off;
}Harmless, right? Asset caching. I left it in the cutover config. Thirty seconds later every .css and .js request under /wedding/ and /bomba/ was 404ing.
Regex locations in nginx match before prefix locations. That regex was eating the asset requests before they could reach my location /wedding/ { alias ... } block, serving them from the default server root (/var/www/cinque.dk), which no longer contained those files post-cutover.
Removed the regex location, moved the expires header into each specific location block. Problem gone. Lesson: when you rewrite a server block, kill every rule that isn’t explicitly doing something you need - no sentimental attachment to old config, it’s all just text.
3. Trailing-slash redirects
After the cutover, https://cinque.dk/wedding/ rendered the wedding site correctly. https://cinque.dk/wedding (no trailing slash) rendered the root landing. Fun.
Reason: location /wedding/ only matches URIs starting with /wedding/. The one-less /wedding falls through to location /, which served /root-next/index.html - the new root page, not a 404. So a visitor without the trailing slash would not just fail - they’d get a confidently-wrong wedding experience.
Two one-liners to fix:
location = /wedding { return 301 /wedding/; }
location = /bomba { return 301 /bomba/; }location = is exact-match. Requests for exactly /wedding get a permanent redirect to /wedding/, which then matches the prefix block and serves from the alias. Every canonical-URL test passes.
The trade
A week from now I’ll be tempted to add “one small dynamic feature” and remember why I loved WordPress. The point of this migration is to stop pretending I wanted a dynamic CMS for my family landing page. I did not.
What I gained: 460 MB of RAM on the LXC, ~700 MB of disk, zero dynamic attack surface, a 10x faster first-paint, a deploy pipeline that’s just rsync, a security posture where the web server literally cannot serve anything it doesn’t have on disk as a static file, and a config I can diff in one screen.
What I lost: a browser-based editor for the family and wedding sites, and ~5 seconds of “what if I change my mind” doubt that I’ve made peace with.
If you’re thinking about this
The short recipe for any WP site that’s 90% static content in spirit:
mysqldump | gzipthe whole DB. Keep it safe.- Tarball
/var/www/<site>before touching anything. Keep it safe. wget -mkEp --no-parentthe live site to a staging dir.- Rename filenames with
?in them (see gotcha #1),sedout the%3Frefs in HTML. - Stage the nginx config in a
.newfile.nginx -tbefore reloading. - Reload. Hard-refresh every URL that matters and click around.
- Once confident, stop PHP-FPM and MariaDB, apt-purge them, delete
wp-*from disk. - Keep the backups. They are not worth much storage and they are your whole safety net.
If you follow that, you can be off WordPress by dinner.
- bomba