Malin 288b79d8eb feat: complete decebal deployment, update README with actual IPs and configs
- decebal fully deployed: MySQL 8.4.9 running, app user created
  (sql_sibiuindepen@100.115.128.41), backup cron installed
- ZFS pool 'data' ONLINE on /dev/da1, data/mysql at /var/db/mysql
- pf active: 3306 allowed from 100.115.128.41 only
- zamolxis -> decebal MySQL connectivity verified
- README: replace all Tailscale IP placeholders with actuals
  (zamolxis 100.115.128.41, decebal 100.67.166.29)
- README: fix PHP extension package names (pecl-imagick, pecl-redis;
  opcache bundled in base php85)
- README: fix nginx if operator (no != support in nginx if blocks)
- README: update my.cnf to deployed config (innodb_redo_log_capacity,
  innodb_buffer_pool_instances, slow query log, sql_mode)
- README: update pf configs to deployed rulesets (both nodes)
- README: update backup script to deployed version with .my.cnf auth
- README: add deployment status table, resolve open items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 10:19:38 +02:00

Sibiu Independent — Stack Migration

May 2026

Migration of sibiuindependent.ro from Debian 12 + AApanel to FreeBSD 15.0 minimal stack.


Current Architecture

[internet] → [Nginx Proxy Manager] → [dracula 100.99.157.56, Tailscale] → [transilvan, Tailscale]
Node Role OS Specs Tailscale IP
dracula Web Debian 12 16 vCPU, 32GB RAM, 245GB disk 100.99.157.56
transilvan Database Debian 12 4 vCPU, 32GB RAM, 245GB disk

Both nodes are VMs running on separate bare-metal hosts within the same datacenter (~1ms RTT). All inter-service traffic runs over Tailscale (WireGuard). The NPM node handles TLS termination and public exposure. The web and DB nodes have no public-facing ports other than what Tailscale exposes.


Current Stack Audit

dracula (web)

Component Version / Detail
OS Debian GNU/Linux 12 (bookworm)
Web server nginx 1.30.1
PHP 8.5.2 (AApanel custom build) + FPM
CMS WordPress, single site: sibiuindependent.ro
Theme Newspaper / TagDiv (td-cloud-library, td-composer, td-standard-pack)
Caching W3 Total Cache + Memcached (page + object cache)
Sessions PHP file sessions
Media wp-content/uploads 6.5GB on disk, S3-compatible offload active
Cache on disk 4.8GB (W3TC page cache — throwaway)
WP code ~700MB (core + plugins + themes)
DB connection DB_HOST=transilvan resolved via /etc/hosts → Tailscale IP
Monitoring Telegraf, Beszel agent, Wazuh agent, Five Nines agent (all dropped in migration)
Security Fail2ban, SSHGuard, sshguard
Mail Postfix + MTA-STS (SMTP plugin used, local MTA not needed)
Panel AApanel (btpanel)
Tailscale IP 100.99.157.56

Active PHP extensions: bcmath, curl, exif, fileinfo, gd, imagick, intl, json, mbstring, memcached, mysqli, mysqlnd, opcache, openssl, pcntl, pdo, pdo_mysql, pdo_sqlite, posix, soap, sockets, sodium, xml, xmlreader, xmlwriter, xsl, zip

transilvan (db)

Component Version / Detail
OS Debian GNU/Linux 12 (bookworm)
Database MariaDB 10.11.14 LTS
Database name sql_sibiuindepen
DB user sql_sibiuindepen
Data directory /www/server/data (~9.6GB raw)
Config /etc/mysql/mariadb.cnf + conf.d/
Monitoring Telegraf, Beszel agent, Wazuh agent, Five Nines agent (all dropped in migration)
Panel AApanel (btpanel)
Binlog Active (mysql-bin.*, high sequence numbers)

OS Evaluation

Candidates assessed: OpenBSD, FreeBSD, Gentoo, Alpine Linux

OpenBSD

  • Security: Best in class — pledge(2), unveil(2), W^X enforced, secure malloc, pf
  • PHP 8.5: Not in packages (ports have 8.4). Source build possible but complex dependency tree
  • php-memcached: Problematic (libmemcached issues on OpenBSD)
  • MySQL 8.x: Not in packages (MariaDB only)
  • PHP JIT: Conflicts with W^X; must disable (negligible for WordPress)
  • Monitoring agents: Wazuh: no official support. Telegraf: community build
  • Verdict: Excellent security philosophy, but too much friction for this specific stack (PHP 8.5, MySQL 8.x, imagick). Viable only if stripping monitoring stack and accepting PHP 8.4 + Redis instead of memcached

