Going live: a lab staging site and a production Linode
Why two copies of the site
I wanted a website I own outright, built on hardware I control, that I can break and fix without anyone watching. I also didn’t want my home connection serving the public internet. So the site runs in two places:
- Staging lives in a Proxmox container in the lab. It’s where I write, rearrange, try plugins and learn WordPress. Nothing here is public.
- Production is a small Ubuntu 24.04 server on Linode that serves www.umbracore.net. Nobody edits it directly.
Staging is the source of truth. When I like what I see, I push it to production.
The production stack
The Linode runs a layered setup:
- Nginx at the front handles HTTPS, HTTP/2, compression, static files and rate limiting on the login page.
- Apache sits behind it on the loopback interface, so WordPress rewrite rules behave the same way they would on a standard host.
- PHP 8.3-FPM and MariaDB do the actual work.
- Let’s Encrypt provides the certificate and renews it automatically. The bare domain redirects to
www.
Around that: a host firewall plus Linode’s cloud firewall, fail2ban watching SSH and the login page, security headers, automatic security updates, and the usual WordPress hardening (no file editing from the dashboard, no PHP execution in uploads, XML-RPC blocked). Within the first hour the logs already showed bots probing for .env files and xmlrpc.php. All of them got refused.
One button to publish
The part I’m happiest with is the publish step. In the staging dashboard there’s a Push to Live button. Clicking it:
- Checks that both servers are healthy and on the same WordPress version.
- Backs up the live database (the last ten are kept).
- Exports the staging database and rewrites every internal address to the public domain.
- Syncs themes, plugins and uploads.
- Imports the database on the live server.
- Loads the home page. If it doesn’t come back healthy, the live database is restored automatically.
There’s also a Preview button that shows exactly what would change without touching anything. Behind the scenes it’s a bash script, a systemd path unit, and a small must-use plugin. WordPress itself never gets SSH keys or shell access; it can only ask for a push. On the server side, the deploy account can run WordPress commands and nothing else.
What broke along the way
Three things bit me, and each one taught me something.
My own firewall blocked me. The first push opened about a dozen SSH connections in a few seconds. My UniFi gateway’s intrusion prevention matched it to a rule for outbound SSH scanning and quietly blocked the container. From the server’s side the traffic simply stopped arriving. The fix was twofold: the push now reuses one SSH session for every step, and the gateway has an exclusion for my own server.
Assumptions about users. The publish button writes a small trigger file. It failed because PHP on my staging box doesn’t run as the usual www-data user. One chgrp fixed it, and the installer now detects the real PHP user instead of assuming.
Every page but the home page returned 404. WordPress needs rewrite rules in .htaccess for clean URLs like /projects/ai-stack/. The installer asked WP-CLI to write them, but WP-CLI can’t detect Apache’s rewrite module from the command line, so it silently skipped the file. Staging runs nginx and doesn’t use .htaccess, which is why I never saw it there. The installer now writes the file itself and tests a sub-page, not just the home page.
What’s next
- Turn on full-server backups for the Linode.
- Move the publish connection onto my Tailscale network and close public SSH entirely.
- Keep writing. That’s the point of having the site.
The updated AI Stack schematic now includes the web tier, showing how staging, the gateway and the production server connect.