The Problem
I’ve been running jphein.com on a Google Cloud e2-micro instance since 2022. It started as a basic Docker Compose setup — WordPress, MySQL 5.7, and an nginx reverse proxy — with a backup script I wrote and mostly forgot about. Over time, things drifted: the auto-update script stopped working (silently!), legacy files accumulated, and MySQL 5.7 went end-of-life without me noticing.
I decided to use Claude Code to audit and harden the entire setup. Here’s what we found and fixed in a single session.
What Claude Code Found
Critical Issues
- Auto-update completely broken — The script ran Docker commands via cron, but the
jpuser wasn’t in thedockergroup. Every Sunday at 2am it would fail silently and report “completed successfully” because the pipe-to-while-read pattern swallowed all errors. - MySQL 5.7 end-of-life — No security patches since October 2023. Running for over two years without updates.
- Restore script had a broken GRANT statement —
GRANT ALL ON wp_db.* TO @%grants to nobody. If I ever needed to restore, the database user wouldn’t have access. - MySQL port 3306 exposed to the internet — Docker bypasses UFW by default, so even though UFW didn’t have a rule for 3306, Docker’s own iptables chain was forwarding it publicly.
Security Gaps
- Zero HTTP security headers — No HSTS, no X-Frame-Options, no Content-Type-Options. The server was also broadcasting
X-Powered-By: PHP/8.3.30to anyone who asked. - Legacy files with hardcoded credentials — An old RDP launcher script (
acrobat) containing a plaintext password was sitting in the web root since 2022, publicly accessible. - WordPress port 8080 exposed — Same Docker-bypasses-UFW issue.
What We Fixed
Infrastructure
- MySQL 5.7 → 8.0.45 — In-place upgrade. Required bumping
thread_stackfrom 128K to 256K and removing deprecatedquery_cachesettings. The upgrade ran automatically when the new container started. - Custom WordPress Dockerfile — The old setup ran
apt-get install cronon every container restart, which was slow and fragile (network-dependent). Now cron is baked into the image. - Ports locked down — MySQL and WordPress ports bound to
127.0.0.1in docker-compose. Port 8080 removed from UFW.
Security
- nginx security headers — Added Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Stripped X-Powered-By.
- Removed 9 legacy files from the web root — test scripts, old LTSP launchers, a Duplicator backup, and the credential-leaking
acrobatfile. - Removed 4 inactive plugins — Each unused plugin is unnecessary attack surface.
Reliability
- Rewrote auto-update script — Docker access pre-flight check, proper error capture (no more pipe-swallowing), warning tracking in email reports.
- Fixed restore script — Correct GRANT syntax with proper username and password.
- Added
set -uo pipefailto the backup script. - Set up email alerts — Installed msmtp with Gmail SMTP so backup failures and update reports actually reach my inbox instead of printing to a log file nobody reads.
Housekeeping
- Trimmed backups from 12 to 6 months — Reduced GCS storage from 20 GB to 11 GB. Six months of daily restore points is plenty for a personal site.
- Cleaned Docker images, WP cache, old logs — Freed about 900 MB on disk.
Current Architecture
Internet → Cloudflare → SWAG (nginx + Let's Encrypt)
↓
WordPress (PHP 8.3, custom Dockerfile)
↓
MySQL 8.0 (localhost only)
Backups: daily incremental to GCS via duplicity (6 months retention)
Updates: weekly via WP-CLI + Docker pull + apt upgrade
Alerts: msmtp → Gmail
Access: Tailscale only (no public SSH)
Lessons Learned
- Silent failures are the worst kind. The auto-update script “worked” for months while doing nothing. Always check that your monitoring actually detects failures — pipe-to-while-read in bash swallows exit codes.
- Docker bypasses UFW. If you expose a port in docker-compose, it’s public regardless of your firewall rules. Always bind to
127.0.0.1for services that don’t need external access. - Test your restore. A backup you can’t restore from is worthless. My GRANT statement was broken — I would have discovered this at the worst possible time.
- EOL software is a ticking clock. MySQL 5.7 was EOL for over two years. Set a calendar reminder when your database engine reaches end-of-life.
The whole audit and remediation took about an hour with Claude Code doing the heavy lifting — reading configs, running tests, making changes, and verifying everything worked after each step.