FreeBSD

  • Security: pf, Capsicum, jails, strong defaults
  • PHP 8.5: Available in ports (lang/php85). All extensions in ports
  • MySQL 8.4 LTS: Available as databases/mysql84-server
  • imagick, intl, soap, redis: All in ports
  • ZFS: Native. Game-changer for database node (atomic snapshots, checksumming, lz4 compression)
  • Monitoring: Telegraf official package. Tailscale official package. Beszel: Go binary works
  • Wazuh: Community source build only — dropped per requirements
  • Verdict: Strongest all-round candidate. PHP 8.5 + all extensions + MySQL 8.4 + ZFS + pf + official Tailscale

Gentoo

  • Minimalism: Genuine — USE flags compile exactly what is needed, OpenRC available
  • PHP 8.5: In portage (rolling release guarantees latest)
  • MySQL 8.4: In portage
  • All agents: Still Linux, everything works natively
  • Maintenance: High ongoing cost — every update is a compile run. Production liability
  • Verdict: Philosophically compelling, operationally expensive for a production news site

Alpine Linux

  • Minimalism: musl libc + busybox, ~130MB base
  • PHP 8.5: Community repos, most extensions packaged
  • Init: OpenRC, no systemd
  • Migration effort: Lowest — still Linux, apk replaces apt, paths similar
  • Verdict: Best minimal Linux option; easier than FreeBSD but fewer operational benefits (no ZFS, no jails)

Decision: FreeBSD 15.0 on both nodes

Tiebreaker: ZFS native on the DB node. zfs snapshot before any migration step, before any MySQL upgrade, before any schema change — this alone justifies the choice.

FreeBSD 15.0 over 14.x specifically because PHP 8.5 is a binary package on 15.0 — pkg install php85 and all extensions install directly, no ports compilation required. FreeBSD 15.0 was released December 2, 2025 and has 6 months of production use. MySQL 8.4 LTS, Redis 8.x, and all required PHP extensions are in the binary package tree. pf is simpler and more powerful than nftables. Tailscale has official FreeBSD support.


Target Stack Versions

Component Version Source Notes
OS FreeBSD 15.0 Release Dec 2, 2025 Production Release, 6 months in production
nginx 1.30.x pkg install nginx
PHP 8.5.6 pkg install php85 Binary pkg on FreeBSD 15 — no compilation
MySQL 8.4.9 LTS pkg install mysql84-server LTS branch. 9.0 and 9.1 both EOL and removed from ports
Redis 8.6.3 pkg install redis Redis 8.x is current major version
Tailscale latest pkg install tailscale Official FreeBSD package

Target Architecture

[internet] → [NPM node, Tailscale] → [zamolxis 100.115.128.41, Tailscale] → [decebal 100.67.166.29, Tailscale]

zamolxis (FreeBSD 15.0)            decebal (FreeBSD 15.0)
──────────────────────────            ─────────────────────────────
nginx 1.30.2 (pkg)                    ZFS pool "data" → /var/db/mysql
PHP 8.5.4 (pkg)                         recordsize=16K (InnoDB optimal)
  gd, pecl-imagick, intl, mbstring,      compression=lz4
  opcache (bundled), pdo_mysql,          primarycache=metadata
  pecl-redis, soap, sockets,           MySQL 8.4.9 LTS (pkg)
  sodium, xml, zip, exif, fileinfo     pf: 3306 from 100.115.128.41 only
nginx fastcgi_cache (filesystem)      Tailscale 100.67.166.29
Redis 8.6.2 (pkg, unix socket)        SSH on Tailscale only
pf: 80 from Tailscale only            Proxmox native monitoring
Tailscale 100.115.128.41
SSH on Tailscale only
Proxmox native monitoring

What is dropped

