WordPress Install Scripts

What this is

Two Bash installers that turn a fresh Debian/Ubuntu box into a working WordPress site, backed by MariaDB and PHP-FPM. One targets nginx (LEMP), the other targets Apache (LAMP, using mod_proxy_fcgi rather than mod_php). Both are interactive by default, but every prompt can be pre-answered with an environment variable for unattended runs.

Core features, shared by both scripts:

  • Installs and enables the web server, MariaDB, and PHP-FPM with the extensions WordPress needs
  • Optionally runs the equivalent of mysql_secure_installation (root password, drop anonymous users, drop the test database)
  • Supports multiple WordPress sites on one server — system setup runs once, then it loops per site
  • Safe to re-run: detects an already-secured MariaDB root account instead of failing, and re-syncs each site’s DB password with wp-config.php every time
  • Downloads the latest WordPress core, generates a random table prefix, and fetches unique security keys/salts from the WordPress.org API
  • Hardens wp-config.php (DISALLOW_FILE_EDIT, FORCE_SSL_ADMIN when HTTPS is chosen) and blocks PHP execution inside wp-content/uploads
  • Rate-limits wp-login.php to slow down brute-force attempts
  • Optional self-signed TLS certificate, or optional “trust an upstream reverse proxy/load balancer” mode that adds the X-Forwarded-Proto handling WordPress needs behind Cloudflare, Traefik, an ALB, etc.
  • Saves generated credentials to root-only files (chmod 600) instead of only printing them to the screen

Requirements: a Debian/Ubuntu host with apt-get, run as root.

Install scenarios

Both scripts ask the same questions in the same order, so the walkthrough below applies to either one — just swap in the script name you’re running.

1. Secure MariaDB?

First run only. Yes sets (or auto-generates) a root password and removes anonymous users and the test database. On a re-run, the script detects the existing root password (from its credentials file) and reuses it — you won’t be asked again unless that file is missing.

2. How should the server identify this site?

  • Catch-all (_) — responds to any hostname or IP. Only works for a single site on the server, since there’s no Host header to route additional sites on.
  • Server’s detected IP address — same one-site-only limitation as catch-all.
  • A real domain name — required for every site after the first, and the only option offered from site #2 onward.

Both scripts pin the catch-all/IP site so it always wins as the default vhost, regardless of what other sites get added later and however their config filenames happen to sort — nginx via an explicit default_server flag, Apache via a 000- filename prefix.

3. HTTP or HTTPS?

HTTPS generates a self-signed certificate (no Let’s Encrypt) and redirects port 80 to 443. Fine for internal/lab use or as a placeholder before swapping in a real certificate; browsers will show a trust warning until you replace it.

4. Behind a reverse proxy?

Answer yes if something in front of this box (Cloudflare, Traefik, another nginx, a load balancer) is the one terminating HTTPS, and this server only ever sees plain HTTP from it. The script adds a small snippet to wp-config.php that trusts the X-Forwarded-Proto header, so WordPress still knows the original request was HTTPS — without it you’ll hit a redirect loop or mixed-content warnings.

5. Site identifier, database name/user/password

The site identifier names its web root (/var/www/<slug>) and vhost config. Sensible defaults are offered for the DB name and user; leave the DB password blank to auto-generate a random one.

6. Install another site?

Say yes to loop back into step 2 for a second (or third, etc.) site on the same server. Environment variables only pre-answer site #1 — every additional site gets fresh prompts.

Running it

Interactively:

sudo ./install-wordpress-nginx.sh
# or
sudo ./install-wordpress-apache.sh

Fully non-interactive (single site, e.g. for provisioning automation):

WP_SECURE_MARIADB=yes WP_DB_ROOT_PASSWORD=... \
WP_SERVER_NAME_MODE=catchall WP_PROTOCOL=http \
WP_DB_NAME=wordpress WP_DB_USER=wpuser WP_DB_PASSWORD=... \
./install-wordpress-nginx.sh

All supported variables:

VariableValuesNotes
WP_SECURE_MARIADByes / noRun the mysql_secure_installation-equivalent steps
WP_DB_ROOT_PASSWORDstringMariaDB root password; blank auto-generates one, only used if securing
WP_SERVER_NAME_MODEcatchall / ip / domainPer site
WP_DOMAINstringRequired if WP_SERVER_NAME_MODE=domain
WP_PROTOCOLhttp / httpshttps = self-signed cert, no Let’s Encrypt
WP_REVERSE_PROXYyes / noTrust X-Forwarded-Proto from an upstream TLS-terminating proxy
WP_SITE_SLUGstringShort identifier for the site’s folder/vhost config
WP_DB_NAME / WP_DB_USER / WP_DB_PASSWORDstringBlank password auto-generates one

After it finishes

Each site gets its own credentials file at /root/wordpress-credentials-<slug>.txt (site URL, web root, vhost path, DB name/user/password, table prefix), chmod 600. The MariaDB root password lives separately in /root/mariadb-root-credentials.txt. Open the printed site URL in a browser to finish the normal WordPress setup wizard — site title, admin username, admin password.

Script: nginx (LEMP)

#!/usr/bin/env bash
#
# WordPress installer: nginx + MariaDB + PHP-FPM (LEMP) for Debian/Ubuntu.
#
# Interactive by default - run:  sudo ./install-wordpress.sh
#
# Supports installing MULTIPLE WordPress sites on one server: system-level
# setup (packages, MariaDB root, PHP tuning) runs once, then it loops asking
# for each site's details until you say no to "install another?".
#
# Safe to re-run: it detects an already-secured MariaDB root account and
# reuses it instead of failing, and each site's DB user password is
# re-synced with wp-config.php every time.
#
# Every prompt can be pre-answered via environment variables (useful for a
# single non-interactive run - see the example at the bottom of this header):
#
#   WP_SECURE_MARIADB     yes | no   (run the mysql_secure_installation-equivalent steps)
#   WP_DB_ROOT_PASSWORD   MariaDB root password (blank = auto-generate, only used if securing)
#   WP_SERVER_NAME_MODE   catchall | ip | domain      (per site)
#   WP_DOMAIN             required if WP_SERVER_NAME_MODE=domain
#   WP_PROTOCOL            http | https                (https = self-signed cert, no Let's Encrypt)
#   WP_REVERSE_PROXY       yes | no  (trust X-Forwarded-Proto from an upstream TLS-terminating
#                          proxy/load balancer - adds the same wp-config.php snippet as
#                          wp-optimize.sh's reverse_proxy_https task, so they never conflict)
#   WP_SITE_SLUG           short identifier for the site's folder/nginx config
#   WP_DB_NAME, WP_DB_USER, WP_DB_PASSWORD
#                          (blank password = auto-generate)
#
# Note: catch-all (_) and bare-IP server_names only work for ONE site per
# server, since nginx has no Host header to route on. Additional sites need
# a real domain name.
#
# Example fully non-interactive single-site run:
#   WP_SECURE_MARIADB=yes WP_DB_ROOT_PASSWORD=... \
#   WP_SERVER_NAME_MODE=catchall WP_PROTOCOL=http \
#   WP_DB_NAME=wordpress WP_DB_USER=wpuser WP_DB_PASSWORD=... \
#   ./install-wordpress.sh
#
set -eu
trap 'err "Installation failed at line $LINENO."' ERR

log() { echo -e "\n\033[1;32m==>\033[0m $1"; }
err() { echo -e "\033[1;31mERROR:\033[0m $1" >&2; }

[[ $EUID -eq 0 ]] || { err "Run this script as root (sudo ./install-wordpress.sh)."; exit 1; }
command -v apt-get >/dev/null 2>&1 || { err "This script supports Debian/Ubuntu (apt-get) systems only."; exit 1; }

rand_pass() { tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-20}"; }

# MySQL identifiers (db name / user): letters, digits, underscore only -
# safe to interpolate into SQL and sed without escaping.
valid_ident() { [[ "$1" =~ ^[A-Za-z0-9_]+$ ]]; }

