From 7d4ae3e2214029ce215d967eee2173011d2371d8 Mon Sep 17 00:00:00 2001 From: RKEUS Date: Mon, 22 Jun 2026 04:01:43 +0000 Subject: [PATCH] Add webserver provisioning + vhost scripts, README, cheatsheet - setup-webserver.sh: idempotent Ubuntu 24.04 LAMP provisioning (Apache event MPM + PHP 8.3-FPM + MariaDB + Node/Python, phpMyAdmin, Composer, Certbot, UFW, Fail2ban; optional components prompted/env-gated) - add-vhost.sh: add an Apache virtual host, optional DB + TLS - CHEATSHEET.md: day-to-day server CLI reference - README.md: setup instructions and env-var matrix --- CHEATSHEET.md | 162 +++++++++++++++++ README.md | 114 ++++++++++++ add-vhost.sh | 160 +++++++++++++++++ setup-webserver.sh | 430 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 866 insertions(+) create mode 100644 CHEATSHEET.md create mode 100644 README.md create mode 100644 add-vhost.sh create mode 100644 setup-webserver.sh diff --git a/CHEATSHEET.md b/CHEATSHEET.md new file mode 100644 index 0000000..989ddae --- /dev/null +++ b/CHEATSHEET.md @@ -0,0 +1,162 @@ +# Web Server CLI Cheatsheet — Ubuntu 24.04 (Apache + PHP-FPM + MariaDB) + +Daily commands for running the server built by `setup-webserver.sh`. + +## Quick status + +```bash +sudo healthcheck # custom: all services, disk, memory, ports +systemctl status apache2 # one service detail +sudo systemctl --failed # anything broken? +htop # live CPU/RAM/process (q to quit) +df -h # disk space +free -h # memory + swap +``` + +## Apache + +```bash +sudo systemctl reload apache2 # apply config, no dropped connections (PREFER) +sudo systemctl restart apache2 # full restart (drops connections) +sudo apache2ctl configtest # check config BEFORE reload — always do this +apache2ctl -v # version + +# Manage sites (vhosts) +sudo a2ensite SITE.conf # enable a site +sudo a2dissite SITE.conf # disable a site +sudo a2enmod rewrite # enable a module +ls /etc/apache2/sites-available/ # all defined sites +ls /etc/apache2/sites-enabled/ # active sites (symlinks) + +# Add a site (your script) +sudo ./add-vhost.sh example.com + +# Logs (live tail, Ctrl+C to stop) +sudo tail -f /var/log/apache2/error.log +sudo tail -f /var/log/apache2/example.com-access.log +``` + +## PHP / PHP-FPM + +```bash +php -v # version +php -m # installed modules +sudo systemctl restart php8.3-fpm # restart after ini changes +php -i | grep opcache # check opcache settings +sudo tail -f /var/log/php8.3-fpm.log # FPM errors + +# Config locations +/etc/php/8.3/fpm/php.ini # main FPM config +/etc/php/8.3/fpm/conf.d/ # drop-in .ini files +/etc/php/8.3/fpm/pool.d/www.conf # worker pool tuning +``` + +## MariaDB / MySQL + +```bash +sudo mariadb # connect as root (socket auth, no password) + +# Inside the mariadb prompt (end each with ;) +SHOW DATABASES; +USE mydb; +SHOW TABLES; +SELECT user, host FROM mysql.user; +\q # quit + +# One-liners from shell +sudo mariadb -e "SHOW DATABASES;" +sudo mariadb mydb < dump.sql # import +sudo mariadb-dump mydb > dump.sql # export single DB +sudo db-backup # your nightly backup, run manually +ls -lh /var/backups/mysql/ # backups + +# Create app DB + user +sudo mariadb -e "CREATE DATABASE app CHARACTER SET utf8mb4; + CREATE USER 'app'@'localhost' IDENTIFIED BY 'CHANGE_ME'; + GRANT ALL ON app.* TO 'app'@'localhost'; FLUSH PRIVILEGES;" +``` + +## Firewall (UFW) + +```bash +sudo ufw status verbose # rules + active? +sudo ufw allow 8080/tcp # open a port +sudo ufw delete allow 8080/tcp # close it +sudo ufw deny from 1.2.3.4 # block an IP +``` + +## Fail2ban (brute-force bans) + +```bash +sudo fail2ban-client status # active jails +sudo fail2ban-client status sshd # banned IPs for SSH +sudo fail2ban-client set sshd unbanip 1.2.3.4 # unban +``` + +## TLS / Certbot + +```bash +sudo certbot --apache -d example.com -d www.example.com # get cert +sudo certbot certificates # list certs + expiry +sudo certbot renew --dry-run # test auto-renew (real renew is automatic) +``` + +## Services (systemd) — the universal pattern + +```bash +sudo systemctl start|stop|restart|reload|status NAME +sudo systemctl enable NAME # start on boot +sudo systemctl disable NAME # don't start on boot +journalctl -u NAME -f # live logs for any service +journalctl -u NAME --since "1 hour ago" +``` + +## Files & permissions (web root) + +```bash +sudo chown -R www-data:www-data /var/www/example.com # web server owns files +sudo find /var/www -type d -exec chmod 755 {} \; # dirs +sudo find /var/www -type f -exec chmod 644 {} \; # files +du -sh /var/www/* # folder sizes +``` + +## Node (if installed) + +```bash +node -v ; npm -v +pm2 start app.js --name myapp # run a node app, kept alive +pm2 list # running apps +pm2 logs myapp +pm2 restart myapp +pm2 startup && pm2 save # survive reboot +``` + +## System maintenance + +```bash +sudo apt update && sudo apt upgrade -y # update packages +sudo apt autoremove # clean unused +sudo reboot +uptime # how long up + load +who # who's logged in +last # login history +``` + +## Logs — where to look when something breaks + +| Problem | Look here | +|---------|-----------| +| Site 500 error | `/var/log/apache2/-error.log` | +| PHP crash | `/var/log/php8.3-fpm.log` | +| DB won't start | `journalctl -u mariadb` | +| Can't SSH in | `journalctl -u ssh` (from console) | +| Service down | `systemctl status ` | +| Anything else | `journalctl -xe` | + +## Survival tips + +- **Always `configtest` before reloading Apache.** Bad config + restart = site down. +- **`reload` over `restart`** when possible — no dropped connections. +- **Keep an SSH session open** when changing SSH/firewall config. Test new login in a *second* terminal before closing the first. +- **`Ctrl+C`** stops a running command (like `tail -f`). **`q`** quits pagers (`less`, `htop`). +- **Tab** autocompletes paths/commands. **↑** recalls last command. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e85717f --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# webserver + +Provisioning + virtual-host scripts for a LAMP-style web server on **Ubuntu 24.04 LTS**. + +Stack: **Apache** (event MPM) + **PHP 8.3-FPM** + **MariaDB** + Node + Python, with +phpMyAdmin (IP-restricted), Composer, Certbot, UFW and Fail2ban. `.htaccess` works out +of the box (`mod_rewrite` + `AllowOverride All`). + +## Contents + +| File | What it does | +|------|--------------| +| [`setup-webserver.sh`](setup-webserver.sh) | One-shot provisioning of a fresh server. Idempotent — safe to re-run. | +| [`add-vhost.sh`](add-vhost.sh) | Add an Apache virtual host (+ optional DB + TLS) for a domain. | +| [`CHEATSHEET.md`](CHEATSHEET.md) | Day-to-day CLI commands for running the server. | + +## Quick start + +On a fresh Ubuntu 24.04 box, as root (or with `sudo`): + +```bash +# 1. Get the scripts +git clone https://git.rkeus.com/rkeus/webserver.git +cd webserver + +# 2. Provision the server (prompts for each optional component) +sudo bash setup-webserver.sh + +# 3. Verify, then DELETE the test page +# http:///info.php +sudo rm /var/www/html/info.php +``` + +### Add a site + +```bash +# Interactive +sudo bash add-vhost.sh + +# Domain as arg, rest prompted +sudo bash add-vhost.sh example.com + +# Domain + web root +sudo bash add-vhost.sh example.com /var/www/example.com +``` + +After DNS points at the server, get HTTPS: + +```bash +sudo certbot --apache -d example.com -d www.example.com +``` + +## Non-interactive / automation + +Both scripts read environment variables so prompts are skipped — good for CI or +unattended runs. + +`setup-webserver.sh` toggles (`yes` | `no`): + +```bash +sudo INSTALL_NODE=no INSTALL_SWAP=yes HARDEN_SSH=no bash setup-webserver.sh +``` + +| Var | Default | Component | +|-----|---------|-----------| +| `INSTALL_PHPMYADMIN` | yes | phpMyAdmin, IP-restricted | +| `INSTALL_NODE` | yes | Node.js + PM2 | +| `INSTALL_REDIS` | yes | Redis + php-redis | +| `INSTALL_CERTBOT` | yes | Certbot (cert requested later) | +| `INSTALL_SWAP` | yes if RAM<4GB | swapfile | +| `INSTALL_DB_BACKUP` | yes | nightly mysqldump cron | +| `TUNE_PHP` | yes | OPcache + FPM pool tuning | +| `INSTALL_HEALTHCHECK` | yes | `healthcheck` command | +| `HARDEN_SSH` | no | key-only SSH (**lockout risk**) | + +Other config (env, with defaults): + +| Var | Default | Meaning | +|-----|---------|---------| +| `SERVER_NAME` | `_` | default vhost ServerName (catch-all) | +| `ADMIN_EMAIL` | `admin@example.com` | certbot contact | +| `TIMEZONE` | `UTC` | system timezone | +| `PMA_ALLOW_IPS` | `127.0.0.1` | IPs/CIDRs allowed to reach phpMyAdmin | +| `NODE_MAJOR` | `22` | NodeSource LTS line | + +`add-vhost.sh` non-interactive: + +```bash +sudo DOMAIN=example.com MAKE_DB=yes RUN_TLS=yes bash add-vhost.sh +``` + +| Var | Default | Meaning | +|-----|---------|---------| +| `DOMAIN` | — (required) | the site domain | +| `WEB_ROOT` | `/var/www/` | document root | +| `ADMIN_EMAIL` | `admin@` | TLS contact | +| `MAKE_DB` | no | create matching MariaDB DB + user | +| `RUN_TLS` | no | request Let's Encrypt cert now (DNS must resolve) | + +When `MAKE_DB=yes`, generated DB credentials are written to +`/root/.db-credentials.txt` (root-only, `chmod 600`). + +## Notes & safety + +- **Run as root.** Both scripts refuse to run otherwise. +- **Tested on Ubuntu 24.04.** Other versions warn and continue. +- **MariaDB root** uses `unix_socket` auth — connect locally with `sudo mariadb`, no password. +- **phpMyAdmin is not public.** Defaults to localhost only; set `PMA_ALLOW_IPS` or use an + SSH tunnel: `ssh -L 8080:localhost:80 user@server` then `http://localhost:8080/phpmyadmin`. +- **`HARDEN_SSH=yes` can lock you out.** It refuses unless an `authorized_keys` already + exists. Keep an SSH session open and test a new login before closing it. +- After provisioning, **delete** `/var/www/html/info.php`. + +See [`CHEATSHEET.md`](CHEATSHEET.md) for the day-to-day command reference. diff --git a/add-vhost.sh b/add-vhost.sh new file mode 100644 index 0000000..55d5c46 --- /dev/null +++ b/add-vhost.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# add-vhost.sh — Add an Apache virtual host on Ubuntu 24.04 +# +# Hybrid: pass args to skip prompts, omit them to be asked. +# +# sudo ./add-vhost.sh # fully interactive +# sudo ./add-vhost.sh example.com # domain set, rest prompted +# sudo ./add-vhost.sh example.com /var/www/ex # domain + root set +# +# Non-interactive (CI / scripted) — set env vars, prompts auto-skip: +# sudo DOMAIN=example.com MAKE_DB=yes RUN_TLS=yes ./add-vhost.sh +# +set -euo pipefail + +PHP_VER="8.3" + +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; } + +[[ $EUID -eq 0 ]] || die "Run as root: sudo bash $0" + +# Prompt only if stdin is a terminal; else rely on env/args (non-interactive). +interactive() { [[ -t 0 ]]; } + +# ask VAR "Question" "default" — sets VAR from env, else arg-passed, else prompt, else default +ask() { + local __var="$1" __q="$2" __def="${3:-}" __cur="${!1:-}" + [[ -n "$__cur" ]] && return # already set (env or earlier arg) + if interactive; then + local __ans + read -rp "$__q${__def:+ [$__def]}: " __ans + printf -v "$__var" '%s' "${__ans:-$__def}" + else + printf -v "$__var" '%s' "$__def" + fi +} + +ask_yn() { # ask_yn VAR "Question" default(yes/no) + 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 +} + +#───────────────────────────────────────────────────────────────────────────── +# Gather inputs: positional args win, then env, then prompt +#───────────────────────────────────────────────────────────────────────────── +DOMAIN="${DOMAIN:-${1:-}}" +WEB_ROOT="${WEB_ROOT:-${2:-}}" + +ask DOMAIN "Domain (e.g. example.com)" +[[ -n "$DOMAIN" ]] || die "Domain required" +# Basic sanity: letters/digits/dots/hyphens only +[[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "Invalid domain: $DOMAIN" + +ask WEB_ROOT "Web root" "/var/www/${DOMAIN}" +ask ADMIN_EMAIL "Admin email (for TLS)" "admin@${DOMAIN}" +ask_yn MAKE_DB "Create matching MySQL database + user?" no +ask_yn RUN_TLS "Request Let's Encrypt TLS now (DNS must point here)?" no + +VHOST_FILE="/etc/apache2/sites-available/${DOMAIN}.conf" + +#───────────────────────────────────────────────────────────────────────────── +log "Web root: ${WEB_ROOT}" +#───────────────────────────────────────────────────────────────────────────── +mkdir -p "$WEB_ROOT" +if [[ ! -f "${WEB_ROOT}/index.html" && -z "$(ls -A "$WEB_ROOT" 2>/dev/null)" ]]; then + echo "

