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 thetestdatabase) - 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.phpevery 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_ADMINwhen HTTPS is chosen) and blocks PHP execution insidewp-content/uploads - Rate-limits
wp-login.phpto 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-Protohandling 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 noHostheader 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:
| Variable | Values | Notes |
|---|---|---|
WP_SECURE_MARIADB | yes / no | Run the mysql_secure_installation-equivalent steps |
WP_DB_ROOT_PASSWORD | string | MariaDB root password; blank auto-generates one, only used if securing |
WP_SERVER_NAME_MODE | catchall / ip / domain | Per site |
WP_DOMAIN | string | 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 |
WP_SITE_SLUG | string | Short identifier for the site’s folder/vhost config |
WP_DB_NAME / WP_DB_USER / WP_DB_PASSWORD | string | Blank 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[@]}"