Component Reason
AApanel / btpanel Replaced by direct config management
W3 Total Cache Replaced by nginx fastcgi_cache + Redis Object Cache plugin
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 agent Not required
Five Nines agent Not required
Beszel agent Not required — Proxmox native monitoring covers VM metrics
Telegraf Not required — Proxmox native monitoring covers VM metrics
memory-cleanup.sh Linux /proc hack, was on wrong server anyway
ngxblocker Not applicable to new stack
AApanel cron scripts acme renewal, site_total, backup.py — all panel-specific

Caching architecture change

Layer Old New
Page cache W3TC on-disk (4.8GB, PHP-generated) nginx fastcgi_cache (filesystem, PHP bypassed entirely on HIT)
Object cache W3TC + Memcached (TCP) Redis Object Cache plugin + Redis (unix socket)
Bytecode cache OPcache OPcache (unchanged, JIT disabled — no benefit for WordPress)
Sessions PHP file sessions Redis via session.save_path unix socket

Host Hardware

Host CPU Logical CPUs RAM Storage Available to VMs VM
dracula host Xeon E5-1620 v2 @ 3.70GHz 8 (4 cores + HT) 64GB Datacenter HDD ~full zamolxis / ID 900 (web)
transilvan host Xeon E5-1620 v2 @ 3.70GHz 8 (4 cores + HT) 32GB SSD 160GB (~130GB free) 130GB decebal / ID 901 (db) — sole VM

Both hosts run Proxmox VE (confirmed via qemu-guest-agent on current VMs). Decebal's host is dedicated — no other VMs share it.


VM Sizing

Rationale for 200 concurrent users

With nginx fastcgi_cache, the vast majority of requests never invoke PHP. On a news site, traffic concentrates on recent articles — the hot working set is small and stays warm in FreeBSD's unified buffer cache (UBC) regardless of underlying disk speed.

Metric Value Notes
Expected cache HIT rate ~8590% News sites have concentrated traffic on recent content
Concurrent PHP requests at peak ~2030 200 users × ~15% miss rate
PHP-FPM workers (pm.max_children) 40 Headroom above expected peak
RAM per PHP-FPM worker ~35MB RSS WordPress with active plugins
PHP-FPM peak RAM ~1.4GB 40 workers × 35MB
nginx HIT response time <5ms Served from UBC, no PHP involved
nginx MISS response time 100400ms WP query + render
InnoDB buffer pool hit rate >99% 26GB pool, 9.6GB dataset fits entirely in RAM with 16GB to spare
Active DB connections ≤ 40 One per active PHP-FPM worker

zamolxis (web VM on HDD host)

Parameter Value
vCPU 6
RAM 24GB
Disk 80GB single virtual disk (HDD-backed)
Balloon Enabled (web VM, acceptable)

RAM breakdown: PHP-FPM peak 1.4GB + Redis ~150MB + nginx + OS ~1GB + ~20GB available for UBC page cache. With 20GB of UBC headroom, the entire nginx fastcgi_cache working set lives in RAM regardless of what is on disk — HDD latency becomes irrelevant for cached pages. Cold cache startup and log writes (sequential) are the only HDD operations that matter.

decebal (DB VM on SSD host — sole VM)

Parameter Value
vCPU 6
RAM 30GB
Disk 1 — OS 20GB virtual (SSD-backed)
Disk 2 — ZFS data pool 100GB virtual (SSD-backed)
Total disk 120GB — fits within 130GB available, 10GB buffer for Proxmox overhead
Balloon Disabled — memory ballooning must not reclaim InnoDB buffer pool RAM

RAM breakdown: innodb_buffer_pool_size 26GB + MySQL overhead ~2GB + OS ~2GB. Decebal is the sole VM on its host — 30GB allocation leaves 2GB for the Proxmox host OS, which is the practical minimum.

6 vCPU (from 8 logical CPUs on host) gives MySQL adequate threads for InnoDB background I/O, parallel queries, and connection handling while leaving 2 threads for Proxmox.

Disk sizing rationale: FreeBSD base installs to ~2GB; 20GB for the OS disk is generous for binaries, ports tree fragments, and logs. The 100GB ZFS data pool provides: 9.6GB current dataset (likely 67GB after lz4 compression), 7 days of compressed mysqldumps (~4GB each = ~28GB), binlog rotation buffer (expire_logs_days = 3), and substantial growth headroom at the current data trajectory.

