docs: migration complete — rename README to sibiuindependent.ro.MD, full post-cutover update

- Renamed README.md to sibiuindependent.ro.MD
- Status updated to LIVE as of 2026-05-31
- All open items resolved and documented
- Added post-cutover issues & fixes section (8 issues):
  SimpleXML/WPS3Media, Perfmatters login URL, InformatiQ-Toolkit
  301→example.com, empty permalink_structure, Redis plugin inactive,
  backup cron wrong format, Redis maxmemory unbounded, nginx missing webp
- Updated pf rules (zamolxis HTTP locked to NPM 100.89.238.4)
- Corrected decebal crontab format (no user field in user crontab)
- Current performance metrics from 2026-05-31 health check
- Deployment status table with all items checked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Malin
2026-05-31 22:33:22 +02:00
parent e04ac02fa2
commit 258d7b2219
2 changed files with 720 additions and 1118 deletions

1118
README.md

File diff suppressed because it is too large Load Diff

720
sibiuindependent.ro.MD Normal file
View File

@@ -0,0 +1,720 @@
# sibiuindependent.ro — Stack Migration
## May 2026
Migration of `sibiuindependent.ro` from Debian 12 + AApanel to FreeBSD 15.0 minimal stack.
**Status: LIVE as of 2026-05-31. Migration complete.**
---
## Live Architecture
```
[internet] → [NPM node, 100.89.238.4 Tailscale] → [zamolxis 100.115.128.41] → [decebal 100.67.166.29]
```
| Node | Role | OS | vCPU | RAM | Disk | Tailscale IP |
|---|---|---|---|---|---|---|
| zamolxis | Web | FreeBSD 15.0-p9 | 6 | 24GB | 80GB HDD | 100.115.128.41 |
| decebal | Database | FreeBSD 15.0-p9 | 6 | 30GB | 20GB OS + 100GB ZFS data (SSD) | 100.67.166.29 |
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.
---
## Stack
### zamolxis (web)
| Component | Version / Detail |
|---|---|
| OS | FreeBSD 15.0-RELEASE-p9 |
| Web server | nginx 1.30.2 |
| PHP | 8.5.4 + FPM (unix socket `/var/run/php-fpm-si.sock`) |
| Page cache | nginx fastcgi_cache (`/var/cache/nginx/si`, 4GB max) |
| Object cache | Redis Object Cache plugin v2.8.0 (Till Krüss) via unix socket |
| Bytecode cache | OPcache 256MB, 20000 files, JIT **disabled** (caused FPM crashes with WordPress plugin stacks) |
| Redis | 8.6.2, unix socket `/var/run/redis/redis.sock`, maxmemory 3GB allkeys-lru |
| 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`) |
| Login URL | `/intrarepresa` (Perfmatters custom login — `/wp-login.php` and `/wp-admin` return 403) |
| WP prefix | `ddf44d_` |
| Firewall | pf — HTTP only from NPM (100.89.238.4), SSH only from mgmt + Tailscale |
**Active PHP extensions:** bcmath, curl, exif, fileinfo, gd, imagick, intl, mbstring, mysqli, opcache, pcntl, pdo_mysql, pecl-redis, posix, simplexml, soap, sockets, sodium, xml, xmlreader, xmlwriter, xsl, zip
Note: `php85-simplexml` is required by WPS3Media (AWS SDK XML parsing). Without it, the entire WPS3Media plugin fails to instantiate silently.
### decebal (db)
| Component | Version / Detail |
|---|---|
| OS | FreeBSD 15.0-RELEASE-p9 |
| Database | MySQL 8.4.9 LTS |
| Database name | `sql_sibiuindepen` |
| Socket | `/var/db/mysql/mysql.sock` |
| Data directory | `/var/db/mysql` (ZFS pool `data/mysql`) |
| ZFS settings | recordsize=16K, compression=lz4, primarycache=metadata |
| InnoDB buffer pool | 26GB (dataset ~1.85GB — fits entirely in RAM) |
| Firewall | pf — MySQL only from zamolxis (100.115.128.41), SSH only from mgmt + Tailscale |
| Backup | `/root/db-backup.sh` daily 02:00, 7-day retention in `/var/db/backups/mysql/` |
| ZFS snapshots | Weekly (Sunday 00:30), last 4 kept |
---
## Migration Source (decommissioned)
| Node | Role | OS | Tailscale IP |
|---|---|---|---|
| dracula | Web | Debian 12 + AApanel | 100.99.157.56 |
| transilvan | Database | Debian 12 + AApanel | — |
| transilvan MySQL socket | — | — | `/tmp/mysqld.sock` |
---
## OS Selection
### 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 |
| Memcached | Replaced by Redis (unix socket, faster, simpler) |
| Fail2ban / SSHGuard | No public SSH port, Tailscale-only |
| Postfix / sendmail | SMTP plugin in use, no local MTA needed |
| Wazuh / Five Nines / Beszel / Telegraf | Not required — Proxmox native monitoring covers VM metrics |
| memory-cleanup.sh | Linux `/proc` hack, was on wrong server anyway |
| ngxblocker | Not applicable |
| AApanel cron scripts | Panel-specific: acme renewal, site_total, backup.py |
## Caching Architecture
| Layer | Old | New |
|---|---|---|
| Page cache | W3TC on-disk (4.8GB, PHP-generated) | nginx fastcgi_cache (PHP bypassed entirely on HIT) |
| Object cache | W3TC + Memcached (TCP) | Redis Object Cache + Redis (unix socket) |
| Bytecode cache | OPcache | OPcache (JIT disabled) |
| Sessions | PHP file sessions | Redis via unix socket |
---
## Host Hardware
| Host | CPU | RAM | Storage | VM |
|---|---|---|---|---|
| zamolxis host | Xeon E5-1620 v2 @ 3.70GHz (8 logical) | 64GB | Datacenter HDD | zamolxis (ID 900) |
| decebal host | Xeon E5-1620 v2 @ 3.70GHz (8 logical) | 32GB | SSD 160GB | decebal (ID 901) — sole VM |
---
## VM Provisioning
### 1. FreeBSD 15.0 ISO
```sh
wget -P /var/lib/vz/template/iso/ \
https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/15.0/FreeBSD-15.0-RELEASE-amd64-disc1.iso
```
### 2. zamolxis (ID 900, HDD host)
```sh
qm create 900 \
--name zamolxis \
--memory 24576 --balloon 24576 \
--cores 6 --cpu host \
--machine q35 --bios ovmf \
--efidisk0 local:1,format=raw \
--net0 virtio,bridge=vmbr0 \
--ostype other \
--scsihw virtio-scsi-single \
--scsi0 local:80,format=raw \
--cdrom local:iso/FreeBSD-15.0-RELEASE-amd64-disc1.iso \
--boot order=ide2;scsi0 \
--agent enabled=1
```
### 3. decebal (ID 901, SSD host — sole VM)
```sh
qm create 901 \
--name decebal \
--memory 30720 --balloon 0 \
--cores 6 --cpu host \
--machine q35 --bios ovmf \
--efidisk0 local:1,format=raw \
--net0 virtio,bridge=vmbr0 \
--ostype other \
--scsihw virtio-scsi-single \
--scsi0 local:20,format=raw,discard=on,ssd=1 \
--cdrom local:iso/FreeBSD-15.0-RELEASE-amd64-disc1.iso \
--boot order=ide2;scsi0 \
--agent enabled=1
qm set 901 --scsi1 local:100,format=raw,discard=on,ssd=1
```
`--balloon 0` is required — memory ballooning must not reclaim InnoDB buffer pool pages.
### 4. FreeBSD installer settings (both VMs)
```
Welcome → Install
Hostname → zamolxis (or decebal)
Distribution → base, kernel only
Partitioning → Auto (ZFS), stripe, da0, swap 2GB, lz4
Network → vtnet0, temporary IP for pkg bootstrap
SSHD → enable
```
### 5. First boot (both VMs)
```sh
freebsd-update fetch install
pkg update && pkg upgrade -y
pkg install -y qemu-guest-agent tailscale sudo curl wget
sysrc qemu_guest_agent_enable=YES
service qemu-guest-agent start
sysrc tailscaled_enable=YES
service tailscaled start
tailscale up --hostname=zamolxis # or decebal
tailscale ip -4
```
---
## Phase 1 — DB Node (decebal)
### ZFS
```sh
zpool create -o ashift=12 data /dev/da1
zfs create -o mountpoint=/var/db/mysql data/mysql
zfs set recordsize=16K data/mysql
zfs set primarycache=metadata data/mysql
zfs set compression=lz4 data/mysql
```
### MySQL 8.4 LTS
MySQL 8.4.9 is the current LTS branch. MySQL 9.0/9.1 were Oracle's "innovation" track — both EOL and removed from FreeBSD ports.
```sh
pkg install mysql84-server
sysrc mysql_enable=YES
sysrc mysql_dbdir=/var/db/mysql
```
`/usr/local/etc/mysql/my.cnf`:
```ini
[mysqld]
bind-address = 100.67.166.29
port = 3306
mysqlx = 0
datadir = /var/db/mysql
socket = /var/db/mysql/mysql.sock
log_error = /var/db/mysql/decebal.err
pid-file = /var/db/mysql/decebal.pid
innodb_buffer_pool_size = 26G
innodb_buffer_pool_instances = 8
innodb_redo_log_capacity = 2G
innodb_flush_log_at_trx_commit = 1
innodb_flush_method = O_DIRECT
innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
max_connections = 300
thread_cache_size = 16
table_open_cache = 4000
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
slow_query_log = 1
slow_query_log_file = /var/db/mysql/slow.log
long_query_time = 2
binlog_expire_logs_seconds = 259200
skip-name-resolve
sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
[client]
socket = /var/db/mysql/mysql.sock
```
Note: `innodb_redo_log_capacity` replaces deprecated `innodb_log_file_size` in MySQL 8.4.
### pf (decebal)
`/etc/pf.conf`:
```
ext_if = "vtnet0"
ts_if = "tailscale0"
mgmt = "{ 127.0.0.0/8, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, 194.56.239.153, 109.69.48.0/24 }"
zamolxis_ts = "100.115.128.41"
set skip on lo0
set block-policy drop
scrub in all
block in all
block out quick inet6 all
pass out all keep state
pass in on $ext_if proto tcp from $mgmt to any port 22 keep state
pass in on $ts_if proto tcp to any port 22 keep state
pass in on $ts_if proto tcp from $zamolxis_ts to any port 3306 keep state
```
### DB Backup (decebal)
`/root/db-backup.sh`:
```sh
#!/bin/sh
BACKUP_DIR="/var/db/backups/mysql"
DB="sql_sibiuindepen"
DATE=$(date +%Y%m%d_%H%M%S)
LOG="/var/log/db-backup.log"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] Starting backup of $DB" >> "$LOG"
/usr/local/bin/mysqldump \
--socket=/var/db/mysql/mysql.sock \
--single-transaction \
--quick \
--routines \
--triggers \
--set-gtid-purged=OFF \
"$DB" | gzip > "${BACKUP_DIR}/${DB}_${DATE}.sql.gz"
if [ $? -eq 0 ]; then
SIZE=$(du -sh "${BACKUP_DIR}/${DB}_${DATE}.sql.gz" | cut -f1)
echo "[$(date)] Backup OK: ${DB}_${DATE}.sql.gz ($SIZE)" >> "$LOG"
else
echo "[$(date)] BACKUP FAILED for $DB" >> "$LOG"
exit 1
fi
find "$BACKUP_DIR" -name "${DB}_*.sql.gz" -mtime +7 -delete
echo "[$(date)] Pruned backups older than 7 days" >> "$LOG"
```
Root crontab on decebal (`crontab -l`):
```
# Daily MySQL dump at 02:00
0 2 * * * /root/db-backup.sh
# Weekly ZFS snapshot (Sunday 00:30)
30 0 * * 0 zfs snapshot data/mysql@weekly-$(date +%Y%m%d)
# Prune ZFS snapshots older than 4 weeks (Sunday 01:00)
0 1 * * 0 zfs list -H -t snapshot -o name data/mysql | sort | head -n -4 | xargs -r zfs destroy
```
Note: user crontab format — no `user` field between time spec and command (that is `/etc/crontab` format).
---
## Phase 2 — Web Node (zamolxis)
### Packages
```sh
pkg install -y nginx redis git curl wget
sysrc nginx_enable=YES redis_enable=YES
service redis start
pkg install -y php85 \
php85-bcmath php85-curl php85-exif php85-fileinfo \
php85-gd php85-pecl-imagick php85-intl php85-mbstring \
php85-mysqli php85-pcntl php85-pdo_mysql php85-posix \
php85-pecl-redis php85-simplexml php85-soap php85-sockets \
php85-sodium php85-xml php85-xmlreader php85-xmlwriter \
php85-xsl php85-zip ImageMagick7
sysrc php_fpm_enable=YES
```
`php85-simplexml` is mandatory — WPS3Media uses the AWS SDK which requires SimpleXML. Without it the plugin silently fails to instantiate.
### PHP-FPM pool
`/usr/local/etc/php-fpm.d/sibiuindependent.conf`:
```ini
[sibiuindependent]
user = www
group = www
listen = /var/run/php-fpm-si.sock
listen.owner = www
listen.group = www
pm = dynamic
pm.max_children = 80
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 16
pm.max_requests = 500
```
`/usr/local/etc/php/php.ini` (relevant):
```ini
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.jit = 0
opcache.jit_buffer_size = 0
session.save_handler = redis
session.save_path = "unix:///var/run/redis/redis.sock"
```
JIT is explicitly disabled. WordPress plugin stacks (especially Yoast + TagDiv composer) caused PHP-FPM crashes under JIT on this install.
### nginx
`/usr/local/etc/nginx/nginx.conf`:
```nginx
worker_processes auto;
worker_rlimit_nofile 65536;
events { worker_connections 8192; }
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
server_tokens off;
fastcgi_cache_path /var/cache/nginx/si
levels=1:2
keys_zone=si_cache:64m
max_size=4g
inactive=24h
use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
server {
listen 80;
server_name sibiuindependent.ro www.sibiuindependent.ro;
root /var/www/sibiuindependent.ro;
index index.php;
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string) { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin/|/wp-login|/xmlrpc|/feed|sitemap") {
set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in") {
set $skip_cache 1; }
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php-fpm-si.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache si_cache;
fastcgi_cache_valid 200 301 302 24h;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache-Status $upstream_cache_status;
}
location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot|mp4|webm|ogv)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
log_not_found off;
}
}
}
```
`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.
### Redis
`/usr/local/etc/redis.conf` (relevant):
```
maxmemory 3gb
maxmemory-policy allkeys-lru
unixsocket /var/run/redis/redis.sock
unixsocketperm 777
```
### pf (zamolxis)
`/etc/pf.conf`:
```
ext_if = "vtnet0"
ts_if = "tailscale0"
mgmt = "{ 127.0.0.0/8, 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, 194.56.239.153, 109.69.48.0/24 }"
npm_ts = "100.89.238.4"
set skip on lo0
set block-policy drop
scrub in all
block in all
block out quick inet6 all
pass out all keep state
pass in on $ext_if proto tcp from $mgmt to any port 22 keep state
pass in on $ts_if proto tcp to any port 22 keep state
pass in on $ts_if proto tcp from $npm_ts to any port 80 keep state
```
### wp-config.php changes
```php
define( 'DB_HOST', '100.67.166.29' );
define( 'DB_PASSWORD', 'W0rdPr3ss@SI2026!' );
define( 'WP_REDIS_SCHEME', 'unix' );
define( 'WP_REDIS_PATH', '/var/run/redis/redis.sock' );
define( 'WP_DEBUG', false );
define( 'WP_DEBUG_LOG', false );
define( 'WP_DEBUG_DISPLAY', false );
```
### post-rsync-fixup.sh
`/tmp/post-rsync-fixup.sh` — run after every rsync:
- Rewrites `DB_HOST` and `DB_PASSWORD` in wp-config.php
- Removes W3TC plugin and its drop-ins (advanced-cache.php, db.php)
- Installs Redis Object Cache drop-in if missing
- Fixes `.user.ini` open_basedir
- Fixes ownership (`www:www`) and wp-config permissions (640)
- Reloads PHP-FPM
### Kernel tuning (sysctl.conf, both VMs)
```
kern.maxfiles=200000
kern.ipc.somaxconn=4096
net.inet.tcp.sendspace=262144
net.inet.tcp.recvspace=262144
```
Additional on zamolxis:
```
vfs.zfs.arc_max=2147483648 # cap ZFS ARC at 2GB, leave RAM for PHP-FPM + nginx UBC
```
### Crons (zamolxis root crontab)
```
*/5 * * * * curl -s -o /dev/null https://sibiuindependent.ro/wp-cron.php?doing_wp_cron
0 3 * * * /root/cache-warmer.sh > /dev/null 2>&1
0 3 * * 1 /root/js-refresh.sh >> /root/js-refresh.log 2>&1
@reboot sleep 60 && /root/cache-warmer.sh > /dev/null 2>&1
```
---
## Cache Warmer
`/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).
```sh
#!/bin/sh
DOMAIN="sibiuindependent.ro"
NGINX="127.0.0.1"
CONCURRENCY=6
SITEMAP_INDEX=$(curl -s --max-time 30 \
--resolve "${DOMAIN}:80:${NGINX}" \
"http://${DOMAIN}/sitemap_index.xml")
SITEMAPS=$(echo "$SITEMAP_INDEX" | grep -oE 'https?://[^<]+\.xml' \
| sed "s|https://${DOMAIN}|http://${DOMAIN}|")
URLS=$(for SM in $SITEMAPS; do
curl -s --max-time 30 --resolve "${DOMAIN}:80:${NGINX}" "$SM" \
| grep -oE 'https?://[^<]+' | grep -v '\.xml' \
| sed "s|https://${DOMAIN}|http://${DOMAIN}|"
done)
echo "$URLS" | grep 'http' | xargs -P $CONCURRENCY -I{} sh -c \
'curl -s -o /dev/null --max-time 20 --resolve "'"${DOMAIN}"':80:'"${NGINX}"'" "{}" 2>/dev/null'
```
---
## JS Asset Refresh
`/root/js-refresh.sh` — weekly, Monday 03:00:
```sh
#!/bin/sh
WEBROOT=/var/www/sibiuindependent.ro
curl -s https://cdn.onesignal.com/sdks/OneSignalSDK.js > ${WEBROOT}/onesignal.js
curl -s https://cdn.onesignal.com/sdks/OneSignalPageSDKES6.js > ${WEBROOT}/onesignal-es6.js
curl -s https://connect.facebook.net/en_US/sdk.js > ${WEBROOT}/fb-sdk.js
curl -s https://connect.facebook.net/ro_RO/sdk.js > ${WEBROOT}/fb-sdk-ro.js
```
---
## DB Migration
Source: transilvan (Debian 12 + MariaDB), socket `/tmp/mysqld.sock`, SSH port 79.
Destination: decebal (FreeBSD 15 + MySQL 8.4.9), socket `/var/db/mysql/mysql.sock`.
```sh
# Run from zamolxis (has access to both via Tailscale/direct)
ssh -p 79 root@100.99.157.56 \
"mysqldump -S /tmp/mysqld.sock --single-transaction --no-tablespaces \
--routines --triggers --events sql_sibiuindepen" \
| 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.
Fix: `wp --allow-root rewrite structure '/%year%/%monthnum%/%day%/%postname%/' --hard && wp rewrite flush`
**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.
Fix: `wp --allow-root plugin activate redis-cache`
### 5. DB backup cron never ran
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.
---
## Current Performance (2026-05-31)
### zamolxis
| Metric | Value |
|---|---|
| Load average | 1.76 (6 vCPU) |
| RAM | 24GB, no swap |
| Disk | 10GB / 75GB (14%) |
| nginx cache | 8,288 files, 1.0GB |
| Redis hit rate | 86.9% |
| Redis memory | 1.03GB / 3GB cap |
| OPcache | 99.6%+ hit rate, 3,343 cached scripts |
### decebal
| Metric | Value |
|---|---|
| Load average | 0.06 (6 vCPU) |
| RAM | No swap, ~18GB free |
| MySQL DB size | 1,850MB |
| Posts | 143,266 |
| Threads connected | 4 |
| Slow queries | 73 in 2 days |
| Disk (MySQL data) | 3.4GB / 94GB (4%) |
| Backup | 137MB daily, verified |
---
## Deployment Status
| Item | Status |
|---|---|
| zamolxis provisioned and configured | Done |
| decebal provisioned and configured | Done |
| rsync (7.1GB webroot) | Done |
| DB import (143,266 posts, 121 tables) | Done — 2026-05-31 |
| NPM cutover (dracula → zamolxis) | Done — 2026-05-31 |
| Tailscale IPs confirmed | zamolxis: 100.115.128.41 / decebal: 100.67.166.29 |
| NPM Tailscale IP confirmed | 100.89.238.4 |
| pf HTTP rule locked to NPM | Done |
| php85-simplexml installed (WPS3Media) | Done |
| WPS3Media S3 rewrite working | Done — 101,145 files |
| Redis Object Cache plugin active | Done |
| Redis maxmemory capped | Done — 3GB allkeys-lru |
| WP_DEBUG disabled | Done |
| nginx static location includes webp | Done |
| InformatiQ-Toolkit redirect fixed | Done — 403 instead of 301→example.com |
| Permalink structure restored | Done |
| DB backup cron fixed and verified | Done — 137MB/run |
| Temp credential files removed | Done |
| Migration key removed | Done |
| Cache warmer running | Done — @reboot + daily 03:00 |
| Old VMs (dracula, transilvan) | Keep powered, no traffic — decommission after 7 days stable |