${DOMAIN} works

" > "${WEB_ROOT}/index.html" +fi +chown -R www-data:www-data "$WEB_ROOT" + +#───────────────────────────────────────────────────────────────────────────── +log "Write vhost: ${VHOST_FILE}" +#───────────────────────────────────────────────────────────────────────────── +[[ -f "$VHOST_FILE" ]] && warn "Overwriting existing ${VHOST_FILE}" +cat > "$VHOST_FILE" < + ServerName ${DOMAIN} + ServerAlias www.${DOMAIN} + DocumentRoot ${WEB_ROOT} + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog \${APACHE_LOG_DIR}/${DOMAIN}-error.log + CustomLog \${APACHE_LOG_DIR}/${DOMAIN}-access.log combined + +EOF + +a2ensite -q "${DOMAIN}.conf" +apache2ctl configtest || die "Apache config test failed — fix before reload" +systemctl reload apache2 +log "Site enabled + Apache reloaded" + +#───────────────────────────────────────────────────────────────────────────── +if [[ "$MAKE_DB" == "yes" ]]; then +log "Create database + user" + # DB name from domain: example.com -> example_com + DB_NAME="$(echo "$DOMAIN" | tr '.-' '__')" + DB_USER="${DB_NAME:0:32}" # MariaDB user max 32 chars (10.4+ allows 80, stay safe) + # Generate random password + DB_PASS="$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)" + + mariadb < "$CRED_FILE" </dev/null; then + certbot --apache -d "$DOMAIN" -d "www.${DOMAIN}" \ + -m "$ADMIN_EMAIL" --agree-tos --redirect -n \ + || warn "Certbot failed — check DNS points to this server, retry manually" + else + warn "certbot not installed. Install: apt install certbot python3-certbot-apache" + fi +fi + +#───────────────────────────────────────────────────────────────────────────── +log "Done: ${DOMAIN}" +#───────────────────────────────────────────────────────────────────────────── +cat < %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