# Passwords: anything printable except characters that are unsafe when
# interpolated into a single-quoted SQL literal or a sed s/// replacement
# (' " \ / & and whitespace).
valid_password() {
  local p="$1"
  [[ -n "$p" ]] || return 1
  [[ "$p" == *[\'\"\\/\&]* ]] && return 1
  [[ "$p" == *[[:space:]]* ]] && return 1
  return 0
}

prompt_menu() {
  # $1 = prompt text, remaining args = options. Echoes the chosen index (1-based).
  local prompt="$1"; shift
  local opts=("$@")
  echo "$prompt" >&2
  local i=1
  for o in "${opts[@]}"; do echo "  $i) $o" >&2; ((i++)); done
  local choice
  while true; do
    read -rp "Choice [1-${#opts[@]}]: " choice
    if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#opts[@]} )); then
      break
    fi
    echo "Invalid choice." >&2
  done
  echo "$choice"
}

prompt_yes_no() {
  # $1 = prompt text, $2 = default (y|n). Returns 0 for yes, 1 for no.
  local prompt="$1" default="${2:-y}" ans suffix="[Y/n]"
  [[ "$default" == "n" ]] && suffix="[y/N]"
  while true; do
    ans=""
    read -rp "$prompt $suffix " ans || ans="$default"
    ans="${ans:-$default}"
    case "$ans" in
      [Yy]*) return 0 ;;
      [Nn]*) return 1 ;;
      *) echo "Please answer y or n." >&2 ;;
    esac
  done
}

################################################################################
### Phase A - one-time system setup: packages, services, MariaDB, PHP tuning
################################################################################

SECURE_MARIADB="${WP_SECURE_MARIADB:-}"
if [[ -z "$SECURE_MARIADB" ]]; then
  if prompt_yes_no "Secure this MariaDB installation now (set root password, remove anonymous users & the test database)?" "y"; then
    SECURE_MARIADB="yes"
  else
    SECURE_MARIADB="no"
  fi
fi
[[ "$SECURE_MARIADB" == "yes" || "$SECURE_MARIADB" == "no" ]] || { err "Invalid WP_SECURE_MARIADB: $SECURE_MARIADB (must be yes or no)"; exit 1; }

MYSQL_ROOT_PASSWORD="${WP_DB_ROOT_PASSWORD:-}"
if [[ "$SECURE_MARIADB" == "yes" && -z "$MYSQL_ROOT_PASSWORD" ]]; then
  while true; do
    read -rsp "MariaDB root password to set (leave blank to auto-generate): " MYSQL_ROOT_PASSWORD; echo
    if [[ -z "$MYSQL_ROOT_PASSWORD" ]]; then
      MYSQL_ROOT_PASSWORD="$(rand_pass 24)"
      log "Generated random MariaDB root password."
      break
    fi
    valid_password "$MYSQL_ROOT_PASSWORD" && break
    echo "Password must not contain quotes, backslashes, /, &, or whitespace." >&2
  done
fi

export DEBIAN_FRONTEND=noninteractive
log "Updating apt and installing nginx, MariaDB, PHP-FPM and extensions..."
apt-get update -y
apt-get install -y \
  nginx mariadb-server \
  php-fpm php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl php-bcmath php-soap \
  unzip wget curl openssl

systemctl enable --now mariadb
systemctl enable --now nginx

PHP_FPM_SERVICE="$(systemctl list-unit-files 'php*-fpm.service' --no-legend | awk '{print $1}' | head -1)"
[[ -z "$PHP_FPM_SERVICE" ]] && { err "Could not find a php-fpm systemd service."; exit 1; }
systemctl enable --now "$PHP_FPM_SERVICE"

PHP_SOCK="$(find /run/php -maxdepth 1 -name '*.sock' 2>/dev/null | head -1)"
[[ -z "$PHP_SOCK" ]] && { err "Could not find the php-fpm socket in /run/php."; exit 1; }

# Raise PHP upload/execution limits to match nginx's client_max_body_size below,
# and hide the PHP version from response headers.
PHP_VERSION="${PHP_FPM_SERVICE#php}"
PHP_VERSION="${PHP_VERSION%-fpm.service}"
PHP_INI="/etc/php/${PHP_VERSION}/fpm/php.ini"
if [[ -f "$PHP_INI" ]]; then
  log "Tuning PHP (upload limits, memory, expose_php off) in ${PHP_INI}..."
  sed -i \
    -e "s/^upload_max_filesize = .*/upload_max_filesize = 64M/" \
    -e "s/^post_max_size = .*/post_max_size = 64M/" \
    -e "s/^memory_limit = .*/memory_limit = 256M/" \
    -e "s/^max_execution_time = .*/max_execution_time = 300/" \
    -e "s/^expose_php = .*/expose_php = Off/" \
    "$PHP_INI"
  systemctl restart "$PHP_FPM_SERVICE"
fi

# MariaDB should only ever listen on localhost for a single-server LEMP box.
MARIADB_CNF="/etc/mysql/mariadb.conf.d/50-server.cnf"
if [[ -f "$MARIADB_CNF" ]] && grep -q '^bind-address' "$MARIADB_CNF" && ! grep -q '^bind-address\s*=\s*127.0.0.1' "$MARIADB_CNF"; then
  log "Restricting MariaDB to listen on 127.0.0.1 only..."
  sed -i 's/^bind-address.*/bind-address = 127.0.0.1/' "$MARIADB_CNF"
  systemctl restart mariadb
fi

log "Configuring MariaDB root access..."

try_mysql_root() { mysql -u root -e 'SELECT 1;' >/dev/null 2>&1; }
try_mysql_root_pw() { mysql -u root -p"$1" -e 'SELECT 1;' >/dev/null 2>&1; }

ROOT_CRED_FILE="/root/mariadb-root-credentials.txt"
LEGACY_CRED_FILE="/root/wordpress-credentials.txt"
EXISTING_ROOT_PW=""
if [[ -f "$ROOT_CRED_FILE" ]]; then
  EXISTING_ROOT_PW="$(sed -n 's/^MariaDB root password:[[:space:]]*//p' "$ROOT_CRED_FILE" | head -1)"
fi
if [[ -z "$EXISTING_ROOT_PW" && -f "$LEGACY_CRED_FILE" ]]; then
  EXISTING_ROOT_PW="$(grep -m1 '^MariaDB root password' "$LEGACY_CRED_FILE" 2>/dev/null | sed -E 's/^[^:]+:[[:space:]]*//')"
fi

ROOT_AUTH_OK=0
ROOT_HAS_PASSWORD=0
if try_mysql_root; then
  MYSQL_AUTH=(mysql -u root)
  ROOT_AUTH_OK=1
elif [[ -n "$EXISTING_ROOT_PW" ]] && try_mysql_root_pw "$EXISTING_ROOT_PW"; then
  MYSQL_AUTH=(mysql -u root -p"${EXISTING_ROOT_PW}")
  MYSQL_ROOT_PASSWORD="$EXISTING_ROOT_PW"
  ROOT_HAS_PASSWORD=1
  ROOT_AUTH_OK=1
elif [[ -n "$MYSQL_ROOT_PASSWORD" ]] && try_mysql_root_pw "$MYSQL_ROOT_PASSWORD"; then
  MYSQL_AUTH=(mysql -u root -p"${MYSQL_ROOT_PASSWORD}")
  ROOT_HAS_PASSWORD=1
  ROOT_AUTH_OK=1
fi

[[ "$ROOT_AUTH_OK" -eq 1 ]] || { err "Cannot authenticate to MariaDB as root. If it was already secured by a previous run, pass its password as WP_DB_ROOT_PASSWORD."; exit 1; }

if [[ "$SECURE_MARIADB" == "yes" && "$ROOT_HAS_PASSWORD" -eq 0 ]]; then
  [[ -z "$MYSQL_ROOT_PASSWORD" ]] && MYSQL_ROOT_PASSWORD="$(rand_pass 24)"
  "${MYSQL_AUTH[@]}" <<SQL
ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('${MYSQL_ROOT_PASSWORD}');
SQL
  MYSQL_AUTH=(mysql -u root -p"${MYSQL_ROOT_PASSWORD}")
  ROOT_HAS_PASSWORD=1
  cat > "$ROOT_CRED_FILE" <<EOF
MariaDB root password: ${MYSQL_ROOT_PASSWORD}
EOF
  chmod 600 "$ROOT_CRED_FILE"
fi

if [[ "$SECURE_MARIADB" == "yes" ]]; then
  "${MYSQL_AUTH[@]}" <<SQL
DELETE FROM mysql.user WHERE User='';
DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost','127.0.0.1','::1');
DROP DATABASE IF EXISTS test;
DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';
FLUSH PRIVILEGES;
SQL
fi

# Shared rate-limit zone for wp-login.php across all sites (must live in the
# http{} context - conf.d/*.conf is included there by Debian's stock nginx.conf).
cat > /etc/nginx/conf.d/wp-rate-limit.conf <<'EOF'
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=5r/m;
EOF

mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
rm -f /etc/nginx/sites-enabled/default

################################################################################
### Phase B - per-site setup (loops for multiple installs)
################################################################################

declare -a USED_SLUGS=()
declare -a SITE_SUMMARIES=()
SITE_NUM=0

