My K.I.S.S – Keep It Simple (by) Scripting

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_fcgi enabled
  • php8.3-fpm enabled
  • rewrite enabled (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 /24 if CIDR prefix is missing
    • Writes valid netplan YAML (correct indentation)
  • 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 -i
    • su
    • su -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

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
  • rewrite enabled 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

Previous