Both virtual disks originate from the same physical SSD pool in Proxmox. Separation is logical: independent sizing, clean zpool create data /dev/da1, ZFS snapshots on the data disk without touching the OS disk, and straightforward future migration if a dedicated physical disk is added to the host.


VM Provisioning (Proxmox)

1. Download FreeBSD 15.0 ISO on each Proxmox host

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. Create zamolxis / ID 900 (run on the HDD host)

Via CLI:

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

Via UI:

General   → Name: zamolxis
OS        → FreeBSD (other), ISO: FreeBSD-15.0-RELEASE-amd64-disc1.iso
System    → Machine: q35, BIOS: OVMF (UEFI), Qemu Agent: ✓
Disks     → VirtIO SCSI, 80GB, Cache: None
CPU       → 6 cores, Type: host
Memory    → 24576 MB
Network   → VirtIO, vmbr0

3. Create decebal / ID 901 (run on the SSD host — sole VM)

# VM with OS disk
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

# Add second disk for ZFS data pool
qm set 901 --scsi1 local:100,format=raw,discard=on,ssd=1

--balloon 0 disables memory ballooning on the DB VM. This is required — ballooning can silently reclaim pages from the InnoDB buffer pool under host memory pressure.

4. FreeBSD installer settings (same for both VMs)

Welcome screen   → Install
Keymap           → your preference
Hostname         → zamolxis  (or decebal)
Distribution     → base, kernel  (nothing else)
Partitioning     → Auto (ZFS)
  Pool type      → stripe (single disk)
  Disk           → da0  (the OS disk)
  Swap           → 2GB
  Compress       → lz4
  Encrypt        → No
Network          → vtnet0, configure with a temporary IP for initial pkg bootstrap
                   (Tailscale will take over; this IP can be removed afterwards)
Mirror           → closest region
Root password    → set strong password
SSHD             → enable

5. First boot — both VMs

# Update base system
freebsd-update fetch install

# Bootstrap pkg and install essentials
pkg update && pkg upgrade -y
pkg install -y qemu-guest-agent tailscale sudo curl wget

# Verify package versions available
pkg search php85 | head -3      # should show 8.5.6+
pkg search mysql84-server       # should show 8.4.9
pkg search redis | grep "^redis" # should show 8.6.x

# Enable QEMU guest agent (so Proxmox can see VM IP, issue graceful shutdowns)
sysrc qemu_guest_agent_enable=YES
service qemu-guest-agent start

# Join Tailscale network
sysrc tailscaled_enable=YES
service tailscaled start
tailscale up --hostname=zamolxis    # or decebal

# Note the assigned Tailscale IP — plug into pf.conf and wp-config placeholders
tailscale ip -4

After both VMs have Tailscale IPs, replace all <zamolxis Tailscale IP> and <decebal Tailscale IP> placeholders throughout this document and in all config files.


Migration Data Inventory

Component Size Action
WordPress core + plugins + themes ~700MB rsync
wp-content/uploads 6.5GB rsync then cleanup (S3 is source of truth)
wp-content/cache 4.8GB Skip — regenerates automatically
w3tc-config 40KB Skip — W3TC removed
Database (sql_sibiuindepen) 9.6GB raw / ~3-4GB compressed dump mysqldump → import

Cron Inventory

Kept and migrated

Schedule Script Notes
*/5 * * * * wp-cron.php trigger Unchanged
0 3 * * * cache-warmer.sh Rewritten for nginx fastcgi_cache
0 15 * * * cache-warmer.sh Rewritten for nginx fastcgi_cache
0 3 * * 1 JS asset refresh (OneSignal, FB SDKs) Unchanged

Dropped

Script Reason
ngxblocker update Not using ngxblocker
analyze-nginx-attacks.sh Panel-specific monitoring
analyze-php-slow.sh Panel-specific monitoring
monitor-php-fpm.sh Panel-specific monitoring
memory-cleanup.sh Linux /proc, wrong server
AApanel acme_v2.py Panel SSL renewal
AApanel site_total_check.py Panel metric
AApanel backup.py Replaced by custom mysqldump script

New on new stack

Schedule Script
0 2 * * * mysqldump → /var/backups + optional S3 upload
0 0 * * 0 ZFS snapshot rotation (keep last 4 weeks)

Phase 0 — Preparation (live servers, non-disruptive)

