Hardening a WordPress Server with Claude Code

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 jp user wasn’t in the docker group. 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 statementGRANT 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.30 to 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_stack from 128K to 256K and removing deprecated query_cache settings. The upgrade ran automatically when the new container started.
  • Custom WordPress Dockerfile — The old setup ran apt-get install cron on 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.1 in 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 acrobat file.
  • 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 pipefail to 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

  1. 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.
  2. 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.1 for services that don’t need external access.
  3. 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.
  4. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.