Both VMs are on separate bare-metal Proxmox hosts. All inter-service traffic runs over Tailscale. NPM handles TLS termination. No public-facing ports on zamolxis or decebal.
| Sessions | Redis via `session.save_path = unix:///var/run/redis/redis.sock` |
| Media | wp-content/uploads local (6.5GB), S3 offload via WPS3Media to `s3.palmasolutions.net/sibiuindependent` (101,145 files in `ddf44d_as3cf_items`) |
### Candidates: OpenBSD, FreeBSD, Gentoo, Alpine Linux
**OpenBSD** — best security (pledge, unveil, W^X), but PHP 8.5 not in packages, MySQL 8.x absent, imagick friction. Viable only with PHP 8.4 + MariaDB.
**FreeBSD** — pf, Capsicum, ZFS native, PHP 8.5 + all extensions as binary packages, MySQL 8.4 LTS, Redis 8.x, official Tailscale. Strongest all-round candidate.
**Gentoo** — genuine minimalism via USE flags, but every update is a compile run. Production liability.
**Alpine Linux** — easiest migration (still Linux), musl + busybox, apk. No ZFS, no jails. Best minimal Linux option.
**Decision: FreeBSD 15.0** — ZFS native on the DB node is the tiebreaker (`zfs snapshot` before any migration step). FreeBSD 15.0 over 14.x specifically because PHP 8.5 is a binary package on 15.0.
---
## What Was Dropped
| Component | Reason |
|---|---|
| AApanel / btpanel | Replaced by direct config management |
| W3 Total Cache | Replaced by nginx fastcgi_cache + Redis Object Cache |
`webp` must be in the static location regex. WPS3Media serves WebP variants from S3; without it, `.webp` requests fall through to PHP-FPM and trigger a full WordPress load for a file that doesn't exist locally.
`/root/cache-warmer.sh` — reads Yoast sitemap index (89 sub-sitemaps), warms all URLs via HTTP directly to nginx on port 80 (nginx does not serve HTTPS; TLS is terminated at NPM).
| mysql --defaults-file=/tmp/.imp.cnf -f --get-server-public-key sql_sibiuindepen
```
Notes:
- `--no-tablespaces` instead of `--set-gtid-purged=OFF` on MariaDB source (MariaDB does not support that flag)
- `--defaults-file` must be the **first** argument to `mysql`
- `-f` (force) was needed due to a duplicate key in `ddf44d_yoast_indexable_hierarchy` — Yoast's indexable table had integrity issues, non-critical (regenerates on save)
- Final import: 143,266 posts, 121 tables, 1,850MB
---
## Post-Cutover Issues & Fixes
### 1. S3 images not loading — WPS3Media class not instantiating
**Root cause:** `php85-simplexml` not installed. WPS3Media's AWS SDK compat check (`AS3CF_Pro_Installer`) detected missing SimpleXML and blocked the entire plugin from loading with "no SimpleXML PHP module." No PHP error was emitted — plugin simply never registered its `wp_get_attachment_url` filter.
**Fix:** `pkg install -y php85-simplexml php85-xmlwriter && service php_fpm restart`
**Verification:** `Amazon_S3_And_CloudFront_Pro` class now instantiates; URLs rewrite to `https://s3.palmasolutions.net/...`. 101,145 files tracked in `ddf44d_as3cf_items`.
### 2. wp-admin "This has been disabled." — by design
Perfmatters plugin custom login URL feature: `login_url: intrarepresa`. Both `/wp-login.php` and `/wp-admin` are blocked with a 403. Login is at `/intrarepresa`.
### 3. Articles redirecting to example.com
Two concurrent causes:
**a) Empty `permalink_structure`** — the DB import came from a server that had the structure set, but something reset it on the new DB. Pretty URLs were broken entirely.
**b) InformatiQ-Toolkit security plugin** — was configured with `redirect_url: https://example.com/blocked` and `response_code: 301_custom`. Any request the plugin classified as a bot or bad actor got a 301 to example.com, which was then cached by nginx (`fastcgi_cache_valid 200 301 302 24h`) for 24 hours.
Fix: `response_code` changed to `403`, `redirect_url` cleared.
Note: nginx's `fastcgi_cache_valid` should not cache 301/302. Consider changing to `fastcgi_cache_valid 200 24h` only.
### 4. Redis Object Cache plugin inactive
The `object-cache.php` drop-in was installed and functioning (Redis was receiving cache hits). However the plugin itself was marked inactive in WordPress — WP-CLI `wp redis` commands and the admin UI status panel were non-functional.
The root crontab had `0 2 * * * root /root/db-backup.sh` — with `root` as a spurious user field (valid in `/etc/crontab` system format, not in user crontab format). Cron was trying to execute `root /root/db-backup.sh` as the command, which failed silently. No backups had run since before the migration.
Fix: removed the `root` field. First successful backup: 137MB, completed in 19 seconds.
### 6. Redis maxmemory unbounded
Redis `maxmemory` was 0 (no limit) with `noeviction` policy. On a server with competing workloads, unbounded Redis with noeviction risks OOM if the object cache grows unexpectedly.
Fix: `maxmemory 3gb`, `maxmemory-policy allkeys-lru`, persisted to `/usr/local/etc/redis.conf`.
### 7. WP_DEBUG left on after migration
`WP_DEBUG = true` was generating PHP notices on every uncached request, logged to nginx error log. Overhead on both disk I/O and PHP execution.
Fix: `WP_DEBUG`, `WP_DEBUG_LOG`, `WP_DEBUG_DISPLAY` all set to `false`.
### 8. nginx static location missing webp
The static file location regex did not include `webp`. WPS3Media serves WebP variants; without the static location match, `.webp` requests fell through to PHP-FPM, which loaded WordPress, checked the DB, found nothing local, and generated errors.
Fix: added `webp|mp4|webm|ogv` to the static location regex.
### 9. Login blocked for all users — ASE Limit Login Attempts locked NPM's IP
All traffic passes through NPM (Tailscale 100.89.238.4). nginx passed `REMOTE_ADDR = 100.89.238.4` to PHP. The Admin Site Enhancements plugin's Limit Login Attempts feature tracked all failed logins against this single IP. After multiple users tried and failed, the 24h lockout triggered — blocking everyone from `/intrarepresa`.
Fix (immediate): ASE deactivated.
Fix (permanent): nginx `real_ip_header X-Real-IP` + `set_real_ip_from 100.89.238.4` configured. `$remote_addr` now reflects true client IP from NPM's `X-Real-IP` header. Access log and all PHP plugins see real per-user IPs. If ASE is re-enabled, Limit Login Attempts will work correctly per-user.
### 10. pf HTTP restriction opened — WAF on NPM handles filtering
HTTP was restricted to NPM's Tailscale IP only. Opened to all: `pass in proto tcp from any to any port 80 keep state`. WAF rules on NPM handle filtering upstream.
### 11. Stale nginx cache on homepage for anonymous (mobile) users
No cache invalidation on post publish. Anonymous users (typically mobile) got the nginx-cached homepage for up to 24h after new posts were published. Logged-in users (desktop) bypassed cache via `wordpress_logged_in` cookie.
Fix: `si_purge_nginx_homepage_cache()` hook added to `sibiu-independent-tweaks/sb.php`. On `transition_post_status` (publish/unpublish), it calculates the homepage cache file path (MD5 of `httpGETsibiuindependent.ro/`, nginx `levels=1:2`) and deletes it. Next organic request regenerates the cache. PHP `www` user has write access to nginx cache files (same owner).