# Capture configs before touching anything
scp -i /root/.ssh/id_ed25519 -P 79 \
  root@dracula:/www/wwwroot/sibiuindependent.ro/wp-config.php ./wp-config.php.bak

scp -i /root/.ssh/id_ed25519 -P 79 \
  root@dracula:/root/cache-warmer.sh ./cache-warmer.sh.original

# Test dump on transilvan — confirms dump works before migration day
ssh -i /root/.ssh/id_ed25519 -p 79 root@transilvan \
  "mysqldump -S /tmp/mysqld.sock --single-transaction \
   --routines --triggers --events sql_sibiuindepen \
   | gzip > /tmp/testdump.sql.gz && ls -lh /tmp/testdump.sql.gz"

# Record current NPM upstream for rollback reference
# Old dracula Tailscale IP: 100.99.157.56

Phase 1 — DB Node (decebal)

1.1 FreeBSD base + ZFS

# During FreeBSD 15.0 install: enable ZFS, create OS pool on first disk (da0)
# da1 is left raw for the database ZFS pool

zpool create -o ashift=12 data /dev/da1
zfs create -o mountpoint=/var/db/mysql data/mysql
zfs set recordsize=16K data/mysql        # InnoDB optimal block size
zfs set primarycache=metadata data/mysql # InnoDB manages its own buffer pool
zfs set compression=lz4 data/mysql       # free performance, transparent

1.2 MySQL 8.4 LTS

MySQL 8.4.9 is the correct choice. MySQL 9.0 was removed from FreeBSD ports (EOL). MySQL 9.1 expired March 2026 (EOL). The 9.x series is Oracle's "innovation" track with no long-term support guarantee. MySQL 8.4 is the current LTS branch.

pkg install mysql84-server
sysrc mysql_enable=YES
sysrc mysql_dbdir=/var/db/mysql

/usr/local/etc/mysql/my.cnf:

[mysqld]
# Network
bind-address             = 100.67.166.29
port                     = 3306
mysqlx                   = 0

# Paths
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
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

# Connections
max_connections          = 150
thread_cache_size        = 16
table_open_cache         = 4000

# Character set
character-set-server     = utf8mb4
collation-server         = utf8mb4_unicode_ci

# Logging
slow_query_log           = 1
slow_query_log_file      = /var/db/mysql/slow.log
long_query_time          = 2
binlog_expire_logs_seconds = 259200

# Safety
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 the deprecated innodb_log_file_size in MySQL 8.4.

1.3 pf firewall

/etc/pf.conf:

# decebal — database server pf ruleset
ext_if = "vtnet0"
ts_if  = "tailscale0"

# Management: local subnet, Tailscale, allowed external IPs
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 Tailscale IP — only source allowed to reach MySQL
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

# SSH — management IPs on either interface
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

# MySQL — zamolxis Tailscale IP only, via Tailscale interface
pass in on $ts_if proto tcp from $zamolxis_ts to any port 3306 keep state
sysrc pf_enable=YES
service pf start

1.4 Tailscale

pkg install tailscale
sysrc tailscaled_enable=YES
service tailscaled start
tailscale up --hostname=decebal

1.5 DB restore

# Dump from old transilvan (port 79), import to new decebal
# Run from a machine with access to both nodes
ssh -p 79 root@transilvan \
  "mysqldump -S /tmp/mysqld.sock --single-transaction \
   --routines --triggers --events sql_sibiuindepen" \
  | ssh root@100.67.166.29 \
    "mysql --socket=/var/db/mysql/mysql.sock sql_sibiuindepen"

# App user already created — zamolxis Tailscale IP 100.115.128.41
# Credentials: sql_sibiuindepen / see keepass

1.6 DB backup cron

/root/db-backup.sh:

#!/bin/sh
# Daily MySQL backup for decebal
# Retention: 7 days local. Credentials via /root/.my.cnf
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"

Crontab (root, on decebal):

# Daily MySQL dump at 02:00
0 2 * * *   root  /root/db-backup.sh

# Weekly ZFS snapshot (Sunday 00:30)
30 0 * * 0  root  zfs snapshot data/mysql@weekly-$(date +\%Y\%m\%d)

# Prune ZFS snapshots — keep last 4 weeks (Sunday 01:00)
0 1 * * 0   root  zfs list -H -t snapshot -o name data/mysql | sort | head -n -4 | xargs -r zfs destroy

