After building many WordPress and Maria database hosts, I realised it was time to start automating some of the process, for my own sanity and time efficiency as well as standardisation.
WordPress on Ubuntu 24.04 — Build Notes & Reference
This post documents a clean, repeatable setup for running WordPress on Ubuntu 24.04 using:
- Apache 2.4
- PHP-FPM 8.3
- MariaDB on a separate host
- Cloudflare Tunnel (no public ports exposed)
The goal is a stable, debuggable, infrastructure-friendly WordPress stack suitable for technical blogging and project notes.
Architecture Overview
Internet
↓
Cloudflare Tunnel
↓
Apache (Ubuntu 24.04)
↓
PHP-FPM 8.3
↓
WordPress
↓
Remote MariaDB server
Key points:
- No inbound ports exposed on the web server
- Database access restricted to a single IP
- PHP runs via FPM (not mod_php)
- Configuration is explicit and observable
Web Server Stack
Installed components
- Apache 2.4
- PHP 8.3 (FPM)
- Required PHP extensions for WordPress:
- mysqli
- curl
- gd / imagick
- mbstring
- xml
- zip
- intl
- opcache
Apache configuration
proxy_fcgienabledphp8.3-fpmenabledrewriteenabled (pretty permalinks)- Dedicated virtual host per site
WordPress Configuration
File location
/var/www/<site-name>
Permissions
- Owner:
www-data:www-data - Directories:
755 - Files:
644 wp-config.php:640
Database configuration (remote)
define('DB_NAME', 'wordpress_prod');
define('DB_USER', 'wpuser');
define('DB_PASSWORD', 'strong_password');
define('DB_HOST', '192.168.100.10');
- Database user is restricted to the web server IP
- UTF-8 charset (
utf8mb4) used for full Unicode support
Reverse Proxy / Cloudflare Awareness
To prevent HTTPS loops and incorrect URL detection when running behind Cloudflare Tunnel:
// Reverse-proxy / Cloudflare HTTPS awareness
if (
(!empty($_SERVER['HTTP_CF_VISITOR']) && strpos($_SERVER['HTTP_CF_VISITOR'], '"scheme":"https"') !== false)
|| (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
) {
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = 443;
}
WordPress Runtime Settings
define('FS_METHOD', 'direct');
define('WP_MEMORY_LIMIT', '256M');
define('WP_MAX_MEMORY_LIMIT', '256M');
These avoid plugin update permission issues and memory exhaustion during installs.
PHP Upload Limits
Configured in:
/etc/php/8.3/fpm/php.ini
Recommended values:
upload_max_filesize = 128M
post_max_size = 128M
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
Restart required:
systemctl restart php8.3-fpm
Database Host Notes
MariaDB listening
bind-address = 0.0.0.0- TCP port
3306
User restriction example
CREATE USER 'wpuser'@'192.168.100.24' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON wordpress_prod.* TO 'wpuser'@'192.168.100.24';
FLUSH PRIVILEGES;
Troubleshooting Reference
White page / HTTP 500
- Check Apache error log:
/var/log/apache2/wordpress_error.log - Validate config:
php -l wp-config.php
Database connection errors
- Test from web host:
mariadb -h DB_IP -u wpuser -p wordpress_prod - Check MariaDB bind address and grants
Why this setup works well for technical blogs
- Clean separation of concerns
- Easy to debug (logs, services, sockets)
- No “magic” hosting behaviour
- Scales from hobby blog to production
- Friendly to automation and IaC later
Future Improvements
- Redis object cache
- Read-only DB replica for reporting
- Cloudflare Access for
/wp-admin - Automated backups
- CI deployment of themes/plugins
Ubuntu 24.04 WordPress Bootstrap Script — Design & Usage Notes
This document describes a single-file bootstrap script used to provision Ubuntu 24.04 virtual machines for either:
- Web hosting (Apache + PHP-FPM + WordPress + Cloudflare Tunnel)
- Database hosting (MariaDB)
The script is designed to be safe, observable, and repeatable, with no background magic.
High-Level Design
The script runs in two explicit phases:
Phase 1 — System Identity & Network
Runs immediately when executed.
- Prompts for:
- Hostname
- Static IP configuration (netplan)
- DNS servers
- Role selection (web or db)
- Automatically fixes common input errors:
- Adds
/24if CIDR prefix is missing - Writes valid netplan YAML (correct indentation)
- Adds
- Optionally collects WordPress details if web role is selected
- Writes all state to disk
- Installs a root login hook
- Reboots cleanly
No services are installed yet.
Phase 2 — Interactive Provisioning
Runs only when logging in as root.
- Triggered on:
sudo -isusu -l
- Displays a clear on-screen prompt
- Runs in the foreground, showing progress live
- Cleans itself up after completion (runs once only)
This avoids silent failures and makes debugging straightforward.
Supported Roles
Web Server Role
Installs and configures:
- Apache 2.4
- PHP 8.3 (PHP-FPM)
- Required WordPress PHP extensions
- Cloudflare Tunnel (
cloudflared) via official repo
If WordPress hosting is selected:
- Downloads latest WordPress
- Creates Apache virtual host
- Generates a safe
wp-config.php - Configures reverse-proxy / Cloudflare HTTPS handling
- Validates PHP syntax before continuing
Database Server Role
Installs:
- MariaDB Server
- Enables service at boot
The script intentionally does not:
- Open firewall ports
- Create databases or users automatically
This prevents accidental exposure and keeps DB security explicit.
WordPress-Specific Behaviour
WordPress Files
- Installed into a configurable document root
- Ownership:
www-data:www-data - Permissions:
- Directories:
755 - Files:
644 wp-config.php:640
- Directories:
Database Configuration
wp-config.php is generated safely from the official sample file.
define('DB_NAME', 'wordpress_prod');
define('DB_USER', 'wpuser');
define('DB_PASSWORD', 'strong_password');
define('DB_HOST', '192.168.100.10');
Values are escaped correctly to avoid syntax errors.
Reverse Proxy / Cloudflare Awareness
To prevent HTTPS loops and incorrect URL detection:
if (
(!empty($_SERVER['HTTP_CF_VISITOR']) && strpos($_SERVER['HTTP_CF_VISITOR'], '"scheme":"https"') !== false)
|| (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
) {
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = 443;
}
This works with Cloudflare Tunnel and other reverse proxies.
WordPress Runtime Defaults
define('FS_METHOD', 'direct');
define('WP_MEMORY_LIMIT', '256M');
define('WP_MAX_MEMORY_LIMIT', '256M');
These avoid plugin update issues and memory exhaustion.
PHP & Apache Configuration
PHP
- PHP-FPM 8.3
- Extensions installed for full WordPress compatibility
- Syntax validation performed on generated configs
Apache
- Uses PHP-FPM via
proxy_fcgi rewriteenabled for permalinks- Dedicated virtual host per site
- Default site disabled
Cloudflare Tunnel
- Installed from Cloudflare’s official apt repository
- Optional automatic service installation if a real tunnel token is provided
- Token validation failures do not stop provisioning
No inbound ports are required on the web server.
Safety & Reliability Features
- No background provisioning
- No silent failures
- No brittle regex replacements
- PHP config validated before use
- Network config validated before reboot
- Phase 2 runs once and cleans itself up
Common Troubleshooting Notes
White page / HTTP 500
- Indicates PHP syntax or runtime error
- Check:
- Apache error log
- PHP-FPM status
php -l wp-config.php
Database connection errors
- Test DB login manually from web host
- Confirm MariaDB
bind-address - Confirm user host matches web server IP
Why This Script Exists
This script was built to avoid:
- “Magic” hosting behaviour
- Silent failures during boot
- Debugging through guesswork
- Irreversible automation mistakes
It is intentionally verbose, explicit, and observable — suitable for:
- Technical blogs
- Homelabs
- Small production environments
- Infrastructure learning
Intended Future Extensions
- Redis object cache
- UFW role-based profiles
- Cloudflare Access for
/wp-admin - Backup automation
- Conversion to cloud-init YAML
#!/usr/bin/env bash
set -euo pipefail
# Ubuntu 24.04 "single file" bootstrap script (ALL FIXES APPLIED)
#
# Phase 1 (interactive, runs now):
# - Set hostname
# - Set static IP using netplan (CIDR auto-fix + correct YAML indentation)
# - Choose role: web or db
# - If web: optionally set up WordPress (download + vhost + wp-config for remote DB)
# - Save all choices into /var/lib/bootstrap/state.env
# - Install Phase 2 script + login hooks
# - Apply netplan and reboot
#
# Phase 2 (runs interactively when you become root):
# - Shows prompt on-screen (works with: sudo -i, su -l, AND su)
# - Installs packages for chosen role
# - Installs cloudflared via Cloudflare apt repo (fix for "Unable to locate package cloudflared")
# - Optionally installs tunnel service from token
# - Optionally installs WordPress files + Apache vhost + generates wp-config.php safely
# - Cleans itself up (runs once)
STATE_DIR="/var/lib/bootstrap"
STATE_FILE="${STATE_DIR}/state.env"
PHASE2_SCRIPT="/usr/local/sbin/bootstrap-phase2.sh"
LOGIN_HOOK="/root/.bootstrap_phase2_login"
need_root() {
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: Run as root (use sudo)."
exit 1
fi
}
log() { echo -e "\n==> $*"; }
prompt() {
local var_name="$1"
local text="$2"
local default="${3:-}"
local value=""
if [[ -n "$default" ]]; then
read -r -p "$text [$default]: " value || true
value="${value:-$default}"
else
read -r -p "$text: " value || true
fi
printf -v "${var_name}" "%s" "${value}"
}
prompt_secret() {
local var_name="$1"
local text="$2"
local value=""
read -r -s -p "$text: " value || true
echo ""
printf -v "${var_name}" "%s" "${value}"
}
detect_primary_iface() {
local iface
iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')"
if [[ -n "${iface}" ]]; then
echo "${iface}"
return
fi
iface="$(ls /sys/class/net | grep -v '^lo$' | head -n1 || true)"
echo "${iface}"
}
write_netplan_static() {
local iface="$1"
local ip_cidr="$2"
local gw="$3"
local dns="$4"
local search="$5"
local np_file="/etc/netplan/01-bootstrap-static.yaml"
local dns_yaml=""
local search_yaml=""
# Build YAML list items with CORRECT indentation under nameservers.addresses:
IFS=',' read -ra dns_arr <<< "${dns}"
for d in "${dns_arr[@]}"; do
d="$(echo "$d" | xargs)"
[[ -n "$d" ]] && dns_yaml+=" - ${d}"$'\n'
done
IFS=',' read -ra search_arr <<< "${search}"
for s in "${search_arr[@]}"; do
s="$(echo "$s" | xargs)"
[[ -n "$s" ]] && search_yaml+=" - ${s}"$'\n'
done
log "Writing netplan static config to ${np_file} (iface: ${iface})"
cat > "${np_file}" <<EOF
network:
version: 2
renderer: networkd
ethernets:
${iface}:
dhcp4: false
addresses:
- ${ip_cidr}
routes:
- to: default
via: ${gw}
nameservers:
addresses:
$(printf "%s" "${dns_yaml:- - 1.1.1.1\n - 8.8.8.8\n}")
EOF
if [[ -n "${search_yaml}" ]]; then
cat >> "${np_file}" <<EOF
search:
$(printf "%s" "${search_yaml}")
EOF
fi
chmod 600 "${np_file}"
}
install_phase2_login_hook() {
log "Installing phase2 script and root-login hook"
mkdir -p "${STATE_DIR}"
chmod 700 "${STATE_DIR}"
cat > "${PHASE2_SCRIPT}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
STATE_FILE="/var/lib/bootstrap/state.env"
LOGIN_HOOK="/root/.bootstrap_phase2_login"
log() { echo -e "\n==> $*"; }
need_root() {
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: Phase2 must be run as root."
exit 1
fi
}
load_state() {
if [[ ! -f "${STATE_FILE}" ]]; then
echo "ERROR: Missing state file at ${STATE_FILE}"
exit 1
fi
# shellcheck disable=SC1090
source "${STATE_FILE}"
}
apt_install_common() {
log "Updating apt + installing base tools"
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y --no-install-recommends \
ca-certificates curl gnupg lsb-release apt-transport-https \
rsync unzip netcat-openbsd
}
install_cloudflared() {
log "Installing cloudflared (Cloudflare apt repo)"
export DEBIAN_FRONTEND=noninteractive
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/cloudflare-main.gpg ]]; then
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| gpg --dearmor -o /etc/apt/keyrings/cloudflare-main.gpg
chmod 0644 /etc/apt/keyrings/cloudflare-main.gpg
fi
cat > /etc/apt/sources.list.d/cloudflared.list <<EOF_REPO
deb [signed-by=/etc/apt/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared noble main
EOF_REPO
apt-get update -y
apt-get install -y cloudflared
}
install_cloudflared_token_service() {
if [[ -z "${CF_TUNNEL_TOKEN:-}" ]]; then
log "No Cloudflare tunnel token provided; cloudflared installed but tunnel service not configured."
log "Later: sudo cloudflared service install <TOKEN>"
return
fi
log "Installing Cloudflare Tunnel service using token"
cloudflared service install "${CF_TUNNEL_TOKEN}" || {
log "WARNING: cloudflared service install failed."
log "This often means a Tunnel ID (UUID) was pasted instead of a Tunnel token (long string)."
log "Fix later with: sudo cloudflared service install <REAL_TOKEN>"
return
}
systemctl enable cloudflared || true
systemctl restart cloudflared || true
}
install_web_stack() {
log "Installing Apache + PHP (WordPress-ready) using PHP-FPM"
export DEBIAN_FRONTEND=noninteractive
apt-get install -y apache2
apt-get install -y \
php php-fpm php-cli php-common \
php-mysql php-curl php-gd php-imagick \
php-intl php-mbstring php-soap \
php-xml php-zip php-opcache
a2enmod proxy_fcgi setenvif rewrite headers remoteip >/dev/null
a2enconf php8.3-fpm >/dev/null
systemctl enable apache2 php8.3-fpm
systemctl restart php8.3-fpm
systemctl restart apache2
log "Web stack installed."
log "Sanity check (local): curl -I http://127.0.0.1/"
}
install_db_stack() {
log "Installing MariaDB server"
export DEBIAN_FRONTEND=noninteractive
apt-get install -y mariadb-server
systemctl enable mariadb
systemctl restart mariadb
log "MariaDB installed and running."
log "Recommended next: sudo mariadb-secure-installation"
}
escape_squotes() {
# Escape single quotes for safe insertion into PHP single-quoted strings
# e.g. abc'def -> abc'\''def (bash) -> becomes abc\'def in PHP
local s="$1"
s="${s//\'/\\\'}"
printf "%s" "$s"
}
wp_configure_apache_vhost() {
log "Configuring Apache vhost for WordPress (${WP_FQDN})"
local conf="/etc/apache2/sites-available/wordpress.conf"
cat > "${conf}" <<EOF_VHOST
<VirtualHost *:80>
ServerName ${WP_FQDN}
DocumentRoot ${WP_DOCROOT}
<Directory ${WP_DOCROOT}>
AllowOverride All
Require all granted
</Directory>
ErrorLog \${APACHE_LOG_DIR}/wordpress_error.log
CustomLog \${APACHE_LOG_DIR}/wordpress_access.log combined
</VirtualHost>
EOF_VHOST
a2dissite 000-default.conf >/dev/null 2>&1 || true
a2ensite wordpress.conf >/dev/null
a2enmod rewrite >/dev/null
systemctl reload apache2
log "WordPress vhost enabled."
}
wp_write_config_safely() {
# Generates a clean wp-config.php without brittle regex injection.
# Works even if sample changes slightly.
local cfg="${WP_DOCROOT}/wp-config.php"
local sample="${WP_DOCROOT}/wp-config-sample.php"
if [[ ! -f "${sample}" ]]; then
echo "ERROR: Missing ${sample}"
exit 1
fi
local db_name db_user db_pass db_host
db_name="$(escape_squotes "${WP_DB_NAME}")"
db_user="$(escape_squotes "${WP_DB_USER}")"
db_pass="$(escape_squotes "${WP_DB_PASS}")"
db_host="$(escape_squotes "${WP_DB_HOST}")"
log "Writing wp-config.php (safe method)"
# Start from sample
cp -f "${sample}" "${cfg}"
# Replace DB lines (simple and safe as long as values are single-quote escaped)
sed -i \
-e "s/define( 'DB_NAME', *'.*' );/define( 'DB_NAME', '${db_name}' );/" \
-e "s/define( 'DB_USER', *'.*' );/define( 'DB_USER', '${db_user}' );/" \
-e "s/define( 'DB_PASSWORD', *'.*' );/define( 'DB_PASSWORD', '${db_pass}' );/" \
-e "s/define( 'DB_HOST', *'.*' );/define( 'DB_HOST', '${db_host}' );/" \
"${cfg}"
# Add our proxy/Cloudflare awareness + sane defaults near the end, before the "stop editing" comment if present.
# If not present, append.
local inject
inject=$'\n'"// Reverse-proxy / Cloudflare HTTPS awareness"$'\n'\
$'if ('\
$'(!empty($_SERVER[\'HTTP_CF_VISITOR\']) && strpos($_SERVER[\'HTTP_CF_VISITOR\'], \'\"scheme\":\"https\"\') !== false) ||'\
$'(!empty($_SERVER[\'HTTP_X_FORWARDED_PROTO\']) && $_SERVER[\'HTTP_X_FORWARDED_PROTO\'] === \'https\')'\
$') {'$'\n'\
$' $_SERVER[\'HTTPS\'] = \'on\';'$'\n'\
$' $_SERVER[\'SERVER_PORT\'] = 443;'$'\n'\
$'}'$'\n'\
$'\n'"// Useful defaults"$'\n'\
$'define(\'FS_METHOD\', \'direct\');'$'\n'\
$'define(\'WP_MEMORY_LIMIT\', \'256M\');'$'\n'\
$'define(\'WP_MAX_MEMORY_LIMIT\', \'256M\');'$'\n'
if grep -q "stop editing" "${cfg}"; then
# Insert before the stop editing line
awk -v ins="${inject}" '
BEGIN{added=0}
/stop editing/ && added==0 {print ins; added=1}
{print}
' "${cfg}" > "${cfg}.tmp"
mv "${cfg}.tmp" "${cfg}"
else
printf "%s\n" "${inject}" >> "${cfg}"
fi
chown www-data:www-data "${cfg}"
chmod 640 "${cfg}"
# Validate syntax to avoid white page
php -l "${cfg}" >/dev/null
}
wp_download_and_setup() {
log "Setting up WordPress at ${WP_DOCROOT} for ${WP_FQDN}"
mkdir -p "${WP_DOCROOT}"
chown -R www-data:www-data "${WP_DOCROOT}"
chmod -R 755 "${WP_DOCROOT}"
log "Downloading WordPress"
tmpdir="$(mktemp -d)"
curl -fsSL -o "${tmpdir}/latest.tar.gz" https://wordpress.org/latest.tar.gz
tar -xzf "${tmpdir}/latest.tar.gz" -C "${tmpdir}"
log "Installing WordPress files"
rsync -a --delete "${tmpdir}/wordpress/" "${WP_DOCROOT}/"
rm -rf "${tmpdir}"
chown -R www-data:www-data "${WP_DOCROOT}"
wp_write_config_safely
wp_configure_apache_vhost
log "Local test: curl -I http://127.0.0.1/ -H 'Host: ${WP_FQDN}'"
}
cleanup() {
log "Cleaning up Phase2 login hook (will not run again)"
rm -f "${LOGIN_HOOK}" || true
# Remove our hook blocks from both profile and bashrc (safe if absent)
for f in /root/.bash_profile /root/.bashrc; do
if [[ -f "$f" ]]; then
sed -i '\|# bootstrap_phase2_login|,/# bootstrap_phase2_login_end/d' "$f" || true
fi
done
}
main() {
need_root
load_state
echo "=================================================="
echo " Bootstrap Phase 2 starting"
echo " Role: ${ROLE}"
echo " WordPress host: ${IS_WP_HOST:-no}"
echo "=================================================="
apt_install_common
case "${ROLE}" in
web) install_web_stack ;;
db) install_db_stack ;;
*) echo "ERROR: Unknown ROLE='${ROLE}'"; exit 1 ;;
esac
install_cloudflared
install_cloudflared_token_service
if [[ "${ROLE}" == "web" && "${IS_WP_HOST:-no}" == "yes" ]]; then
wp_download_and_setup
fi
log "Phase2 complete."
cleanup
log "Done."
}
main "$@"
EOF
chmod 700 "${PHASE2_SCRIPT}"
# Marker file tells root shells to offer phase2
cat > "${LOGIN_HOOK}" <<EOF
PHASE2_READY=1
EOF
chmod 600 "${LOGIN_HOOK}"
# Ensure root shell hooks exist (both login and non-login shells)
for f in /root/.bash_profile /root/.bashrc; do
if [[ ! -f "$f" ]]; then
touch "$f"
chmod 600 "$f"
fi
# Avoid duplicate hook blocks
if ! grep -q "# bootstrap_phase2_login" "$f" 2>/dev/null; then
cat >> "$f" <<'EOF'
# bootstrap_phase2_login
if [[ -f /root/.bootstrap_phase2_login ]]; then
echo ""
echo "=================================================="
echo " Bootstrap Phase 2 is ready to run"
echo "=================================================="
read -r -p "Run now? [Y/n]: " ans
ans="${ans:-Y}"
if [[ "${ans,,}" =~ ^y ]]; then
/usr/local/sbin/bootstrap-phase2.sh
else
echo "Phase 2 postponed. Re-enter root to run."
fi
fi
# bootstrap_phase2_login_end
EOF
fi
done
}
phase1() {
need_root
local iface ip_cidr gw dns search hostname role cf_token
local is_wp_host wp_fqdn wp_docroot wp_db_host wp_db_name wp_db_user wp_db_pass
iface="$(detect_primary_iface)"
if [[ -z "${iface}" ]]; then
echo "ERROR: Could not detect a network interface."
exit 1
fi
log "Phase1: collect settings (will reboot at the end)"
echo "Detected primary interface: ${iface}"
echo ""
prompt hostname "Hostname (e.g. web-01 or db-01)"
prompt ip_cidr "Static IP (CIDR recommended, e.g. 192.168.1.50/24)"
prompt gw "Default gateway IP (e.g. 192.168.1.1)"
prompt dns "DNS servers (comma-separated)" "1.1.1.1,8.8.8.8"
prompt search "DNS search domains (comma-separated, optional)" ""
# Fix: if user enters just an IP, assume /24 (prevents netplan prefix error)
if [[ "${ip_cidr}" != */* ]]; then
ip_cidr="${ip_cidr}/24"
fi
echo ""
echo "Select role:"
echo " 1) web (Apache + PHP + WP modules + cloudflared)"
echo " 2) db (MariaDB server + cloudflared)"
local choice=""
read -r -p "Enter 1 or 2 [1]: " choice || true
choice="${choice:-1}"
if [[ "${choice}" == "2" ]]; then
role="db"
else
role="web"
fi
# If web, ask if WordPress host + collect DB details
is_wp_host="no"
wp_fqdn=""
wp_docroot="/var/www/wordpress"
wp_db_host=""
wp_db_name=""
wp_db_user=""
wp_db_pass=""
if [[ "${role}" == "web" ]]; then
echo ""
read -r -p "Will this VM host WordPress files? (y/N): " ans || true
ans="${ans:-N}"
if [[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]]; then
is_wp_host="yes"
prompt wp_fqdn "WordPress FQDN (e.g. site.example.com)"
prompt wp_docroot "WordPress docroot" "${wp_docroot}"
prompt wp_db_host "DB host (hostname or IP, optionally :port)"
prompt wp_db_name "DB name"
prompt wp_db_user "DB user"
prompt_secret wp_db_pass "DB password (input hidden)"
fi
fi
echo ""
echo "Cloudflare Tunnel token (optional)."
echo "If you paste a REAL token, Phase2 will install the tunnel service automatically."
echo "Leave blank to configure cloudflared later."
read -r -p "Tunnel token (blank to skip): " cf_token || true
cf_token="${cf_token:-}"
log "Applying hostname"
hostnamectl set-hostname "${hostname}"
log "Applying static network config"
write_netplan_static "${iface}" "${ip_cidr}" "${gw}" "${dns}" "${search}"
log "Saving state for phase2"
mkdir -p "${STATE_DIR}"
chmod 700 "${STATE_DIR}"
cat > "${STATE_FILE}" <<EOF
ROLE="${role}"
CF_TUNNEL_TOKEN="${cf_token}"
IS_WP_HOST="${is_wp_host}"
WP_FQDN="${wp_fqdn}"
WP_DOCROOT="${wp_docroot}"
WP_DB_HOST="${wp_db_host}"
WP_DB_NAME="${wp_db_name}"
WP_DB_USER="${wp_db_user}"
WP_DB_PASS="${wp_db_pass}"
EOF
chmod 600 "${STATE_FILE}"
install_phase2_login_hook
log "Netplan apply (may briefly disrupt SSH/network)"
netplan generate
netplan apply
log "Rebooting now. After reboot, become root (sudo -i OR su OR su -l) to run Phase 2 on-screen."
reboot
}
phase1