while true; do
  SITE_NUM=$((SITE_NUM + 1))

  if [[ "$SITE_NUM" -gt 1 ]]; then
    # Force fresh prompts for every additional site - env vars only pre-answer site #1.
    unset WP_SERVER_NAME_MODE WP_DOMAIN WP_PROTOCOL WP_SITE_SLUG WP_DB_NAME WP_DB_USER WP_DB_PASSWORD || true
  fi

  log "=== Site #${SITE_NUM} configuration ==="

  SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
  DEFAULT_SLUG="wordpress"

  if [[ "$SITE_NUM" -gt 1 ]]; then
    # nginx has no Host header to route on for catch-all (_) or a bare IP, so
    # only the first site on a server may use them - every site after that
    # is hard-required to use a real domain name. Not even offered as a choice.
    SERVER_NAME_MODE="domain"
    if [[ "${WP_SERVER_NAME_MODE:-domain}" != "domain" ]]; then
      err "Site #${SITE_NUM}: only a domain name may be used (WP_SERVER_NAME_MODE=${WP_SERVER_NAME_MODE}) - catch-all/IP only works for the first site on a server."
      exit 1
    fi
    log "Site #${SITE_NUM} must use a real domain name (nginx can't route catch-all/IP to more than one site)."
  else
    SERVER_NAME_MODE="${WP_SERVER_NAME_MODE:-}"
    if [[ -z "$SERVER_NAME_MODE" ]]; then
      c=$(prompt_menu "How should nginx identify this site (server_name)?" \
        "Catch-all (_) - respond to any hostname/IP (only works for a single site)" \
        "Use this server's detected IP address (only works for a single site)" \
        "I'll type a domain name")
      case "$c" in
        1) SERVER_NAME_MODE="catchall" ;;
        2) SERVER_NAME_MODE="ip" ;;
        3) SERVER_NAME_MODE="domain" ;;
      esac
    fi
  fi

  case "$SERVER_NAME_MODE" in
    catchall)
      SERVER_NAME="_"
      ;;
    ip)
      if [[ -z "$SERVER_IP" ]]; then
        err "Could not detect this server's IP address. Re-run and choose the catch-all or domain option instead."
        exit 1
      fi
      SERVER_NAME="${SERVER_IP}"
      ;;
    domain)
      DOMAIN="${WP_DOMAIN:-}"
      while [[ -z "$DOMAIN" ]]; do
        read -rp "Enter the domain name (e.g. example.com): " DOMAIN
      done
      SERVER_NAME="$DOMAIN"
      DEFAULT_SLUG="$(printf '%s' "$DOMAIN" | tr -c 'A-Za-z0-9' '-' | sed 's/^-*//;s/-*$//')"
      [[ -z "$DEFAULT_SLUG" ]] && DEFAULT_SLUG="wordpress"
      ;;
    *) err "Invalid WP_SERVER_NAME_MODE: $SERVER_NAME_MODE"; exit 1 ;;
  esac

  # nginx picks a "default" server per port by file order unless one is
  # explicitly marked default_server - a catch-all/IP site relying on being
  # first-in-glob-order breaks the instant another site's vhost file happens
  # to sort earlier (e.g. "blog-local.conf" before "wordpress.conf"), silently
  # hijacking the catch-all site's traffic. Mark it explicitly so it stays the
  # default regardless of what gets added later.
  DEFAULT_SERVER_FLAG=""
  [[ "$SERVER_NAME_MODE" == "catchall" || "$SERVER_NAME_MODE" == "ip" ]] && DEFAULT_SERVER_FLAG=" default_server"

  PROTOCOL="${WP_PROTOCOL:-}"
  if [[ -z "$PROTOCOL" ]]; then
    c=$(prompt_menu "Which protocol should this site use?" \
      "HTTP only" \
      "HTTPS with a self-signed certificate (no Let's Encrypt)")
    case "$c" in
      1) PROTOCOL="http" ;;
      2) PROTOCOL="https" ;;
    esac
  fi
  [[ "$PROTOCOL" == "http" || "$PROTOCOL" == "https" ]] || { err "Invalid WP_PROTOCOL: $PROTOCOL"; exit 1; }

  REVERSE_PROXY="${WP_REVERSE_PROXY:-}"
  if [[ -z "$REVERSE_PROXY" ]]; then
    if prompt_yes_no "Is this site behind an external reverse proxy or load balancer (Cloudflare, Traefik, another nginx, an ALB, etc.) that terminates HTTPS in front of it?" "n"; then
      REVERSE_PROXY="yes"
    else
      REVERSE_PROXY="no"
    fi
  fi
  [[ "$REVERSE_PROXY" == "yes" || "$REVERSE_PROXY" == "no" ]] || { err "Invalid WP_REVERSE_PROXY: $REVERSE_PROXY (must be yes or no)"; exit 1; }

  while true; do
    SITE_SLUG="${WP_SITE_SLUG:-}"
    if [[ -z "$SITE_SLUG" ]]; then
      read -rp "Short identifier for this site (used for its folder & nginx config) [${DEFAULT_SLUG}]: " SITE_SLUG
      SITE_SLUG="${SITE_SLUG:-$DEFAULT_SLUG}"
    fi
    SITE_SLUG="$(printf '%s' "$SITE_SLUG" | tr -c 'A-Za-z0-9_-' '-')"
    DUPLICATE=0
    for used in "${USED_SLUGS[@]}"; do
      [[ "$used" == "$SITE_SLUG" ]] && DUPLICATE=1
    done
    if [[ "$DUPLICATE" -eq 1 ]]; then
      echo "Identifier '${SITE_SLUG}' was already used earlier in this run - choose a different one." >&2
      WP_SITE_SLUG=""
      continue
    fi
    break
  done
  USED_SLUGS+=("$SITE_SLUG")

  WP_DB_NAME="${WP_DB_NAME:-}"
  if [[ -z "$WP_DB_NAME" ]]; then
    while true; do
      read -rp "WordPress database name [${SITE_SLUG}]: " WP_DB_NAME
      WP_DB_NAME="${WP_DB_NAME:-$SITE_SLUG}"
      WP_DB_NAME="$(printf '%s' "$WP_DB_NAME" | tr -c 'A-Za-z0-9_' '_')"
      valid_ident "$WP_DB_NAME" && break
      echo "Database name may only contain letters, digits, and underscores." >&2
      WP_DB_NAME=""
    done
  else
    valid_ident "$WP_DB_NAME" || { err "WP_DB_NAME may only contain letters, digits, and underscores."; exit 1; }
  fi

  WP_DB_USER="${WP_DB_USER:-}"
  if [[ -z "$WP_DB_USER" ]]; then
    while true; do
      read -rp "WordPress database user [${WP_DB_NAME}_user]: " WP_DB_USER
      WP_DB_USER="${WP_DB_USER:-${WP_DB_NAME}_user}"
      valid_ident "$WP_DB_USER" && break
      echo "Database user may only contain letters, digits, and underscores." >&2
      WP_DB_USER=""
    done
  else
    valid_ident "$WP_DB_USER" || { err "WP_DB_USER may only contain letters, digits, and underscores."; exit 1; }
  fi

  WP_DB_PASSWORD="${WP_DB_PASSWORD:-}"
  if [[ -z "$WP_DB_PASSWORD" ]]; then
    while true; do
      read -rsp "WordPress database password (leave blank to auto-generate): " WP_DB_PASSWORD; echo
      if [[ -z "$WP_DB_PASSWORD" ]]; then
        WP_DB_PASSWORD="$(rand_pass 24)"
        log "Generated random WordPress DB password."
        break
      fi
      valid_password "$WP_DB_PASSWORD" && break
      echo "Password must not contain quotes, backslashes, /, &, or whitespace." >&2
    done
  else
    valid_password "$WP_DB_PASSWORD" || { err "WP_DB_PASSWORD contains an unsupported character (quotes, backslash, /, &, or whitespace)."; exit 1; }
  fi

  WP_ROOT="/var/www/${SITE_SLUG}"
  SITE_CRED_FILE="/root/wordpress-credentials-${SITE_SLUG}.txt"

  log "Site #${SITE_NUM} configuration:
  server_name    : ${SERVER_NAME}
  protocol       : ${PROTOCOL}
  reverse proxy  : ${REVERSE_PROXY}
  web root       : ${WP_ROOT}
  db name        : ${WP_DB_NAME}
  db user        : ${WP_DB_USER}"

  log "Creating database and user for this site..."
  "${MYSQL_AUTH[@]}" <<SQL