Phase 2 — Web Node (zamolxis)

2.1 Base packages

Redis 8.x (currently 8.6.3 in ports) is the current major version.

pkg install -y nginx redis git curl wget
sysrc nginx_enable=YES redis_enable=YES
service redis start

2.2 PHP 8.5 from binary packages

On FreeBSD 15.0, PHP 8.5 and all extensions are available as binary packages — no ports compilation required.

# Base PHP 8.5 + FPM (opcache is bundled in the base php85 package)
pkg install -y php85

# Extensions needed for WordPress
# Note: imagick and redis are PECL extensions — package prefix is php85-pecl-*
pkg install -y \
  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-soap \
  php85-sockets \
  php85-sodium \
  php85-xml \
  php85-xmlreader \
  php85-xmlwriter \
  php85-xsl \
  php85-zip

pkg install -y ImageMagick7

sysrc php_fpm_enable=YES

2.3 PHP-FPM pool

/usr/local/etc/php-fpm.d/sibiuindependent.conf:

[sibiuindependent]
user  = www
group = www
listen = /var/run/php-fpm-si.sock
listen.owner = www
listen.group = www
pm                   = dynamic
pm.max_children      = 40
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):

opcache.enable                = 1
opcache.memory_consumption    = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps   = 0
opcache.jit                   = 0
session.save_handler          = redis
session.save_path             = "unix:///var/run/redis/redis.sock"

2.4 nginx with fastcgi_cache

/usr/local/etc/nginx/nginx.conf:

worker_processes auto;
events { worker_connections 4096; }

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; }  # nginx if does not support !=
        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|svg|ico|woff2)$ {
            expires    1y;
            add_header Cache-Control "public, immutable";
            access_log off;
        }
    }
}
mkdir -p /var/cache/nginx/si
chown www:www /var/cache/nginx/si
service nginx start

2.5 pf

/etc/pf.conf:

# zamolxis — web server pf ruleset
ext_if = "vtnet0"
ts_if  = "tailscale0"

# Management: local subnet, Tailscale, allowed external IPs
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 }"

set skip on lo0
set block-policy drop
scrub in all

# Default deny
block in all
block out quick inet6 all

# Allow all outbound (package updates, S3, SMTP relay, etc.)
pass out all keep state

# SSH — from management IPs on either interface
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

# HTTP — from NPM via Tailscale only
# Tighten to specific NPM Tailscale IP once known
pass in on $ts_if proto tcp to any port 80 keep state

Note: tighten the HTTP rule to NPM's specific Tailscale IP once confirmed.

sysrc pf_enable=YES
service pf start

2.6 Tailscale

pkg install tailscale
sysrc tailscaled_enable=YES
service tailscaled start
tailscale up --hostname=zamolxis

2.7 rsync web files

# Exclude cache — does not migrate, regenerates on first request
rsync -avz --progress \
  -e "ssh -i /root/.ssh/id_ed25519 -p 79" \
  --exclude="wp-content/cache/" \
  --exclude="wp-content/w3tc-config/" \
  root@dracula:/www/wwwroot/sibiuindependent.ro/ \
  /var/www/sibiuindependent.ro/

chown -R www:www /var/www/sibiuindependent.ro

2.8 wp-config.php changes

Two changes on the new node only:

// 1. Update DB_HOST to decebal Tailscale IP
define( 'DB_HOST', '100.67.166.29' );

// 2. WP_CACHE stays true for Redis Object Cache drop-in
//    Remove the W3TC comment, keep the constant:
define( 'WP_CACHE', true );

Remove W3TC plugin and its drop-ins:

rm -rf /var/www/sibiuindependent.ro/wp-content/plugins/w3-total-cache
rm -f  /var/www/sibiuindependent.ro/wp-content/advanced-cache.php
rm -f  /var/www/sibiuindependent.ro/wp-content/object-cache.php

Install Redis Object Cache plugin (Till Krüss), activate, configure:

// In wp-config.php, before "That's all, stop editing":
define( 'WP_REDIS_PATH', '/var/run/redis/redis.sock' );
define( 'WP_REDIS_SCHEME', 'unix' );

2.9 Crons

