#!/usr/bin/env bash # # setup-webserver.sh — Fresh web server provisioning for Ubuntu 24.04 LTS # # Stack: Apache (event MPM) + PHP 8.3-FPM + MariaDB + Node + Python # + phpMyAdmin (IP-restricted) + Composer + Certbot + UFW + Fail2ban # # .htaccess works: mod_rewrite enabled, AllowOverride All on web root. # # Idempotent: safe to re-run. Each section guarded. # Run as root or with sudo: sudo bash setup-webserver.sh # set -euo pipefail #───────────────────────────────────────────────────────────────────────────── # CONFIG — edit these before running #───────────────────────────────────────────────────────────────────────────── WEB_ROOT="/var/www/html" SERVER_NAME="${SERVER_NAME:-_}" # e.g. example.com (default: catch-all) ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}" # for certbot TIMEZONE="${TIMEZONE:-UTC}" # phpMyAdmin access restriction — only these IPs/CIDRs may reach it. # Set to your office/home IP. Empty = localhost only (use SSH tunnel). PMA_ALLOW_IPS="${PMA_ALLOW_IPS:-127.0.0.1}" # Feature toggles — left UNSET so the script PROMPTS for each (Enter = default). # Set any via env to skip its prompt (good for automation/CI): # sudo INSTALL_NODE=no INSTALL_SWAP=yes bash setup-webserver.sh # Values: yes | no # # INSTALL_PHPMYADMIN (default yes) phpMyAdmin, IP-restricted # INSTALL_NODE (default yes) Node.js + PM2 # INSTALL_REDIS (default yes) Redis + php-redis # INSTALL_CERTBOT (default yes) certbot (cert requested later, manually) # INSTALL_SWAP (default yes if RAM<4GB) swapfile # HARDEN_SSH (default no) key-only SSH <-- lockout risk, asks twice # INSTALL_DB_BACKUP (default yes) nightly mysqldump cron # TUNE_PHP (default yes) OPcache + FPM pool tuning # INSTALL_HEALTHCHECK (default yes) /usr/local/bin/healthcheck NODE_MAJOR="${NODE_MAJOR:-22}" # NodeSource LTS line PHP_VER="8.3" # Ubuntu 24.04 default #───────────────────────────────────────────────────────────────────────────── # Helpers #───────────────────────────────────────────────────────────────────────────── log() { printf '\n\033[1;32m==> %s\033[0m\n' "$*"; } warn() { printf '\033[1;33m!! %s\033[0m\n' "$*"; } die() { printf '\033[1;31mXX %s\033[0m\n' "$*" >&2; exit 1; } # Prompt only when stdin is a real terminal; else use env/default (CI-safe). interactive() { [[ -t 0 ]]; } # ask_yn VAR "Question" default(yes/no) # - if VAR already set (via env): keep it, no prompt # - else if interactive: ask, Enter = default # - else (no TTY): use default ask_yn() { local __var="$1" __q="$2" __def="${3:-no}" __cur="${!1:-}" [[ -n "$__cur" ]] && return if interactive; then local __ans read -rp " ? $__q [$( [[ $__def == yes ]] && echo Y/n || echo y/N )]: " __ans __ans="${__ans:-$__def}" case "$__ans" in [Yy]*) printf -v "$__var" yes;; *) printf -v "$__var" no;; esac else printf -v "$__var" '%s' "$__def" fi } [[ $EUID -eq 0 ]] || die "Run as root: sudo bash $0" . /etc/os-release [[ "${VERSION_ID:-}" == "24.04" ]] || warn "Tested on 24.04, found ${VERSION_ID:-unknown} — continuing" export DEBIAN_FRONTEND=noninteractive # Detect RAM (MB) to pick swap default RAM_MB=$(free -m | awk '/^Mem:/{print $2}') #───────────────────────────────────────────────────────────────────────────── log "Choose what to install (Enter = default)" #───────────────────────────────────────────────────────────────────────────── ask_yn INSTALL_PHPMYADMIN "phpMyAdmin (IP-restricted web DB admin)?" yes ask_yn INSTALL_NODE "Node.js ${NODE_MAJOR}.x + PM2?" yes ask_yn INSTALL_REDIS "Redis cache + php-redis?" yes ask_yn INSTALL_CERTBOT "Certbot (Let's Encrypt TLS tooling)?" yes ask_yn INSTALL_SWAP "Swapfile (RAM safety, ${RAM_MB}MB detected)?" "$([[ $RAM_MB -lt 4000 ]] && echo yes || echo no)" ask_yn INSTALL_DB_BACKUP "Nightly DB backup cron?" yes ask_yn TUNE_PHP "Tune PHP OPcache + FPM pool?" yes ask_yn INSTALL_HEALTHCHECK "Install healthcheck command?" yes ask_yn HARDEN_SSH "Harden SSH (key-only, no root login)? RISK" no #───────────────────────────────────────────────────────────────────────────── log "System update + base tools" #───────────────────────────────────────────────────────────────────────────── apt-get update -y apt-get upgrade -y apt-get install -y \ curl wget git unzip zip ca-certificates gnupg lsb-release \ software-properties-common apt-transport-https \ htop vim ufw fail2ban unattended-upgrades \ build-essential pkg-config timedatectl set-timezone "$TIMEZONE" || warn "timezone set failed" #───────────────────────────────────────────────────────────────────────────── log "Apache (event MPM) + mod_rewrite + proxy_fcgi" #───────────────────────────────────────────────────────────────────────────── apt-get install -y apache2 # Use event MPM (fast) with PHP-FPM, not prefork/mod_php. a2dismod -q mpm_prefork 2>/dev/null || true a2enmod -q mpm_event a2enmod -q rewrite headers ssl proxy proxy_fcgi setenvif http2 #───────────────────────────────────────────────────────────────────────────── log "PHP ${PHP_VER} + FPM + common extensions" #───────────────────────────────────────────────────────────────────────────── apt-get install -y \ php${PHP_VER} php${PHP_VER}-fpm php${PHP_VER}-cli \ php${PHP_VER}-mysql php${PHP_VER}-curl php${PHP_VER}-gd \ php${PHP_VER}-mbstring php${PHP_VER}-xml php${PHP_VER}-zip \ php${PHP_VER}-bcmath php${PHP_VER}-intl php${PHP_VER}-soap \ php${PHP_VER}-readline php${PHP_VER}-opcache # Hand PHP to FPM a2enconf -q php${PHP_VER}-fpm systemctl enable --now php${PHP_VER}-fpm #───────────────────────────────────────────────────────────────────────────── log "Apache vhost — .htaccess enabled (AllowOverride All)" #───────────────────────────────────────────────────────────────────────────── mkdir -p "$WEB_ROOT" cat > /etc/apache2/sites-available/000-default.conf < ServerName ${SERVER_NAME} DocumentRoot ${WEB_ROOT} Options -Indexes +FollowSymLinks AllowOverride All Require all granted ErrorLog \${APACHE_LOG_DIR}/error.log CustomLog \${APACHE_LOG_DIR}/access.log combined EOF # phpinfo test page (delete after verifying) [[ -f "${WEB_ROOT}/info.php" ]] || echo " "${WEB_ROOT}/info.php" chown -R www-data:www-data "$WEB_ROOT" systemctl restart apache2 #───────────────────────────────────────────────────────────────────────────── log "MariaDB (MySQL-compatible)" #───────────────────────────────────────────────────────────────────────────── apt-get install -y mariadb-server mariadb-client systemctl enable --now mariadb # Non-interactive hardening (mysql_secure_installation equivalent). # Root uses unix_socket auth by default on 24.04 — no password needed locally. mariadb <<'SQL' DELETE FROM mysql.global_priv WHERE User=''; DELETE FROM mysql.global_priv 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 warn "MariaDB root uses unix_socket auth — connect with: sudo mariadb" #───────────────────────────────────────────────────────────────────────────── log "Composer (PHP dependency manager)" #───────────────────────────────────────────────────────────────────────────── if ! command -v composer >/dev/null; then EXPECTED="$(curl -s https://composer.github.io/installer.sig)" php -r "copy('https://getcomposer.org/installer','/tmp/composer-setup.php');" ACTUAL="$(php -r "echo hash_file('sha384','/tmp/composer-setup.php');")" [[ "$EXPECTED" == "$ACTUAL" ]] || die "Composer installer checksum mismatch" php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer rm -f /tmp/composer-setup.php fi #───────────────────────────────────────────────────────────────────────────── log "Python 3 + pip + venv" #───────────────────────────────────────────────────────────────────────────── apt-get install -y python3 python3-pip python3-venv python3-dev #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_NODE" == "yes" ]]; then log "Node.js ${NODE_MAJOR}.x (NodeSource) + PM2" if ! command -v node >/dev/null || [[ "$(node -v 2>/dev/null)" != v${NODE_MAJOR}* ]]; then curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - apt-get install -y nodejs fi npm install -g pm2 fi #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_REDIS" == "yes" ]]; then log "Redis (cache / sessions)" apt-get install -y redis-server php${PHP_VER}-redis systemctl enable --now redis-server fi #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_PHPMYADMIN" == "yes" ]]; then log "phpMyAdmin (IP-restricted — NOT public)" # Preseed so apt install is non-interactive; reconfigure web server manually. debconf-set-selections <<'PMA' phpmyadmin phpmyadmin/dbconfig-install boolean true phpmyadmin phpmyadmin/reconfigure-webserver multiselect apache2 phpmyadmin phpmyadmin/mysql/admin-pass password phpmyadmin phpmyadmin/app-password-confirm password phpmyadmin phpmyadmin/mysql/app-pass password PMA apt-get install -y phpmyadmin # Lock down access by IP. Default localhost-only -> use SSH tunnel. ALLOW_BLOCK=" Require ip ${PMA_ALLOW_IPS}" cat > /etc/apache2/conf-available/phpmyadmin-restrict.conf < ${ALLOW_BLOCK} EOF a2enconf -q phpmyadmin-restrict systemctl reload apache2 warn "phpMyAdmin reachable only from: ${PMA_ALLOW_IPS}" warn "Remote admin? SSH tunnel: ssh -L 8080:localhost:80 user@server then http://localhost:8080/phpmyadmin" fi #───────────────────────────────────────────────────────────────────────────── log "Firewall (UFW)" #───────────────────────────────────────────────────────────────────────────── ufw allow OpenSSH ufw allow 'Apache Full' # 80 + 443 ufw --force enable #───────────────────────────────────────────────────────────────────────────── log "Fail2ban (SSH brute-force protection)" #───────────────────────────────────────────────────────────────────────────── cat > /etc/fail2ban/jail.local <<'EOF' [sshd] enabled = true maxretry = 5 bantime = 1h findtime = 10m EOF systemctl enable --now fail2ban systemctl restart fail2ban #───────────────────────────────────────────────────────────────────────────── log "Unattended security upgrades" #───────────────────────────────────────────────────────────────────────────── dpkg-reconfigure -f noninteractive unattended-upgrades || true #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_CERTBOT" == "yes" ]]; then log "Certbot (Let's Encrypt TLS)" apt-get install -y certbot python3-certbot-apache warn "Get a cert AFTER DNS points here: certbot --apache -d ${SERVER_NAME} -m ${ADMIN_EMAIL} --agree-tos" fi #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_SWAP" == "yes" ]]; then log "Swapfile (RAM safety net)" # Why: when RAM fills, kernel pages cold memory to disk instead of OOM-killing # MySQL/Apache. Disk-slow, but stops crashes on memory spikes (small VPS). if swapon --show | grep -q '/swapfile'; then warn "Swap already active, skipping" else # Size: ~2x RAM if tiny, else = RAM, capped 4G if [[ $RAM_MB -le 2000 ]]; then SWAP_G=$(( (RAM_MB*2+512)/1024 )) elif [[ $RAM_MB -le 8000 ]]; then SWAP_G=$(( (RAM_MB+512)/1024 )) else SWAP_G=4; fi [[ $SWAP_G -lt 1 ]] && SWAP_G=1 fallocate -l "${SWAP_G}G" /swapfile || dd if=/dev/zero of=/swapfile bs=1M count=$((SWAP_G*1024)) chmod 600 /swapfile mkswap /swapfile swapon /swapfile grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab # Less aggressive swapping — prefer RAM, swap only when needed (server tuning) sysctl -w vm.swappiness=10 grep -q '^vm.swappiness' /etc/sysctl.conf || echo 'vm.swappiness=10' >> /etc/sysctl.conf log "Swap on: ${SWAP_G}G (swappiness=10)" fi fi #───────────────────────────────────────────────────────────────────────────── if [[ "$TUNE_PHP" == "yes" ]]; then log "Tune PHP — OPcache + FPM pool (sized to RAM)" PHP_INI_DIR="/etc/php/${PHP_VER}/fpm/conf.d" # OPcache: caches compiled PHP bytecode in RAM -> big speed win. cat > "${PHP_INI_DIR}/99-custom-opcache.ini" <<'EOF' ; Compiled-script cache. Skips re-parsing PHP on every request. opcache.enable=1 opcache.memory_consumption=128 ; MB for cached bytecode opcache.interned_strings_buffer=16 opcache.max_accelerated_files=10000 ; raise if many .php files opcache.revalidate_freq=2 ; sec between file-change checks (prod: higher) opcache.fast_shutdown=1 ; General PHP sanity for web apps upload_max_filesize=64M post_max_size=64M memory_limit=256M max_execution_time=60 EOF # FPM pool: how many PHP workers. Each ~30-50MB. Size to half RAM / 40MB. POOL="/etc/php/${PHP_VER}/fpm/pool.d/www.conf" MAXCHILD=$(( RAM_MB / 2 / 40 )); [[ $MAXCHILD -lt 4 ]] && MAXCHILD=4 if [[ -f "$POOL" ]]; then sed -i \ -e "s/^pm.max_children = .*/pm.max_children = ${MAXCHILD}/" \ -e "s/^pm.start_servers = .*/pm.start_servers = $(( MAXCHILD/4>0?MAXCHILD/4:1 ))/" \ -e "s/^pm.min_spare_servers = .*/pm.min_spare_servers = $(( MAXCHILD/4>0?MAXCHILD/4:1 ))/" \ -e "s/^pm.max_spare_servers = .*/pm.max_spare_servers = $(( MAXCHILD/2>0?MAXCHILD/2:2 ))/" \ "$POOL" log "FPM pm.max_children=${MAXCHILD}" fi systemctl restart php${PHP_VER}-fpm fi #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_DB_BACKUP" == "yes" ]]; then log "Nightly DB backup cron" mkdir -p /var/backups/mysql chmod 700 /var/backups/mysql # Dumps every DB to its own gzip, keeps 7 days. Root uses socket auth. cat > /usr/local/bin/db-backup <<'EOF' #!/usr/bin/env bash set -euo pipefail DEST=/var/backups/mysql STAMP=$(date +%F) for DB in $(mariadb -N -e "SHOW DATABASES" | grep -Ev '^(information_schema|performance_schema|mysql|sys)$'); do mariadb-dump --single-transaction --quick "$DB" | gzip > "${DEST}/${DB}-${STAMP}.sql.gz" done # Rotate: delete dumps older than 7 days find "$DEST" -name '*.sql.gz' -mtime +7 -delete EOF chmod 700 /usr/local/bin/db-backup # Run 03:15 nightly cat > /etc/cron.d/db-backup <<'EOF' 15 3 * * * root /usr/local/bin/db-backup >> /var/log/db-backup.log 2>&1 EOF log "Backups -> /var/backups/mysql (nightly 03:15, 7-day retention)" warn "Manual run: sudo db-backup" fi #───────────────────────────────────────────────────────────────────────────── if [[ "$INSTALL_HEALTHCHECK" == "yes" ]]; then log "Healthcheck command" cat > /usr/local/bin/healthcheck </dev/null)" fi done echo "=== Disk ===" df -h / | awk 'NR==2{print "root "\$5" used ("\$3"/"\$2")"}' echo "=== Memory ===" free -h | awk '/^Mem:/{print "RAM "\$3" / "\$2" used"} /^Swap:/{print "Swap "\$3" / "\$2" used"}' echo "=== Apache config ===" apache2ctl configtest 2>&1 echo "=== Listening ports ===" ss -tlnp 2>/dev/null | awk 'NR>1{print \$4}' | sort -u EOF chmod 755 /usr/local/bin/healthcheck log "Run anytime: sudo healthcheck" fi #───────────────────────────────────────────────────────────────────────────── if [[ "$HARDEN_SSH" == "yes" ]]; then log "Harden SSH (key-only, no root login)" # SAFETY: only proceed if at least one SSH key exists, else you lock yourself out. KEYFOUND=no for h in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do [[ -s "$h" ]] && KEYFOUND=yes done if [[ "$KEYFOUND" != yes ]]; then warn "NO SSH keys found in any authorized_keys — REFUSING to disable password login." warn "Add your key first: ssh-copy-id user@server then re-run with HARDEN_SSH=yes" else cat > /etc/ssh/sshd_config.d/99-hardening.conf <<'EOF' PermitRootLogin no PasswordAuthentication no ChallengeResponseAuthentication no KbdInteractiveAuthentication no MaxAuthTries 3 EOF if sshd -t; then systemctl reload ssh warn "SSH now KEY-ONLY, root login disabled. Keep this session open + test new login before closing!" else rm -f /etc/ssh/sshd_config.d/99-hardening.conf warn "sshd config test failed — hardening reverted, SSH untouched" fi fi fi #───────────────────────────────────────────────────────────────────────────── log "Done. Stack ready." #───────────────────────────────────────────────────────────────────────────── cat </info.php (DELETE after check: rm ${WEB_ROOT}/info.php) Apache : $(apache2 -v | head -1 | awk '{print $3}') (event MPM + PHP-FPM) PHP : $(php -v | head -1 | awk '{print $2}') MariaDB : $(mariadb --version | awk '{print $5}' | tr -d ,) -> connect: sudo mariadb $( command -v node >/dev/null && echo " Node : $(node -v)" ) $( command -v composer>/dev/null && echo " Composer : $(composer --version | awk '{print $3}')" ) $( command -v python3 >/dev/null && echo " Python : $(python3 -V | awk '{print $2}')" ) .htaccess : ENABLED (mod_rewrite + AllowOverride All) Firewall : UFW active (22/80/443) TLS : certbot --apache -d yourdomain.com NEXT: 1. rm ${WEB_ROOT}/info.php 2. Point DNS to this server 3. Run certbot for HTTPS 4. Create app DB: sudo mariadb -e "CREATE DATABASE app; CREATE USER 'app'@'localhost' IDENTIFIED BY 'CHANGEME'; GRANT ALL ON app.* TO 'app'@'localhost';" EOF