CREATE DATABASE IF NOT EXISTS \`${WP_DB_NAME}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${WP_DB_USER}'@'localhost';
ALTER USER '${WP_DB_USER}'@'localhost' IDENTIFIED BY '${WP_DB_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${WP_DB_NAME}\`.* TO '${WP_DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL

  if [[ -d "$WP_ROOT" && -n "$(ls -A "$WP_ROOT" 2>/dev/null)" ]]; then
    BACKUP="${WP_ROOT}.bak.$(date +%s)"
    log "Existing ${WP_ROOT} is not empty - moving it to ${BACKUP}"
    mv "$WP_ROOT" "$BACKUP"
  fi

  log "Downloading latest WordPress..."
  TMP_DIR="$(mktemp -d)"
  curl -fsSL https://wordpress.org/latest.tar.gz -o "$TMP_DIR/wordpress.tar.gz"
  tar -xzf "$TMP_DIR/wordpress.tar.gz" -C "$TMP_DIR"

  mkdir -p "$WP_ROOT"
  cp -a "$TMP_DIR"/wordpress/. "$WP_ROOT"/
  rm -rf "$TMP_DIR"

  cp "$WP_ROOT/wp-config-sample.php" "$WP_ROOT/wp-config.php"
  sed -i "s/database_name_here/${WP_DB_NAME}/" "$WP_ROOT/wp-config.php"
  sed -i "s/username_here/${WP_DB_USER}/" "$WP_ROOT/wp-config.php"
  sed -i "s/password_here/${WP_DB_PASSWORD}/" "$WP_ROOT/wp-config.php"

  RAND_PREFIX="wp_$(rand_pass 6 | tr 'A-Z' 'a-z')_"
  sed -i "s/table_prefix = 'wp_';/table_prefix = '${RAND_PREFIX}';/" "$WP_ROOT/wp-config.php"

  # Hardening: block the theme/plugin file editor, and force HTTPS in wp-admin if applicable.
  sed -i "/require_once ABSPATH \. 'wp-settings.php';/i define('DISALLOW_FILE_EDIT', true);" "$WP_ROOT/wp-config.php"
  if [[ "$PROTOCOL" == "https" ]]; then
    sed -i "/require_once ABSPATH \. 'wp-settings.php';/i define('FORCE_SSL_ADMIN', true);" "$WP_ROOT/wp-config.php"
  fi

  if [[ "$REVERSE_PROXY" == "yes" ]]; then
    log "Adding reverse-proxy HTTPS detection to wp-config.php..."
    # Trusts X-Forwarded-Proto from the upstream proxy so WordPress knows the
    # original request was HTTPS even though this box only sees plain HTTP
    # from the proxy - without this, siteurl/home being https:// causes a
    # redirect loop (or mixed-content warnings) behind a TLS-terminating
    # proxy. Same marker/logic as wp-optimize.sh's reverse_proxy_https task -
    # matching it means the two never fight over or duplicate this block on
    # a site either one has already touched.
    php -r '
      $cfgFile = $argv[1];
      $cfg = file_get_contents($cfgFile);
      if (strpos($cfg, "HTTP_X_FORWARDED_PROTO") === false) {
        $block = "\n// >>> reverse-proxy-https\n"
          . "if ( isset( \$_SERVER[\x27HTTP_X_FORWARDED_PROTO\x27] ) && strtolower( \$_SERVER[\x27HTTP_X_FORWARDED_PROTO\x27] ) === \x27https\x27 ) {\n"
          . "    \$_SERVER[\x27HTTPS\x27] = \x27on\x27;\n"
          . "}\n"
          . "// <<< reverse-proxy-https\n";
        $anchor = "require_once ABSPATH . \x27wp-settings.php\x27;";
        if (strpos($cfg, $anchor) !== false) {
          $cfg = str_replace($anchor, $block . $anchor, $cfg);
          file_put_contents($cfgFile, $cfg);
        }
      }
    ' "$WP_ROOT/wp-config.php"
  fi

  log "Fetching unique WordPress security keys..."
  SALTS_FILE="$(mktemp)"
  curl -fsSL https://api.wordpress.org/secret-key/1.1/salt/ -o "$SALTS_FILE" || true
  if [[ -s "$SALTS_FILE" ]]; then
    php -r '
      $cfgFile = $argv[1];
      $saltsFile = $argv[2];
      $cfg = file_get_contents($cfgFile);
      $salts = file_get_contents($saltsFile);
      $cfg = preg_replace(
        "/define\(\s*[\x27\x22]AUTH_KEY[\x27\x22].*?define\(\s*[\x27\x22]NONCE_SALT[\x27\x22][^;]*;/s",
        rtrim($salts),
        $cfg,
        1
      );
      file_put_contents($cfgFile, $cfg);
    ' "$WP_ROOT/wp-config.php" "$SALTS_FILE"
  else
    log "WARNING: could not fetch security keys from api.wordpress.org (offline?). wp-config.php keeps placeholder AUTH_KEY/SALT values - replace them manually from https://api.wordpress.org/secret-key/1.1/salt/ before going live."
  fi
  rm -f "$SALTS_FILE"

  chown -R www-data:www-data "$WP_ROOT"
  find "$WP_ROOT" -type d -exec chmod 755 {} \;
  find "$WP_ROOT" -type f -exec chmod 644 {} \;
  chown root:www-data "$WP_ROOT/wp-config.php"
  chmod 640 "$WP_ROOT/wp-config.php"

  NGINX_CONF="/etc/nginx/sites-available/${SITE_SLUG}.conf"

  if [[ "$PROTOCOL" == "https" ]]; then
    log "Generating self-signed TLS certificate..."
    mkdir -p /etc/nginx/ssl
    CERT_CN="$SERVER_NAME"
    [[ "$CERT_CN" == "_" ]] && CERT_CN="${SERVER_IP:-localhost}"
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
      -keyout "/etc/nginx/ssl/${SITE_SLUG}-selfsigned.key" \
      -out "/etc/nginx/ssl/${SITE_SLUG}-selfsigned.crt" \
      -subj "/CN=${CERT_CN}" >/dev/null 2>&1

    cat > "$NGINX_CONF" <<NGINXCONF
server {
    listen 80${DEFAULT_SERVER_FLAG};
    listen [::]:80${DEFAULT_SERVER_FLAG};
    server_name ${SERVER_NAME};
    server_tokens off;
    return 301 https://\$host\$request_uri;
}

server {
    listen 443 ssl${DEFAULT_SERVER_FLAG};
    listen [::]:443 ssl${DEFAULT_SERVER_FLAG};
    http2 on;
    server_name ${SERVER_NAME};
    root ${WP_ROOT};
    index index.php index.html index.htm;

    ssl_certificate /etc/nginx/ssl/${SITE_SLUG}-selfsigned.crt;
    ssl_certificate_key /etc/nginx/ssl/${SITE_SLUG}-selfsigned.key;
    ssl_protocols TLSv1.2 TLSv1.3;

    server_tokens off;
    client_max_body_size 64M;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    access_log /var/log/nginx/${SITE_SLUG}_access.log;
    error_log /var/log/nginx/${SITE_SLUG}_error.log;

    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }

    location = /wp-login.php {
        limit_req zone=wplogin burst=3 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_SOCK};
    }

    location ~* /wp-content/uploads/.*\.php\$ {
        deny all;
    }

    location = /wp-config.php {
        deny all;
    }

    location ~ \.php\$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_SOCK};
    }

    location ~ /\. {
        deny all;
    }

    location ~* \.(bak|old|swp|save|orig|sql|log|dpkg-old|dpkg-new)\$ {
        deny all;
    }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt { log_not_found off; access_log off; allow all; }

    location ~* \.(css|gif|ico|jpeg|jpg|js|png|svg|woff|woff2)\$ {
        expires max;
        log_not_found off;
    }
}
NGINXCONF
  else
    cat > "$NGINX_CONF" <<NGINXCONF
server {
    listen 80${DEFAULT_SERVER_FLAG};
    listen [::]:80${DEFAULT_SERVER_FLAG};
    server_name ${SERVER_NAME};
    root ${WP_ROOT};
    index index.php index.html index.htm;

    server_tokens off;
    client_max_body_size 64M;

    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    access_log /var/log/nginx/${SITE_SLUG}_access.log;
    error_log /var/log/nginx/${SITE_SLUG}_error.log;

    location / {
        try_files \$uri \$uri/ /index.php?\$args;
    }

    location = /wp-login.php {
        limit_req zone=wplogin burst=3 nodelay;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_SOCK};
    }

    location ~* /wp-content/uploads/.*\.php\$ {
        deny all;
    }

    location = /wp-config.php {
        deny all;
    }

    location ~ \.php\$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:${PHP_SOCK};
    }

    location ~ /\. {
        deny all;
    }

    location ~* \.(bak|old|swp|save|orig|sql|log|dpkg-old|dpkg-new)\$ {
        deny all;
    }

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt { log_not_found off; access_log off; allow all; }

    location ~* \.(css|gif|ico|jpeg|jpg|js|png|svg|woff|woff2)\$ {
        expires max;
        log_not_found off;
    }
}
NGINXCONF
  fi

  ln -sf "$NGINX_CONF" "/etc/nginx/sites-enabled/${SITE_SLUG}.conf"
  nginx -t
  systemctl reload nginx

  if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
    if [[ "$PROTOCOL" == "https" ]]; then
      ufw allow 'Nginx Full' >/dev/null || true
    else
      ufw allow 'Nginx HTTP' >/dev/null || true
    fi
  fi

  DISPLAY_HOST="$SERVER_NAME"
  [[ "$SERVER_NAME" == "_" ]] && DISPLAY_HOST="${SERVER_IP:-localhost}"
  SCHEME="http"; [[ "$PROTOCOL" == "https" ]] && SCHEME="https"
  SITE_URL="${SCHEME}://${DISPLAY_HOST}/"

  cat > "$SITE_CRED_FILE" <<CREDS
WordPress site "${SITE_SLUG}" - $(date)
=================================
Site URL        : ${SITE_URL}
Web root        : ${WP_ROOT}
nginx vhost     : ${NGINX_CONF}
Reverse proxy   : ${REVERSE_PROXY}

WordPress DB name      : ${WP_DB_NAME}
WordPress DB user      : ${WP_DB_USER}
WordPress DB password  : ${WP_DB_PASSWORD}
Table prefix            : ${RAND_PREFIX}

MariaDB root password is stored separately in ${ROOT_CRED_FILE}.

Next step: open the Site URL above in a browser to finish the WordPress
setup wizard (site title, admin username, admin password).
CREDS
  chmod 600 "$SITE_CRED_FILE"

  log "Site #${SITE_NUM} done. Visit: ${SITE_URL}"
  log "Credentials saved to ${SITE_CRED_FILE} (root-only, chmod 600)."

  SITE_SUMMARIES+=("${SITE_SLUG}: ${SITE_URL} (creds: ${SITE_CRED_FILE})")

  if [[ -t 0 ]]; then
    prompt_yes_no "Install another WordPress site on this server?" "n" && continue
  fi
  break
done

log "All done - ${SITE_NUM} site(s) installed:"
printf '  %s\n' "${SITE_SUMMARIES[@]}"

Script: Apache (LAMP)

#!/usr/bin/env bash
#
# WordPress installer: Apache + MariaDB + PHP-FPM (LAMP, PHP-FPM via
# mod_proxy_fcgi - not mod_php) for Debian/Ubuntu.
#
# Interactive by default - run:  sudo ./install-wordpress-apache.sh
#
# Supports installing MULTIPLE WordPress sites on one server: system-level
# setup (packages, MariaDB root, PHP tuning) runs once, then it loops asking
# for each site's details until you say no to "install another?".
#
# Safe to re-run: it detects an already-secured MariaDB root account and
# reuses it instead of failing, and each site's DB user password is
# re-synced with wp-config.php every time.
#
# Every prompt can be pre-answered via environment variables (useful for a
# single non-interactive run - see the example at the bottom of this header):
#
#   WP_SECURE_MARIADB     yes | no   (run the mysql_secure_installation-equivalent steps)
#   WP_DB_ROOT_PASSWORD   MariaDB root password (blank = auto-generate, only used if securing)
#   WP_SERVER_NAME_MODE   catchall | ip | domain      (per site)
#   WP_DOMAIN             required if WP_SERVER_NAME_MODE=domain
#   WP_PROTOCOL            http | https                (https = self-signed cert, no Let's Encrypt)
#   WP_REVERSE_PROXY       yes | no  (trust X-Forwarded-Proto from an upstream TLS-terminating
#                          proxy/load balancer - same wp-config.php snippet as wp-optimize.sh's
#                          reverse_proxy_https task, so they never conflict)
#   WP_SITE_SLUG           short identifier for the site's folder/vhost config
#   WP_DB_NAME, WP_DB_USER, WP_DB_PASSWORD
#                          (blank password = auto-generate)
#
# Note: catch-all (_) and bare-IP server_names only work for ONE site per
# server, since Apache has no Host header to route on for them either -
# same limitation as the nginx version of this installer. Additional sites
# need a real domain name.
#
# Example fully non-interactive single-site run:
#   WP_SECURE_MARIADB=yes WP_DB_ROOT_PASSWORD=... \
#   WP_SERVER_NAME_MODE=catchall WP_PROTOCOL=http \
#   WP_DB_NAME=wordpress WP_DB_USER=wpuser WP_DB_PASSWORD=... \
#   ./install-wordpress-apache.sh
#
set -eu
trap 'err "Installation failed at line $LINENO."' ERR

log() { echo -e "\n\033[1;32m==>\033[0m $1"; }
err() { echo -e "\033[1;31mERROR:\033[0m $1" >&2; }

[[ $EUID -eq 0 ]] || { err "Run this script as root (sudo ./install-wordpress-apache.sh)."; exit 1; }
command -v apt-get >/dev/null 2>&1 || { err "This script supports Debian/Ubuntu (apt-get) systems only."; exit 1; }

rand_pass() { tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-20}"; }

# MySQL identifiers (db name / user): letters, digits, underscore only -
# safe to interpolate into SQL and sed without escaping.
valid_ident() { [[ "$1" =~ ^[A-Za-z0-9_]+$ ]]; }

# Passwords: anything printable except characters that are unsafe when
# interpolated into a single-quoted SQL literal or a sed s/// replacement
# (' " \ / & and whitespace).
valid_password() {
  local p="$1"
  [[ -n "$p" ]] || return 1
  [[ "$p" == *[\'\"\\/\&]* ]] && return 1
  [[ "$p" == *[[:space:]]* ]] && return 1
  return 0
}

prompt_menu() {
  # $1 = prompt text, remaining args = options. Echoes the chosen index (1-based).
  local prompt="$1"; shift
  local opts=("$@")
  echo "$prompt" >&2
  local i=1
  for o in "${opts[@]}"; do echo "  $i) $o" >&2; ((i++)); done
  local choice
  while true; do
    read -rp "Choice [1-${#opts[@]}]: " choice
    if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#opts[@]} )); then
      break
    fi
    echo "Invalid choice." >&2
  done
  echo "$choice"
}

prompt_yes_no() {
  # $1 = prompt text, $2 = default (y|n). Returns 0 for yes, 1 for no.
  local prompt="$1" default="${2:-y}" ans suffix="[Y/n]"
  [[ "$default" == "n" ]] && suffix="[y/N]"
  while true; do
    ans=""
    read -rp "$prompt $suffix " ans || ans="$default"
    ans="${ans:-$default}"
    case "$ans" in
      [Yy]*) return 0 ;;
      [Nn]*) return 1 ;;
      *) echo "Please answer y or n." >&2 ;;
    esac
  done
}

################################################################################
### Phase A - one-time system setup: packages, services, MariaDB, PHP tuning
################################################################################

SECURE_MARIADB="${WP_SECURE_MARIADB:-}"
if [[ -z "$SECURE_MARIADB" ]]; then
  if prompt_yes_no "Secure this MariaDB installation now (set root password, remove anonymous users & the test database)?" "y"; then
    SECURE_MARIADB="yes"
  else
    SECURE_MARIADB="no"
  fi
fi
[[ "$SECURE_MARIADB" == "yes" || "$SECURE_MARIADB" == "no" ]] || { err "Invalid WP_SECURE_MARIADB: $SECURE_MARIADB (must be yes or no)"; exit 1; }

MYSQL_ROOT_PASSWORD="${WP_DB_ROOT_PASSWORD:-}"
if [[ "$SECURE_MARIADB" == "yes" && -z "$MYSQL_ROOT_PASSWORD" ]]; then
  while true; do
    read -rsp "MariaDB root password to set (leave blank to auto-generate): " MYSQL_ROOT_PASSWORD; echo
    if [[ -z "$MYSQL_ROOT_PASSWORD" ]]; then
      MYSQL_ROOT_PASSWORD="$(rand_pass 24)"
      log "Generated random MariaDB root password."
      break
    fi
    valid_password "$MYSQL_ROOT_PASSWORD" && break
    echo "Password must not contain quotes, backslashes, /, &, or whitespace." >&2
  done
fi

export DEBIAN_FRONTEND=noninteractive
log "Updating apt and installing Apache, MariaDB, PHP-FPM and extensions..."
apt-get update -y
apt-get install -y \
  apache2 mariadb-server \
  php-fpm php-mysql php-curl php-gd php-mbstring php-xml php-zip php-intl php-bcmath php-soap \
  unzip wget curl openssl

systemctl enable --now mariadb

PHP_FPM_SERVICE="$(systemctl list-unit-files 'php*-fpm.service' --no-legend | awk '{print $1}' | head -1)"
[[ -z "$PHP_FPM_SERVICE" ]] && { err "Could not find a php-fpm systemd service."; exit 1; }
systemctl enable --now "$PHP_FPM_SERVICE"

PHP_SOCK="$(find /run/php -maxdepth 1 -name '*.sock' 2>/dev/null | head -1)"
[[ -z "$PHP_SOCK" ]] && { err "Could not find the php-fpm socket in /run/php."; exit 1; }

# Raise PHP upload/execution limits to match Apache's LimitRequestBody below,
# and hide the PHP version from response headers.
PHP_VERSION="${PHP_FPM_SERVICE#php}"
PHP_VERSION="${PHP_VERSION%-fpm.service}"
PHP_INI="/etc/php/${PHP_VERSION}/fpm/php.ini"
if [[ -f "$PHP_INI" ]]; then
  log "Tuning PHP (upload limits, memory, expose_php off) in ${PHP_INI}..."
  sed -i \
    -e "s/^upload_max_filesize = .*/upload_max_filesize = 64M/" \
    -e "s/^post_max_size = .*/post_max_size = 64M/" \
    -e "s/^memory_limit = .*/memory_limit = 256M/" \
    -e "s/^max_execution_time = .*/max_execution_time = 300/" \
    -e "s/^expose_php = .*/expose_php = Off/" \
    "$PHP_INI"
  systemctl restart "$PHP_FPM_SERVICE"
fi

# MariaDB should only ever listen on localhost for a single-server LAMP box.
MARIADB_CNF="/etc/mysql/mariadb.conf.d/50-server.cnf"
if [[ -f "$MARIADB_CNF" ]] && grep -q '^bind-address' "$MARIADB_CNF" && ! grep -q '^bind-address\s*=\s*127.0.0.1' "$MARIADB_CNF"; then
  log "Restricting MariaDB to listen on 127.0.0.1 only..."
  sed -i 's/^bind-address.*/bind-address = 127.0.0.1/' "$MARIADB_CNF"
  systemctl restart mariadb
fi

log "Configuring MariaDB root access..."

try_mysql_root() { mysql -u root -e 'SELECT 1;' >/dev/null 2>&1; }
try_mysql_root_pw() { mysql -u root -p"$1" -e 'SELECT 1;' >/dev/null 2>&1; }

ROOT_CRED_FILE="/root/mariadb-root-credentials.txt"
LEGACY_CRED_FILE="/root/wordpress-credentials.txt"
EXISTING_ROOT_PW=""
if [[ -f "$ROOT_CRED_FILE" ]]; then
  EXISTING_ROOT_PW="$(sed -n 's/^MariaDB root password:[[:space:]]*//p' "$ROOT_CRED_FILE" | head -1)"
fi
if [[ -z "$EXISTING_ROOT_PW" && -f "$LEGACY_CRED_FILE" ]]; then
  EXISTING_ROOT_PW="$(grep -m1 '^MariaDB root password' "$LEGACY_CRED_FILE" 2>/dev/null | sed -E 's/^[^:]+:[[:space:]]*//')"
fi

ROOT_AUTH_OK=0
ROOT_HAS_PASSWORD=0
if try_mysql_root; then
  MYSQL_AUTH=(mysql -u root)
  ROOT_AUTH_OK=1
elif [[ -n "$EXISTING_ROOT_PW" ]] && try_mysql_root_pw "$EXISTING_ROOT_PW"; then
  MYSQL_AUTH=(mysql -u root -p"${EXISTING_ROOT_PW}")
  MYSQL_ROOT_PASSWORD="$EXISTING_ROOT_PW"
  ROOT_HAS_PASSWORD=1
  ROOT_AUTH_OK=1
elif [[ -n "$MYSQL_ROOT_PASSWORD" ]] && try_mysql_root_pw "$MYSQL_ROOT_PASSWORD"; then
  MYSQL_AUTH=(mysql -u root -p"${MYSQL_ROOT_PASSWORD}")
  ROOT_HAS_PASSWORD=1
  ROOT_AUTH_OK=1
fi

[[ "$ROOT_AUTH_OK" -eq 1 ]] || { err "Cannot authenticate to MariaDB as root. If it was already secured by a previous run, pass its password as WP_DB_ROOT_PASSWORD."; exit 1; }

if [[ "$SECURE_MARIADB" == "yes" && "$ROOT_HAS_PASSWORD" -eq 0 ]]; then
  [[ -z "$MYSQL_ROOT_PASSWORD" ]] && MYSQL_ROOT_PASSWORD="$(rand_pass 24)"
  "${MYSQL_AUTH[@]}" <<SQL
ALTER USER 'root'@'localhost' IDENTIFIED VIA unix_socket OR mysql_native_password USING PASSWORD('${MYSQL_ROOT_PASSWORD}');
SQL
  MYSQL_AUTH=(mysql -u root -p"${MYSQL_ROOT_PASSWORD}")
  ROOT_HAS_PASSWORD=1
  cat > "$ROOT_CRED_FILE" <<EOF
MariaDB root password: ${MYSQL_ROOT_PASSWORD}
EOF
  chmod 600 "$ROOT_CRED_FILE"
fi

if [[ "$SECURE_MARIADB" == "yes" ]]; then
  "${MYSQL_AUTH[@]}" <<SQL
DELETE FROM mysql.user WHERE User='';
DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost','127.0.0.1','::1');
DROP DATABASE IF EXISTS test;
DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';
FLUSH PRIVILEGES;
SQL
fi

# --- Apache modules + MPM ----------------------------------------------------
# PHP-FPM via mod_proxy_fcgi needs mpm_event (mpm_prefork is the Debian
# default and is incompatible with it) - matches what wp-optimize.sh's own
# MPM-switch logic expects to find already in place.
log "Configuring Apache modules (mpm_event, proxy_fcgi, ssl, rewrite, headers)..."
a2enmod rewrite headers ssl http2 >/dev/null
if a2query -m mpm_prefork >/dev/null 2>&1; then
  a2dismod mpm_prefork >/dev/null 2>&1 || true
fi
a2enmod mpm_event proxy_fcgi setenvif >/dev/null
a2dissite 000-default >/dev/null 2>&1 || true
a2dissite default-ssl >/dev/null 2>&1 || true

mkdir -p /etc/apache2/sites-available /etc/apache2/sites-enabled

################################################################################
### Phase B - per-site setup (loops for multiple installs)
################################################################################

declare -a USED_SLUGS=()
declare -a SITE_SUMMARIES=()
SITE_NUM=0

while true; do
  SITE_NUM=$((SITE_NUM + 1))

  if [[ "$SITE_NUM" -gt 1 ]]; then
    # Force fresh prompts for every additional site - env vars only pre-answer site #1.
    unset WP_SERVER_NAME_MODE WP_DOMAIN WP_PROTOCOL WP_SITE_SLUG WP_DB_NAME WP_DB_USER WP_DB_PASSWORD || true
  fi

  log "=== Site #${SITE_NUM} configuration ==="

  SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
  DEFAULT_SLUG="wordpress"

  if [[ "$SITE_NUM" -gt 1 ]]; then
    # Apache picks a "default" vhost per address:port the same fragile way
    # nginx does - by config-file load order - so catch-all/IP has the exact
    # same one-site-only limitation here. Not even offered as a choice.
    SERVER_NAME_MODE="domain"
    if [[ "${WP_SERVER_NAME_MODE:-domain}" != "domain" ]]; then
      err "Site #${SITE_NUM}: only a domain name may be used (WP_SERVER_NAME_MODE=${WP_SERVER_NAME_MODE}) - catch-all/IP only works for the first site on a server."
      exit 1
    fi
    log "Site #${SITE_NUM} must use a real domain name (Apache can't route catch-all/IP to more than one site)."
  else
    SERVER_NAME_MODE="${WP_SERVER_NAME_MODE:-}"
    if [[ -z "$SERVER_NAME_MODE" ]]; then
      c=$(prompt_menu "How should Apache identify this site (ServerName)?" \
        "Catch-all (_) - respond to any hostname/IP (only works for a single site)" \
        "Use this server's detected IP address (only works for a single site)" \
        "I'll type a domain name")
      case "$c" in
        1) SERVER_NAME_MODE="catchall" ;;
        2) SERVER_NAME_MODE="ip" ;;
        3) SERVER_NAME_MODE="domain" ;;
      esac
    fi
  fi

  case "$SERVER_NAME_MODE" in
    catchall)
      SERVER_NAME="_"
      ;;
    ip)
      if [[ -z "$SERVER_IP" ]]; then
        err "Could not detect this server's IP address. Re-run and choose the catch-all or domain option instead."
        exit 1
      fi
      SERVER_NAME="${SERVER_IP}"
      ;;
    domain)
      DOMAIN="${WP_DOMAIN:-}"
      while [[ -z "$DOMAIN" ]]; do
        read -rp "Enter the domain name (e.g. example.com): " DOMAIN
      done
      SERVER_NAME="$DOMAIN"
      DEFAULT_SLUG="$(printf '%s' "$DOMAIN" | tr -c 'A-Za-z0-9' '-' | sed 's/^-*//;s/-*$//')"
      [[ -z "$DEFAULT_SLUG" ]] && DEFAULT_SLUG="wordpress"
      ;;
    *) err "Invalid WP_SERVER_NAME_MODE: $SERVER_NAME_MODE"; exit 1 ;;
  esac

  PROTOCOL="${WP_PROTOCOL:-}"
  if [[ -z "$PROTOCOL" ]]; then
    c=$(prompt_menu "Which protocol should this site use?" \
      "HTTP only" \
      "HTTPS with a self-signed certificate (no Let's Encrypt)")
    case "$c" in
      1) PROTOCOL="http" ;;
      2) PROTOCOL="https" ;;
    esac
  fi
  [[ "$PROTOCOL" == "http" || "$PROTOCOL" == "https" ]] || { err "Invalid WP_PROTOCOL: $PROTOCOL"; exit 1; }

  REVERSE_PROXY="${WP_REVERSE_PROXY:-}"
  if [[ -z "$REVERSE_PROXY" ]]; then
    if prompt_yes_no "Is this site behind an external reverse proxy or load balancer (Cloudflare, Traefik, another nginx, an ALB, etc.) that terminates HTTPS in front of it?" "n"; then
      REVERSE_PROXY="yes"
    else
      REVERSE_PROXY="no"
    fi
  fi
  [[ "$REVERSE_PROXY" == "yes" || "$REVERSE_PROXY" == "no" ]] || { err "Invalid WP_REVERSE_PROXY: $REVERSE_PROXY (must be yes or no)"; exit 1; }

  while true; do
    SITE_SLUG="${WP_SITE_SLUG:-}"
    if [[ -z "$SITE_SLUG" ]]; then
      read -rp "Short identifier for this site (used for its folder & vhost config) [${DEFAULT_SLUG}]: " SITE_SLUG
      SITE_SLUG="${SITE_SLUG:-$DEFAULT_SLUG}"
    fi
    SITE_SLUG="$(printf '%s' "$SITE_SLUG" | tr -c 'A-Za-z0-9_-' '-')"
    DUPLICATE=0
    for used in "${USED_SLUGS[@]}"; do
      [[ "$used" == "$SITE_SLUG" ]] && DUPLICATE=1
    done
    if [[ "$DUPLICATE" -eq 1 ]]; then
      echo "Identifier '${SITE_SLUG}' was already used earlier in this run - choose a different one." >&2
      WP_SITE_SLUG=""
      continue
    fi
    break
  done
  USED_SLUGS+=("$SITE_SLUG")

  WP_DB_NAME="${WP_DB_NAME:-}"
  if [[ -z "$WP_DB_NAME" ]]; then
    while true; do
      read -rp "WordPress database name [${SITE_SLUG}]: " WP_DB_NAME
      WP_DB_NAME="${WP_DB_NAME:-$SITE_SLUG}"
      WP_DB_NAME="$(printf '%s' "$WP_DB_NAME" | tr -c 'A-Za-z0-9_' '_')"
      valid_ident "$WP_DB_NAME" && break
      echo "Database name may only contain letters, digits, and underscores." >&2
      WP_DB_NAME=""
    done
  else
    valid_ident "$WP_DB_NAME" || { err "WP_DB_NAME may only contain letters, digits, and underscores."; exit 1; }
  fi

  WP_DB_USER="${WP_DB_USER:-}"
  if [[ -z "$WP_DB_USER" ]]; then
    while true; do
      read -rp "WordPress database user [${WP_DB_NAME}_user]: " WP_DB_USER
      WP_DB_USER="${WP_DB_USER:-${WP_DB_NAME}_user}"
      valid_ident "$WP_DB_USER" && break
      echo "Database user may only contain letters, digits, and underscores." >&2
      WP_DB_USER=""
    done
  else
    valid_ident "$WP_DB_USER" || { err "WP_DB_USER may only contain letters, digits, and underscores."; exit 1; }
  fi

  WP_DB_PASSWORD="${WP_DB_PASSWORD:-}"
  if [[ -z "$WP_DB_PASSWORD" ]]; then
    while true; do
      read -rsp "WordPress database password (leave blank to auto-generate): " WP_DB_PASSWORD; echo
      if [[ -z "$WP_DB_PASSWORD" ]]; then
        WP_DB_PASSWORD="$(rand_pass 24)"
        log "Generated random WordPress DB password."
        break
      fi
      valid_password "$WP_DB_PASSWORD" && break
      echo "Password must not contain quotes, backslashes, /, &, or whitespace." >&2
    done
  else
    valid_password "$WP_DB_PASSWORD" || { err "WP_DB_PASSWORD contains an unsupported character (quotes, backslash, /, &, or whitespace)."; exit 1; }
  fi

  WP_ROOT="/var/www/${SITE_SLUG}"
  SITE_CRED_FILE="/root/wordpress-credentials-${SITE_SLUG}.txt"

  log "Site #${SITE_NUM} configuration:
  server_name    : ${SERVER_NAME}
  protocol       : ${PROTOCOL}
  reverse proxy  : ${REVERSE_PROXY}
  web root       : ${WP_ROOT}
  db name        : ${WP_DB_NAME}
  db user        : ${WP_DB_USER}"

  log "Creating database and user for this site..."
  "${MYSQL_AUTH[@]}" <<SQL
CREATE DATABASE IF NOT EXISTS \`${WP_DB_NAME}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${WP_DB_USER}'@'localhost';
ALTER USER '${WP_DB_USER}'@'localhost' IDENTIFIED BY '${WP_DB_PASSWORD}';
GRANT ALL PRIVILEGES ON \`${WP_DB_NAME}\`.* TO '${WP_DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL

  if [[ -d "$WP_ROOT" && -n "$(ls -A "$WP_ROOT" 2>/dev/null)" ]]; then
    BACKUP="${WP_ROOT}.bak.$(date +%s)"
    log "Existing ${WP_ROOT} is not empty - moving it to ${BACKUP}"
    mv "$WP_ROOT" "$BACKUP"
  fi

  log "Downloading latest WordPress..."
  TMP_DIR="$(mktemp -d)"
  curl -fsSL https://wordpress.org/latest.tar.gz -o "$TMP_DIR/wordpress.tar.gz"
  tar -xzf "$TMP_DIR/wordpress.tar.gz" -C "$TMP_DIR"

  mkdir -p "$WP_ROOT"
  cp -a "$TMP_DIR"/wordpress/. "$WP_ROOT"/
  rm -rf "$TMP_DIR"

  cp "$WP_ROOT/wp-config-sample.php" "$WP_ROOT/wp-config.php"
  sed -i "s/database_name_here/${WP_DB_NAME}/" "$WP_ROOT/wp-config.php"
  sed -i "s/username_here/${WP_DB_USER}/" "$WP_ROOT/wp-config.php"
  sed -i "s/password_here/${WP_DB_PASSWORD}/" "$WP_ROOT/wp-config.php"

  RAND_PREFIX="wp_$(rand_pass 6 | tr 'A-Z' 'a-z')_"
  sed -i "s/table_prefix = 'wp_';/table_prefix = '${RAND_PREFIX}';/" "$WP_ROOT/wp-config.php"

  # Hardening: block the theme/plugin file editor, and force HTTPS in wp-admin if applicable.
  sed -i "/require_once ABSPATH \. 'wp-settings.php';/i define('DISALLOW_FILE_EDIT', true);" "$WP_ROOT/wp-config.php"
  if [[ "$PROTOCOL" == "https" ]]; then
    sed -i "/require_once ABSPATH \. 'wp-settings.php';/i define('FORCE_SSL_ADMIN', true);" "$WP_ROOT/wp-config.php"
  fi

  if [[ "$REVERSE_PROXY" == "yes" ]]; then
    log "Adding reverse-proxy HTTPS detection to wp-config.php..."
    # Trusts X-Forwarded-Proto from the upstream proxy so WordPress knows the
    # original request was HTTPS even though this box only sees plain HTTP
    # from the proxy. Same marker/logic as wp-optimize.sh's reverse_proxy_https
    # task and the nginx version of this installer, so none of the three ever
    # fight over or duplicate this block on a site any of them has touched.
    php -r '
      $cfgFile = $argv[1];
      $cfg = file_get_contents($cfgFile);
      if (strpos($cfg, "HTTP_X_FORWARDED_PROTO") === false) {
        $block = "\n// >>> reverse-proxy-https\n"
          . "if ( isset( \$_SERVER[\x27HTTP_X_FORWARDED_PROTO\x27] ) && strtolower( \$_SERVER[\x27HTTP_X_FORWARDED_PROTO\x27] ) === \x27https\x27 ) {\n"
          . "    \$_SERVER[\x27HTTPS\x27] = \x27on\x27;\n"
          . "}\n"
          . "// <<< reverse-proxy-https\n";
        $anchor = "require_once ABSPATH . \x27wp-settings.php\x27;";
        if (strpos($cfg, $anchor) !== false) {
          $cfg = str_replace($anchor, $block . $anchor, $cfg);
          file_put_contents($cfgFile, $cfg);
        }
      }
    ' "$WP_ROOT/wp-config.php"
  fi

  log "Fetching unique WordPress security keys..."
  SALTS_FILE="$(mktemp)"
  curl -fsSL https://api.wordpress.org/secret-key/1.1/salt/ -o "$SALTS_FILE" || true
  if [[ -s "$SALTS_FILE" ]]; then
    php -r '
      $cfgFile = $argv[1];
      $saltsFile = $argv[2];
      $cfg = file_get_contents($cfgFile);
      $salts = file_get_contents($saltsFile);
      $cfg = preg_replace(
        "/define\(\s*[\x27\x22]AUTH_KEY[\x27\x22].*?define\(\s*[\x27\x22]NONCE_SALT[\x27\x22][^;]*;/s",
        rtrim($salts),
        $cfg,
        1
      );
      file_put_contents($cfgFile, $cfg);
    ' "$WP_ROOT/wp-config.php" "$SALTS_FILE"
  else
    log "WARNING: could not fetch security keys from api.wordpress.org (offline?). wp-config.php keeps placeholder AUTH_KEY/SALT values - replace them manually from https://api.wordpress.org/secret-key/1.1/salt/ before going live."
  fi
  rm -f "$SALTS_FILE"

  chown -R www-data:www-data "$WP_ROOT"
  find "$WP_ROOT" -type d -exec chmod 755 {} \;
  find "$WP_ROOT" -type f -exec chmod 644 {} \;
  chown root:www-data "$WP_ROOT/wp-config.php"
  chmod 640 "$WP_ROOT/wp-config.php"

  # A catch-all/IP site must win Apache's "first vhost for this address:port
  # wins when nothing else matches" selection regardless of what other sites'
  # config files get added later (alphabetically or otherwise) - the exact
  # bug class the nginx version of this installer hit in practice. A "000-"
  # filename prefix guarantees it sorts first among sites-enabled/*.conf.
  if [[ "$SERVER_NAME_MODE" == "catchall" || "$SERVER_NAME_MODE" == "ip" ]]; then
    APACHE_CONF_NAME="000-${SITE_SLUG}"
  else
    APACHE_CONF_NAME="${SITE_SLUG}"
  fi
  APACHE_CONF="/etc/apache2/sites-available/${APACHE_CONF_NAME}.conf"

  if [[ "$PROTOCOL" == "https" ]]; then
    log "Generating self-signed TLS certificate..."
    mkdir -p /etc/apache2/ssl
    CERT_CN="$SERVER_NAME"
    [[ "$CERT_CN" == "_" ]] && CERT_CN="${SERVER_IP:-localhost}"
    openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
      -keyout "/etc/apache2/ssl/${SITE_SLUG}-selfsigned.key" \
      -out "/etc/apache2/ssl/${SITE_SLUG}-selfsigned.crt" \
      -subj "/CN=${CERT_CN}" >/dev/null 2>&1

    cat > "$APACHE_CONF" <<APACHECONF
<VirtualHost *:80>
    ServerName ${SERVER_NAME}
    Redirect permanent / https://${CERT_CN}/
</VirtualHost>

<VirtualHost *:443>
    ServerName ${SERVER_NAME}
    DocumentRoot ${WP_ROOT}

    SSLEngine on
    SSLCertificateFile /etc/apache2/ssl/${SITE_SLUG}-selfsigned.crt
    SSLCertificateKeyFile /etc/apache2/ssl/${SITE_SLUG}-selfsigned.key
    SSLProtocol -all +TLSv1.2 +TLSv1.3
    Protocols h2 http/1.1

    LimitRequestBody 67108864

    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header unset X-Powered-By
    ServerSignature Off

    ErrorLog \${APACHE_LOG_DIR}/${SITE_SLUG}_error.log
    CustomLog \${APACHE_LOG_DIR}/${SITE_SLUG}_access.log combined

    <Directory ${WP_ROOT}>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    <FilesMatch "\.php\$">
        SetHandler "proxy:unix:${PHP_SOCK}|fcgi://localhost"
    </FilesMatch>

    <FilesMatch "^\.">
        Require all denied
    </FilesMatch>

    <FilesMatch "\.(bak|old|swp|save|orig|sql|log|dpkg-old|dpkg-new)\$">
        Require all denied
    </FilesMatch>

    <Files "wp-config.php">
        Require all denied
    </Files>

    <Directory ${WP_ROOT}/wp-content/uploads>
        <FilesMatch "\.php\$">
            Require all denied
        </FilesMatch>
    </Directory>
</VirtualHost>
APACHECONF
  else
    cat > "$APACHE_CONF" <<APACHECONF
<VirtualHost *:80>
    ServerName ${SERVER_NAME}
    DocumentRoot ${WP_ROOT}

    LimitRequestBody 67108864

    Header always set X-Content-Type-Options "nosniff"
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header unset X-Powered-By
    ServerSignature Off

    ErrorLog \${APACHE_LOG_DIR}/${SITE_SLUG}_error.log
    CustomLog \${APACHE_LOG_DIR}/${SITE_SLUG}_access.log combined

    <Directory ${WP_ROOT}>
        Options -Indexes +FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    <FilesMatch "\.php\$">
        SetHandler "proxy:unix:${PHP_SOCK}|fcgi://localhost"
    </FilesMatch>

    <FilesMatch "^\.">
        Require all denied
    </FilesMatch>

    <FilesMatch "\.(bak|old|swp|save|orig|sql|log|dpkg-old|dpkg-new)\$">
        Require all denied
    </FilesMatch>

    <Files "wp-config.php">
        Require all denied
    </Files>

    <Directory ${WP_ROOT}/wp-content/uploads>
        <FilesMatch "\.php\$">
            Require all denied
        </FilesMatch>
    </Directory>
</VirtualHost>
APACHECONF
  fi

  a2ensite "${APACHE_CONF_NAME}" >/dev/null
  apache2ctl configtest
  systemctl reload apache2

  if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
    if [[ "$PROTOCOL" == "https" ]]; then
      ufw allow 'Apache Full' >/dev/null || true
    else
      ufw allow 'Apache' >/dev/null || true
    fi
  fi

  DISPLAY_HOST="$SERVER_NAME"
  [[ "$SERVER_NAME" == "_" ]] && DISPLAY_HOST="${SERVER_IP:-localhost}"
  SCHEME="http"; [[ "$PROTOCOL" == "https" ]] && SCHEME="https"
  SITE_URL="${SCHEME}://${DISPLAY_HOST}/"

  cat > "$SITE_CRED_FILE" <<CREDS
WordPress site "${SITE_SLUG}" - $(date)
=================================
Site URL        : ${SITE_URL}
Web root        : ${WP_ROOT}
Apache vhost    : ${APACHE_CONF}
Reverse proxy   : ${REVERSE_PROXY}

WordPress DB name      : ${WP_DB_NAME}
WordPress DB user      : ${WP_DB_USER}
WordPress DB password  : ${WP_DB_PASSWORD}
Table prefix            : ${RAND_PREFIX}

MariaDB root password is stored separately in ${ROOT_CRED_FILE}.

Next step: open the Site URL above in a browser to finish the WordPress
setup wizard (site title, admin username, admin password).
CREDS
  chmod 600 "$SITE_CRED_FILE"

  log "Site #${SITE_NUM} done. Visit: ${SITE_URL}"
  log "Credentials saved to ${SITE_CRED_FILE} (root-only, chmod 600)."

  SITE_SUMMARIES+=("${SITE_SLUG}: ${SITE_URL} (creds: ${SITE_CRED_FILE})")

  if [[ -t 0 ]]; then
    prompt_yes_no "Install another WordPress site on this server?" "n" && continue
  fi
  break
done

log "All done - ${SITE_NUM} site(s) installed:"
printf '  %s\n' "${SITE_SUMMARIES[@]}"

Leave a Reply

Your email address will not be published. Required fields are marked *