*/5  * * * *  www   curl -s -o /dev/null https://sibiuindependent.ro/wp-cron.php?doing_wp_cron
0    3 * * *  root  /root/cache-warmer.sh >> /root/cache-warmer.log 2>&1
0   15 * * *  root  /root/cache-warmer.sh >> /root/cache-warmer.log 2>&1
0    3 * * 1  root  /root/js-refresh.sh >> /root/js-refresh.log 2>&1

Cache Warmer (rewritten for nginx fastcgi_cache)

/root/cache-warmer.sh:

#!/bin/sh
# Incremental cache warmer for nginx fastcgi_cache.
# Checks X-Cache-Status header: skips HITs, warms MISSes.
# Requires curl >= 7.84 (FreeBSD 14 ships curl 8.x — compatible).

SITE='https://sibiuindependent.ro'
CONCURRENCY=4
LOG='/root/cache-warmer.log'
URL_FILE='/tmp/cache-warmer-urls.txt'
DONE_FILE='/tmp/cache-warmer-done.txt'

echo "[$(date)] Starting cache warmer (incremental, nginx fastcgi_cache)" | tee -a "$LOG"

: > "$URL_FILE"
SITEMAPS=$(curl -sk "${SITE}/sitemap_index.xml" | \
  grep -oE 'https?://[^<]+\.xml' | grep -v image)
echo "[$(date)] Sub-sitemaps: $(echo "$SITEMAPS" | wc -l)" | tee -a "$LOG"

for SMAP in $SITEMAPS; do
    curl -sk "$SMAP" | grep -oE 'https?://[^<]+' | grep -v '\.xml' >> "$URL_FILE"
    sleep 0.3
done

TOTAL=$(wc -l < "$URL_FILE")
echo "[$(date)] Total URLs from sitemap: $TOTAL" | tee -a "$LOG"

: > "$DONE_FILE"
WARMED=0
SKIPPED=0
COUNT=0

while IFS= read -r URL; do
    (
        RESULT=$(curl -sk -o /dev/null \
          -w '%{http_code} %header{x-cache-status}' \
          -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' \
          -H 'Accept-Encoding: gzip' \
          --max-time 20 "$URL")
        echo "$RESULT $URL" >> "$DONE_FILE"
    ) &
    COUNT=$((COUNT + 1))
    if [ $((COUNT % CONCURRENCY)) -eq 0 ]; then
        wait
    fi
    sleep 0.05
done < "$URL_FILE"
wait

WARMED=$(grep -vc 'HIT' "$DONE_FILE" 2>/dev/null || echo 0)
SKIPPED=$(grep -c 'HIT' "$DONE_FILE" 2>/dev/null || echo 0)
echo "[$(date)] Done. Warmed: $WARMED, Skipped (HIT): $SKIPPED" | tee -a "$LOG"

Key difference from original

Original (W3TC) New (nginx fastcgi_cache)
Cache check method Stat filesystem for _index_slash_ssl.html HTTP X-Cache-Status: HIT header
Cache path /www/wwwroot/.../cache/page_enhanced/... /var/cache/nginx/si/ (hashed, not human-readable)
Logic Skip if file exists Skip if header is HIT
Concurrency Background subshells Background subshells (unchanged)
Sitemap parsing Unchanged Unchanged

JS Asset Refresh Cron

/root/js-refresh.sh:

#!/bin/sh
# Weekly refresh of self-hosted third-party JS assets
WEBROOT=/var/www/sibiuindependent.ro

curl -s https://cdn.onesignal.com/sdks/OneSignalSDK.js \
  | sed 's|//# sourceMappingURL=.*||' \
  > ${WEBROOT}/onesignal.js

curl -s https://cdn.onesignal.com/sdks/OneSignalPageSDKES6.js \
  | sed 's|//# sourceMappingURL=.*||' \
  > ${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

Phase 3 — Zero-downtime Cutover

Pre-cutover checklist (run on zamolxis before touching NPM)

# 1. Site responds via new stack directly
curl -sk -o /dev/null -w "%{http_code}" \
  http://100.115.128.41/ \
  -H "Host: sibiuindependent.ro"
# Expected: 200

# 2. DB connection live
mysql -h 100.67.166.29 \
  -u sql_sibiuindepen -p \
  --get-server-public-key \
  -e "SELECT COUNT(*) FROM wp_posts WHERE post_status='publish';"

# 3. Redis object cache connected
wp --path=/var/www/sibiuindependent.ro --allow-root redis-cache status

# 4. PHP extensions present
php85 -m | grep -E "imagick|redis|pdo_mysql|opcache|intl|soap|sodium"

# 5. nginx fastcgi_cache working — second request should be HIT
curl -sI http://100.115.128.41/ -H "Host: sibiuindependent.ro" \
  | grep X-Cache-Status
# First request: MISS, second request: HIT

Cutover sequence

T-0   Disable cache warmer crons on OLD dracula (comment out both lines)

T-1   Final rsync of uploads (incremental delta only):
        rsync -avz --progress \
          -e "ssh -i /root/.ssh/id_ed25519 -p 79" \
          --exclude="wp-content/cache/" \
          root@dracula:/www/wwwroot/sibiuindependent.ro/wp-content/uploads/ \
          /var/www/sibiuindependent.ro/wp-content/uploads/

T-2   Final DB dump and import:
        ssh root@transilvan \
          "mysqldump -S /tmp/mysqld.sock --single-transaction \
           --routines --triggers --events sql_sibiuindepen" \
          | ssh root@decebal "mysql -u root sql_sibiuindepen"

T-3   Switch NPM upstream:
        old: 100.99.157.56 (dracula Tailscale IP)
        new: 100.115.128.41 (zamolxis Tailscale IP)
        (single upstream change in NPM — no DNS TTL involved, instant)

T-4   Smoke test:
        - Homepage loads
        - An article page loads
        - wp-admin accessible
        - Second page load shows X-Cache-Status: HIT

T-5   Enable cache warmer on new dracula

Rollback: Revert NPM upstream to 100.99.157.56. Old stack is untouched throughout. No DNS change required. Instant.

Old VMs: Keep powered on (no traffic) for 48 hours. Decommission after confirmed stable.

Post-cutover

# Run cache warmer immediately on new node
bash /root/cache-warmer.sh

# Monitor
tail -f /var/log/nginx/error.log
tail -f /var/log/php-fpm.log

# ZFS snapshot of DB post-migration
zfs snapshot data/mysql@post-migration-$(date +%Y%m%d)

Open Items

  • Tailscale IPs confirmed — zamolxis: 100.115.128.41 / decebal: 100.67.166.29
  • NPM node Tailscale IP — tighten zamolxis pf HTTP rule to specific NPM IP once confirmed
  • PHP 8.5 — binary package on FreeBSD 15.0, all extensions installed. PECL packages use php85-pecl-* prefix (imagick, redis). opcache bundled in base php85.
  • Redis Object Cache plugin — confirm if already in wp-content/plugins from old dracula or install fresh
  • W3TC minify in use? — if yes, decide before cutover: drop minify or use separate plugin
  • ZFS pool — data pool on /dev/da1 (100GB), ONLINE. data/mysql dataset ONLINE at /var/db/mysql
  • MySQL 8.4.9 LTS — deployed and running on decebal
  • Beszel and Telegraf — dropped. Proxmox native monitoring sufficient.
  • Redis placement — local to zamolxis (unix socket). No 3rd host needed.
  • Phase 2 — rsync WP files from dracula (port 79) to zamolxis /var/www/sibiuindependent.ro
  • Phase 2 — update wp-config.php with DB_HOST=100.67.166.29 and Redis constants
  • Phase 2 — remove W3TC, install Redis Object Cache plugin
  • Phase 3 — pre-cutover checklist verification
  • Phase 3 — NPM upstream switch 100.99.157.56 → 100.115.128.41

Deployment Status (as of 2026-05-29)

Node Status Notes
zamolxis Deployed nginx 1.30.2, PHP 8.5.4, Redis 8.6.2, pf active, crons installed
decebal Deployed MySQL 8.4.9, ZFS pool online, pf active, app user created, backup cron installed

Connectivity verified

  • zamolxis → decebal MySQL (100.67.166.29:3306) as sql_sibiuindepen: OK
  • pf on decebal allows only 100.115.128.41 to reach port 3306: OK
  • Backup test run: /var/db/backups/mysql/sql_sibiuindepen_*.sql.gz created OK
Description
No description provided
Readme 143 KiB
Languages
Markdown 100%