Initial commit: wp-engine hardening script, README, and architecture docs

This commit is contained in:
Mike Kell 2026-10-03 13:39:34 -04:00
commit 08fc8f66d0
5 changed files with 1506 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# OS / editor noise
.DS_Store
Thumbs.db
*.swp
*~
.vscode/
.idea/
# Secrets & runtime state — never commit
.engine-settings
.env
*.env
*.log
# Transient
*.tmp

513
README.md Normal file
View File

@ -0,0 +1,513 @@
# SecureServer — `wp-engine.sh`
**Also known as WP-Engine — Multi-Tenant Hardened WordPress Infrastructure.**
A single, self-contained Bash script that takes a **bare-metal server, Proxmox VM, Proxmox LXC, or base container** from an open, unhardened state to a **secure, multi-tenant WordPress hosting platform** with **observability dashboards** — in two commands: `--bootstrap` and `create`.
It eliminates container sprawl by combining a **centralized, tuned MariaDB 11.4 instance** and a **centralized Redis 7 object cache** with completely isolated tenant environments — alongside a host-level security perimeter (hardened high-port SSH, UFW with container-bypass prevention, and Fail2ban) and a full Prometheus + Grafana observability stack.
Work proceeds in two phases: `--bootstrap` hardens the host and stands up the platform, then `create` spins up unlimited WordPress sites — each with its own database, credentials, Redis cache index, and TLS routing.
---
## Table of Contents
1. [Project Overview](#1-project-overview)
2. [Requirements](#2-requirements)
3. [Quick Start](#3-quick-start)
4. [CLI Reference & Options](#4-cli-reference--options)
5. [Step-by-Step: What `--bootstrap` Does](#5-step-by-step-what---bootstrap-does)
6. [Step-by-Step: What `create` Does](#6-step-by-step-what-create-does)
7. [Option Guides](#7-option-guides)
8. [Architecture: Networks, Ports, Layout](#8-architecture-networks-ports-layout)
9. [Credentials & State Files](#9-credentials--state-files)
10. [Monitoring Dashboards](#10-monitoring-dashboards)
11. [Day-2 Operations](#11-day-2-operations)
12. [Security Hardening Summary](#12-security-hardening-summary)
13. [Known Limitations & Post-Install TODOs](#13-known-limitations--post-install-todos)
---
## 1. Project Overview
**Problem:** A freshly provisioned Linux server is open to the internet — SSH brute force, WordPress exploit scanners, open databases, no firewall, no visibility.
**Solution:** `wp-engine.sh` performs two phases:
| Phase | Command | Purpose |
|---|---|---|
| **Phase 1 — Host Hardening & Platform** | `./wp-engine.sh --bootstrap` | Installs packages, container runtime, hardened SSH, UFW firewall, Fail2ban jails, container networks, ingress reverse proxy, and the Grafana/Prometheus monitoring stack. |
| **Phase 2 — Site Provisioning** | `./wp-engine.sh create <slug> <domain> ...` | Creates an isolated WordPress site (container, database, DB user, Redis cache index, TLS route, WP-CLI automated install). |
**Key design properties:**
- **Multi-tenant** — many WordPress sites share one host, one MariaDB instance (isolated DB per site), and one Redis instance (isolated DB index per site).
- **Proxy-agnostic** — choose Nginx Proxy Manager, Traefik v3, or Caddy at bootstrap time.
- **Runtime-agnostic** — Docker (official packages) or Podman (distro packages).
- **Persistent state** — choices are persisted to `/server/websites/.engine-settings`, so subsequent `create` runs need no re-prompting.
- **Automated WordPress install** — `wp core install`, permalinks, and Redis object cache are wired up via WP-CLI containers; no browser wizard required.
Supporting design documents live in [`docs/`](docs/):
- [`docs/Runtime_Architecture_Network_Topology.md`](docs/Runtime_Architecture_Network_Topology.md) — Mermaid diagram of traffic flow, networks, and security layers.
- [`docs/Filesystem_and_Directory_Structure.md`](docs/Filesystem_and_Directory_Structure.md) — Mermaid diagram of `/server/websites/` layout.
---
## 2. Requirements
| Requirement | Detail |
|---|---|
| **OS** | Clean Debian 11/12 or Ubuntu 22.04/24.04 LTS installation (bare-metal, Proxmox VM, or Proxmox LXC) with `apt` and `systemd` (the script calls `apt-get`, `systemctl`, `ufw`). Not compatible with RHEL/Alpine/Windows hosts. |
| **Privileges** | Must be run as `root` (the script exits if `EUID != 0`). |
| **Network** | Outbound internet access to pull packages and container images (Docker Hub, download.docker.com, etc.). |
| **Inbound ports** | `80`/`443` reachable from the internet (for HTTP/HTTPS + ACME TLS challenges), plus your chosen SSH port. |
| **DNS** | `A`/`AAAA` records for each site domain pointing at the server's public IP **before** running `create` (needed for certificate issuance). |
| **Resources** | Recommended ≥ 2 GB RAM (MariaDB buffer pool is pre-set to `2G` in `99-performance.cnf`) and ≥ 20 GB disk. |
---
## 3. Quick Start
```bash
# 0. Copy the script to the target host and make it executable
chmod +x wp-engine.sh
# 1. Harden the host + deploy proxy & monitoring (interactive prompts)
./wp-engine.sh --bootstrap
# 2. Provision your first WordPress site
./wp-engine.sh create teetimeprints teetimeprints.com mkell admin@teetimeprints.com
```
**Fully unattended bootstrap** (no prompts — ideal for cloud-init / Ansible / automation):
```bash
# Docker + Caddy on custom SSH port 48222
./wp-engine.sh --bootstrap --ssh-port 48222 --engine docker --proxy caddy
# Docker + Traefik v3 on custom SSH port 52822
./wp-engine.sh --bootstrap --ssh-port 52822 --engine docker --proxy traefik
# Podman + Nginx Proxy Manager (NPM) on custom SSH port 48222
./wp-engine.sh --bootstrap --ssh-port 48222 --engine podman --proxy npm
```
**Install system-wide** so it can be run from anywhere:
```bash
install -m 0755 wp-engine.sh /usr/local/bin/wp-engine.sh
# ...or download it directly:
curl -fsSL https://your-repo/wp-engine.sh -o /usr/local/bin/wp-engine.sh
chmod +x /usr/local/bin/wp-engine.sh
```
> [!WARNING]
> **Do not close your current SSH terminal** after running `--bootstrap`. Open a secondary terminal and verify that you can connect over your chosen port before disconnecting:
> ```bash
> ssh -p <PORT> user@<HOST_IP>
> ```
---
## 4. CLI Reference & Options
```
Usage:
Interactive Bootstrap:
./wp-engine.sh --bootstrap
Unattended Bootstrap:
./wp-engine.sh --bootstrap --ssh-port <port> --engine <docker|podman> --proxy <npm|traefik|caddy>
Provision Site:
./wp-engine.sh create <slug> <domain> [admin_user] [admin_email]
Examples:
./wp-engine.sh --bootstrap --ssh-port 48222 --engine docker --proxy caddy
./wp-engine.sh create teetimeprints teetimeprints.com mkell admin@teetimeprints.com
```
### Actions
| Action | Description |
|---|---|
| `--bootstrap` | Runs the 6-step host provisioning sequence, then deploys the monitoring stack. Prompts for any option not supplied via flags. |
| `create <slug> <domain> [admin_user] [admin_email]` | Provisions a new WordPress site. If the ingress proxy isn't running yet, bootstrap runs automatically; if the shared DB/cache core isn't up, it is initialized automatically. |
| *(no/unknown action)* | Prints the usage text above and exits with code 1. |
### Flags (used with `--bootstrap`)
| Flag | Values | Default | Effect |
|---|---|---|---|
| `--ssh-port` | `1–65535` | `48222` | Moves SSH off port 22; written to `sshd_config.d/99-hardened.conf`, the `ssh.socket` unit (if present), UFW, and the Fail2ban `sshd` jail. |
| `--engine` | `docker` \| `podman` | `docker` | Container runtime. Docker installs from Docker's official apt repo; Podman installs `podman`, `podman-compose`, `podman-docker` from distro repos. |
| `--proxy` | `npm` \| `traefik` \| `caddy` | `npm` | Ingress reverse proxy. Determines the proxy compose file, extra firewall openings, TLS automation, and how sites get routed. |
> **Note:** Flags are read before the persisted state is loaded, so they take precedence for that run and are saved into `/server/websites/.engine-settings`. Subsequent `create` commands reuse the saved choices without prompting.
### `create` arguments
| Position | Argument | Default | Description |
|---|---|---|---|
| 1 | `slug` | *(required)* | Directory/container name, e.g. `teetimeprints`. Hyphens are converted to underscores for DB identifiers. |
| 2 | `domain` | *(required)* | Public FQDN, e.g. `teetimeprints.com`. Used for TLS routing and the WP site URL. |
| 3 | `admin_user` | `admin` | WordPress administrator username. |
| 4 | `admin_email` | `admin@<domain>` | WordPress administrator e-mail. |
Additional `create` examples:
```bash
# Minimal — admin user defaults to `admin`, e-mail to admin@myblog.com
./wp-engine.sh create myblog myblog.com
# Custom admin credentials
./wp-engine.sh create teetimeprints teetimeprints.com mkell admin@teetimeprints.com
```
---
## 5. Step-by-Step: What `--bootstrap` Does
### Phase A — Bare-host provisioning & hardening (6 steps)
**`[1/6] Installing Host Packages`**
Installs `apt-transport-https ca-certificates curl gnupg lsb-release ufw fail2ban nftables openssl jq`.
**`[2/6] Setting up Container Engine`**
- *Docker:* adds Docker's official GPG key + apt repo, installs `docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, `docker-compose-plugin`, enables the service (skipped if Docker already present).
- *Podman:* `apt-get install podman podman-compose podman-docker`, enables the service.
**`[3/6] Hardening SSH on Port <port>`**
Writes `/etc/ssh/sshd_config.d/99-hardened.conf`:
```
Port <chosen port>
PermitRootLogin prohibit-password
PasswordAuthentication yes
X11Forwarding no
MaxAuthTries 4
ClientAliveInterval 300
ClientAliveCountMax 2
```
Also re-points `ssh.socket`'s `ListenStream` to the new port when socket activation is in use, then restarts SSH.
**`[4/6] Configuring Firewall & Isolation Rules`**
- UFW policy: **deny incoming / allow outgoing**.
- Allowed inbound: SSH port, `80`, `443`, `3000` (Grafana), plus `81` if NPM or `8080` if Traefik.
- For Docker, appends a **`DOCKER-USER` chain** to `/etc/ufw/after.rules` so UFW rules actually apply to container traffic (blocks direct SYN scans into `172.16.0.0/12`, rate-limited logging).
- `ufw --force enable && ufw reload`.
**`[5/6] Scaffolding Networks & Fail2ban Jails`**
- Creates the directory skeleton (`/server/websites/{proxy,core,template,sites,monitoring}`).
- Creates three external container networks: `proxy-net`, `shared-backend`, `monitoring-net`.
- Writes Fail2ban jails:
- **`sshd`** — your SSH port, `maxretry 3`, `findtime 600`, `bantime 3600`, `banaction ufw`.
- **`ingress-wordpress`** — watches `/server/websites/proxy/logs/*.log` for brute force on `/wp-login.php` and `/xmlrpc.php`, plus scans for `.env`, `.git`, `wp-config.php`, `eval-stdin.php`; `maxretry 4`, `bantime 86400` (24 h), `banaction ufw`.
- Enables and restarts Fail2ban.
**`[6/6] Launching Ingress Proxy`** — writes and starts the proxy compose file (differences per proxy are covered in [Option Guides](#7-option-guides)), then deploys monitoring.
### Phase B — Observability stack (`deploy_monitoring`)
Deploys `/server/websites/monitoring/docker-compose.yml` on the isolated `monitoring-net`:
| Service | Image | Purpose | Exposure |
|---|---|---|---|
| `prometheus` | `prom/prometheus:v2.54.0` | Time-series DB; scrapes node_exporter + cAdvisor every 15 s | internal |
| `node_exporter` | `prom/node-exporter:v1.8.2` | Host CPU/RAM/disk metrics (`/proc`, `/sys`, `/` mounted read-only) | internal |
| `cadvisor` | `gcr.io/cadvisor/cadvisor:v0.49.1` | Per-container cgroup resource usage | internal |
| `grafana` | `grafana/grafana:11.2.0` | Dashboards; Prometheus datasource auto-provisioned; sign-up disabled | **`:3000`** |
A random Grafana admin password is generated (`openssl rand`) and appended to the state file as `GRAFANA_ADMIN_PASS`.
---
## 6. Step-by-Step: What `create` Does
`./wp-engine.sh create <slug> <domain> [admin_user] [admin_email]`
1. **Load state** — reads `/server/websites/.engine-settings` (runs bootstrap prompts only if it doesn't exist yet).
2. **Auto-heal** — if no `ingress_proxy` container is running, bootstrap runs automatically; if `core_mariadb` is missing, the shared core is initialized.
3. **Allocate resources**
- Next Redis DB index from `NEXT_REDIS_INDEX` (incremented in `core/.env`).
- DB name `wp_<slug>` and DB user `usr_<slug>` (hyphens → underscores).
- Random DB password and random WordPress admin password (`openssl rand`).
4. **Create the database** — `CREATE DATABASE`, `CREATE USER`, `GRANT ALL` scoped to that site's DB only, executed inside `core_mariadb`.
5. **Write site files**
- `config/wordpress.ini` — PHP limits: `memory_limit 512M`, `upload_max_filesize/post_max_size 128M`, `max_execution_time 300`, `max_input_vars 5000`, OPcache enabled (256 MB, 20 000 files).
- `docker-compose.yml` — `wordpress:6.7-apache` container attached to `proxy-net` **and** `shared-backend`, with `WORDPRESS_CONFIG_EXTRA` injecting:
- Redis connection (`core_redis`, port 6379, per-site password, per-site DB index, per-site cache key salt)
- `DISALLOW_FILE_EDIT` (no theme/plugin editing from wp-admin)
- `FORCE_SSL_ADMIN`
- `X-Forwarded-Proto` HTTPS detection behind the proxy
- Traefik routing labels (only when `--proxy traefik`)
6. **Start the site** — `chown 33:33` on the docroot, then `compose up -d`.
7. **Wire up routing**
- *Caddy:* appends a `domain { reverse_proxy slug_wp:80 ... }` block to the `Caddyfile` and hot-reloads Caddy.
- *Traefik:* handled by the labels from step 5 (router on `websecure`, cert resolver `myresolver`).
- *NPM:* prints a reminder — create the Proxy Host manually in the NPM UI.
8. **Automated WordPress bootstrap (WP-CLI container)**
- Waits for `wp-config.php` to appear in the docroot.
- `wp core install --url=https://<domain> --admin_user=... --admin_password=... --admin_email=... --skip-email`
- `wp rewrite structure '/%postname%/' --hard`
- `wp plugin install redis-cache --activate` → `wp redis enable`
- `chown -R 33:33 data/wp/wp-content`
9. **Prints a summary** — domain, admin user, **admin password (shown only once — copy it now)**, DB name, DB user, Redis index, directory path, plus any proxy-specific next step.
---
## 7. Option Guides
### 7.1 Container runtime
| | **Docker** (default) | **Podman** |
|---|---|---|
| Install source | Docker official apt repo (`docker-ce` + compose plugin) | Distro repos (`podman`, `podman-compose`, `podman-docker`) |
| Compose command | `docker compose` | `podman-compose` |
| UFW integration | Gets the `DOCKER-USER` after.rules patch (required, since Docker bypasses UFW by default) | Not applied (Podman networking works differently with nftables/UFW) |
| Root | Script still requires root for host hardening | Same — host services are hardened system-wide |
### 7.2 Ingress proxy
| | **NPM** (default) | **Traefik v3** | **Caddy** |
|---|---|---|---|
| Image | `jc21/nginx-proxy-manager` | `traefik:v3.1` | `caddy:2-alpine` |
| Management | Web UI on **`:81`** (opened in UFW) | Docker labels + insecure API dashboard on **`:8080`** (opened in UFW) | Text `Caddyfile` |
| TLS certificates | Requested per host in the UI (Let's Encrypt) | ACME **TLS-ALPN challenge** via `myresolver` labels | **Fully automatic** Let's Encrypt |
| Site onboarding | **Manual**: create Proxy Host → `https://domain` → forward to `<slug>_wp:80` | **Automatic**: labels emitted by `create` | **Automatic**: `create` appends a site block + reloads |
| Access log | bind-mounted `proxy/logs/` | `proxy/logs/access.log` (`--accesslog`) | `proxy/logs/access.log` via `log {}` block |
| Extra ports opened | `81` | `8080` | none |
| Best for | Users who want a GUI | Label-driven, declarative setups | Minimal, zero-touch SSL |
The proxy choice affects the generated `proxy/docker-compose.yml`, the UFW allow rules, and how `create` wires routing — all persisted to the state file.
### 7.3 SSH port
Any non-standard port (default `48222`). Applied consistently in four places: `sshd_config.d`, `ssh.socket`, the UFW allow rule, and the Fail2ban `sshd` jail — so ban actions target the right port.
---
## 8. Architecture: Networks, Ports, Layout
High-level traffic flow (see `docs/` for the full Mermaid diagrams):
```text
Internet / WAN
│
▼
┌──────────────────────────────────┐
│ Host Firewall (UFW / nftables) │
│ + Fail2ban Bare-Host Jails │
└─────────────────┬────────────────┘
│ Ports 80 / 443
▼
┌──────────────────────────────────┐
│ Ingress: Caddy / Traefik / NPM │ ── Access Logs ──► Host Fail2ban
└──────────────┬───────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ WordPress │ │ Observability│
│ - PHP 8.3 │ │ - Grafana │ (Port 3000)
│ - WP-CLI │ │ - Prometheus│
│ - OPcache │ │ - Node Exp │
└──────┬───────┘ │ - cAdvisor │
│ └──────────────┘
┌────┴────────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ MariaDB │ │ Redis │
│ (Shared │ │ (Shared │
│ Core) │ │ Cache) │
└───────────┘ └───────────┘
```
### Networks
| Network | Scope | Members |
|---|---|---|
| `proxy-net` | external | ingress proxy + all WordPress containers (public-facing side) |
| `shared-backend` | external, isolated | all WordPress containers + `core_mariadb` + `core_redis` (no host ports published) |
| `monitoring-net` | external, isolated | Prometheus, node_exporter, cAdvisor, Grafana |
### Ports
| Port | Service | Firewall |
|---|---|---|
| 22 → **48222** (default) | SSH | allowed (custom port) |
| 80 / 443 | Ingress proxy (HTTP/HTTPS + ACME) | allowed |
| 81 | Nginx Proxy Manager admin UI | allowed **only if** `--proxy npm` |
| 8080 | Traefik dashboard | allowed **only if** `--proxy traefik` |
| 3000 | Grafana | allowed |
| 3306 / 6379 | MariaDB / Redis | **not published** — container network only |
### Filesystem layout (`/server/websites/`)
```
/server/websites/
├── .engine-settings # Persisted choices: ENGINE, PROXY_TYPE, SSH_PORT, GRAFANA_ADMIN_PASS
├── core/ # Shared data tier
│ ├── .env # DB_ROOT_PASSWORD, REDIS_PASSWORD, NEXT_REDIS_INDEX (chmod 600)
│ ├── docker-compose.yml # core_mariadb (MariaDB 11.4) + core_redis (Redis 7)
│ ├── config/mariadb/99-performance.cnf
│ └── data/{mariadb,redis}/
├── proxy/ # Ingress tier
│ ├── docker-compose.yml # ingress_proxy container
│ ├── Caddyfile # (Caddy only) auto-appended per site
│ ├── letsencrypt/ # acme.json / certs
│ └── logs/access.log # scanned by Fail2ban `ingress-wordpress`
├── monitoring/ # Observability tier
│ ├── docker-compose.yml # prometheus, node_exporter, cadvisor, grafana
│ ├── prometheus/prometheus.yml
│ └── grafana/provisioning/datasources/prometheus.yaml
├── sites/<slug>/ # One directory per WordPress tenant
│ ├── docker-compose.yml # <slug>_wp container (proxy-net + shared-backend)
│ ├── config/wordpress.ini # OPcache & upload limits
│ └── data/wp/ # Full WordPress docroot (uid 33)
└── template/ # Reserved for site templates
```
Host-level configs written by the script:
- `/etc/ssh/sshd_config.d/99-hardened.conf`
- `/etc/ufw/after.rules` (DOCKER-USER protection block)
- `/etc/fail2ban/jail.d/sshd.local`
- `/etc/fail2ban/jail.d/ingress-wordpress.local`
- `/etc/fail2ban/filter.d/ingress-wordpress.conf`
---
## 9. Credentials & State Files
| Secret | Where it lives | Notes |
|---|---|---|
| Engine / proxy / SSH port | `/server/websites/.engine-settings` | plain, non-secret |
| Grafana admin password | `/server/websites/.engine-settings` → `GRAFANA_ADMIN_PASS` | login: `admin` @ `http://<host>:3000` |
| MariaDB **root** password | `/server/websites/core/.env` → `DB_ROOT_PASSWORD` | `chmod 600`; used for creating per-site DBs |
| Redis password | `/server/websites/core/.env` → `REDIS_PASSWORD` | injected into every site's `wp-config` |
| Per-site DB password | injected into the site's compose environment | random per site |
| **WordPress admin password** | **printed to stdout only, at the end of `create`** | **not persisted anywhere — save it immediately** |
| Redis DB index | `core/.env` → `NEXT_REDIS_INDEX` counter | one index allocated per site (0, 1, 2, …) |
> ⚠️ Re-running `--bootstrap` appends a **new** `GRAFANA_ADMIN_PASS` line; when the state file is sourced, the **last** line wins, effectively rotating the Grafana password.
---
## 10. Monitoring Dashboards
After bootstrap:
1. Open `http://<HOST_IP>:3000` (Grafana).
2. Log in with `admin` / the password generated during bootstrap and saved in the state file:
```bash
grep GRAFANA_ADMIN_PASS /server/websites/.engine-settings
```
3. The **Prometheus** datasource is pre-provisioned and set as default. Import dashboards via **Dashboards → Import** — recommended IDs:
| ID | Dashboard | Provides |
|---|---|---|
| `1860` | Node Exporter Full | Real-time CPU, RAM, disk I/O, network throughput, load averages |
| `14282` | cAdvisor Docker Monitoring | Per-container resource usage; spot CPU/RAM throttling across tenant sites |
Data collected:
- **node_exporter** → host CPU, memory, disk I/O, network.
- **cAdvisor** → per-container CPU/memory of every WordPress, DB, proxy, and monitoring container.
- **Prometheus** → 15 s scrape interval, persistent `prometheus_data` volume.
---
## 11. Day-2 Operations
```bash
# Add another site (no prompts — state already saved)
./wp-engine.sh create kellcreations kellcreations.com admin admin@kellcreations.com
# Check running containers
docker ps # or: podman ps
# Site lifecycle
cd /server/websites/sites/<slug>
docker compose up -d / stop / restart / logs -f
# Core stack & proxy
cd /server/websites/core && docker compose ps
cd /server/websites/proxy && docker compose logs -f ingress
# WP-CLI inside a site (one-off)
docker run --rm --user 33:33 --network shared-backend \
-v /server/websites/sites/<slug>/data/wp:/var/www/html wordpress:cli wp plugin list
# Logs for a specific site
docker logs -f <slug>_wp # or: podman logs -f <slug>_wp
# Unban an IP address
fail2ban-client set ingress-wordpress unbanip <IP_ADDRESS>
# Security status
ufw status verbose
fail2ban-client status
fail2ban-client status ingress-wordpress
fail2ban-client status sshd
fail2ban-client get ingress-wordpress banned
```
**Backups** (not automated by the script — recommended):
- `/server/websites/core/data/mariadb/` — all site databases (stop MariaDB or use `mariadb-dump` for consistency).
- `/server/websites/sites/*/data/wp/` — WordPress docroots (uploads, themes, plugins).
- `/server/websites/core/.env` and `.engine-settings` — credentials/state.
---
## 12. Security Hardening Summary
| Layer | Control |
|---|---|
| **Access** | SSH moved off port 22; root login limited to key auth (`PermitRootLogin prohibit-password`); `MaxAuthTries 4`; X11 forwarding off; idle timeout via `ClientAliveInterval`/`ClientAliveCountMax` |
| **Perimeter** | UFW default-deny inbound; explicit allow-list (SSH, 80, 443, 3000, +81/8080 per proxy) |
| **Container escape path** | `DOCKER-USER` nftables chain in `/etc/ufw/after.rules` so UFW governs Docker-bridged traffic; direct scans into RFC1918 container ranges are logged + dropped |
| **Brute force** | Fail2ban `sshd` jail (3 attempts → 1 h ban) and `ingress-wordpress` jail (4 attempts on `wp-login.php`/`xmlrpc.php`/sensitive-file scans → 24 h ban via UFW), fed by proxy access logs |
| **WordPress** | `DISALLOW_FILE_EDIT`, `FORCE_SSL_ADMIN`, HTTPS detection behind proxy, randomized admin/DB passwords, per-site scoped DB grants (no shared credentials) |
| **Secrets** | Root/Redis passwords generated with `openssl rand`; `core/.env` set to `chmod 600`; Grafana sign-up disabled |
| **Isolation** | Each site gets its own DB, DB user, Redis index, cache key salt, docroot, and compose project — on a shared-but-segmented backend network with **no published host ports** |
| **Observability** | Centralized access logs → Fail2ban; Prometheus metrics for capacity/anomaly detection |
---
## 13. Known Limitations & Post-Install TODOs
Behaviors of the current script — review and adjust to your threat model:
1. **NPM requires a manual step** — after `create`, log in to `http://<host>:81` (NPM default login) and add a Proxy Host forwarding `https://<domain>` → `<slug>_wp:80` with an SSL certificate. Change the NPM default password immediately.
2. **Traefik ACME email is a placeholder** — the generated compose file hardcodes `admin@example.com`. Edit `/server/websites/proxy/docker-compose.yml` before the first certificate request.
3. **Traefik dashboard is exposed** — `--api.insecure=true` on `:8080` and UFW allows it publicly. Restrict or close it if you don't need it.
4. **NPM admin UI (`:81`) is exposed publicly** when `--proxy npm` is selected. Consider restricting it to your IP.
5. **Grafana (`:3000`) is served over plain HTTP** with no TLS and is firewall-open. Put it behind the proxy with HTTPS, or restrict the firewall rule to your IP.
6. **`PasswordAuthentication yes`** remains enabled in the SSH hardening block — convenient for first-time migration, but switch to keys-only (`no`) once your key is installed.
7. **Single MariaDB/Redis instance** — `innodb_buffer_pool_size = 2G` is a fixed default; tune `core/config/mariadb/99-performance.cnf` for smaller/larger hosts.
8. **Distro scope** — apt/systemd only. Non-Debian hosts need manual adaptation of steps 1 and 4.
9. **No automated backups or WAF** — add ModSecurity/Cloudflare or scheduled backups per your requirements.
10. **WP admin password is ephemeral** — captured only in the terminal output of `create`; store it in your password manager right away.
---
## Repository Contents
```
wp-engine.sh # The installer/provisioner script (executable)
docs/Runtime_Architecture_Network_Topology.md # Architecture & network Mermaid diagram
docs/Filesystem_and_Directory_Structure.md # Filesystem layout Mermaid diagram
README.md # This file
```

View File

@ -0,0 +1,99 @@
# Filesystem & Directory Structure
```mermaid
graph TD
Root["/server/websites/"]
%% Root Configuration & State Files
StateFile[".engine-settings\n(Persisted: RUNTIME, PROXY_TYPE, SSH_PORT, GRAFANA_PASS)"]
ScriptExec["/usr/local/bin/wp-engine.sh\n(Self-contained installer & provisioner)"]
Root --- StateFile
Root --- ScriptExec
%% Core Shared Stack
subgraph CoreDir["/server/websites/core/"]
CoreEnv[".env (DB_ROOT_PASSWORD, REDIS_PASSWORD, NEXT_REDIS_INDEX)"]
CoreCompose["docker-compose.yml (core_mariadb + core_redis)"]
CoreConfig["config/mariadb/99-performance.cnf"]
CoreDataMariaDB[("data/mariadb/ (Persistent MySQL data)")]
CoreDataRedis[("data/redis/ (Persistent Redis dump)")]
CoreEnv --- CoreCompose
CoreCompose --- CoreConfig
CoreCompose --- CoreDataMariaDB
CoreCompose --- CoreDataRedis
end
Root --> CoreDir
%% Proxy Stack
subgraph ProxyDir["/server/websites/proxy/"]
ProxyCompose["docker-compose.yml (ingress_proxy)"]
ProxyData["data/ (NPM state / Caddy data)"]
ProxyLE["letsencrypt/ (acme.json / certs)"]
ProxyLogs["logs/access.log (Scanned by bare-host Fail2ban)"]
ProxyCaddy["Caddyfile (If Caddy selected; dynamically updated)"]
ProxyCompose --- ProxyData
ProxyCompose --- ProxyLE
ProxyCompose --- ProxyLogs
ProxyCompose --- ProxyCaddy
end
Root --> ProxyDir
%% Monitoring Stack
subgraph MonDir["/server/websites/monitoring/"]
MonCompose["docker-compose.yml (Prometheus, Node Exp, cAdvisor, Grafana)"]
PromConfig["prometheus/prometheus.yml"]
GrafanaProv["grafana/provisioning/datasources/prometheus.yaml"]
MonData["prometheus_data / grafana_data (Named Volumes)"]
MonCompose --- PromConfig
MonCompose --- GrafanaProv
MonCompose --- MonData
end
Root --> MonDir
%% Template Stack
subgraph TemplateDir["/server/websites/template/"]
SiteTemplate["docker-compose.site.yml (Standard WP Compose definition)"]
end
Root --> TemplateDir
%% Tenant Sites
subgraph SitesDir["/server/websites/sites/"]
subgraph SiteA["mysite/"]
SiteAEnv[".env (Scoped DB user, password, Redis DB index)"]
SiteACompose["docker-compose.yml"]
SiteAConfig["config/wordpress.ini (OPcache, upload limits)"]
SiteAData[("data/wp/ (Full WordPress document root)")]
SiteAEnv --- SiteACompose
SiteACompose --- SiteAConfig
SiteACompose --- SiteAData
end
subgraph SiteB["teetimeprints/"]
SiteBCompose["... (Identical isolated structure)"]
end
end
Root --> SitesDir
%% Host-level configurations
subgraph HostConfig["Host-Level Configs (/etc/)"]
F2BJail["/etc/fail2ban/jail.d/ingress-wordpress.local"]
F2BFilter["/etc/fail2ban/filter.d/ingress-wordpress.conf"]
UFWChain["/etc/ufw/after.rules (DOCKER-USER isolation chain)"]
SSHDDropin["/etc/ssh/sshd_config.d/99-hardened.conf"]
end
ScriptExec -.->|Deploys & Controls| HostConfig
%% Styling
classDef dir fill:#eceff1,stroke:#455a64,stroke-width:2px,color:#263238;
classDef compose fill:#e1f5fe,stroke:#0288d1,stroke-width:1px,color:#01579b;
classDef data fill:#fff8e1,stroke:#ffa000,stroke-width:1px,color:#ff6f00;
class Root,CoreDir,ProxyDir,MonDir,TemplateDir,SitesDir,SiteA,SiteB dir;
class CoreCompose,ProxyCompose,MonCompose,SiteTemplate,SiteACompose compose;
class CoreDataMariaDB,CoreDataRedis,SiteAData,MonData data;
```

View File

@ -0,0 +1,84 @@
# Runtime Architecture & Network Topology
```mermaid
flowchart TD
%% Internet & External Ingress
User([WAN / Client Traffic]) -->|Port 80 / 443| IngressRule
Attacker([Malicious Actor / Brute Force]) -.->|wp-login / xmlrpc / exploits| IngressRule
%% Bare Host Hardening & Security Layer
subgraph BareHost["Bare Proxmox / Debian Host"]
IngressRule["Host Firewall: nftables / UFW\n(Deny All Incoming except 80, 443, 3000, 48222)"]
SSHD["Hardened OpenSSH\n(Port 48222)"]
F2B["Fail2ban Engine\n(jail: ingress-wordpress, sshd)"]
LogWatcher[("/server/websites/proxy/logs/*.log")]
IngressRule -->|Port 48222| SSHD
LogWatcher -->|Inspects Access Logs| F2B
F2B -->|Bans IP via UFW / nftables| IngressRule
end
%% Ingress Proxy Tier
subgraph NetProxy["Docker / Podman Network: proxy-net (External)"]
IngressProxy["Ingress Reverse Proxy\n(Caddy / Traefik v3 / NPM)"]
IngressRule -->|Forward 80/443| IngressProxy
IngressProxy -.->|Writes Client Access Logs| LogWatcher
end
%% WordPress Multi-Tenant Tier
subgraph NetBackend["Docker / Podman Network: shared-backend (Isolated)"]
subgraph Tenant1["Tenant: Site 1 (e.g. teetimeprints)"]
WP1["WordPress 6.7 Container\n(PHP 8.3 / Apache)"]
RedisDropin1["Redis Object Cache Drop-in\n(Key Salt: teetimeprints_)"]
WP1 --- RedisDropin1
end
subgraph Tenant2["Tenant: Site N (e.g. kellcreations)"]
WPN["WordPress Container\n(PHP 8.3 / Apache)"]
RedisDropinN["Redis Object Cache Drop-in\n(Key Salt: siteN_)"]
WPN --- RedisDropinN
end
%% Centralized Core Data Tier
subgraph CoreData["Centralized Core Stack"]
CoreDB[("core_mariadb\n(MariaDB 11.4)\n- Isolated DB per site\n- Scoped DB user per site\n- Buffer Pool: 2GB")]
CoreRedis[("core_redis\n(Redis 7 Alpine)\n- DB Index 0..15 per site\n- Maxmemory: 512MB / LRU")]
end
WP1 -->|MySQL: Port 3306| CoreDB
WP1 -->|TCP: Port 6379 / DB 0| CoreRedis
WPN -->|MySQL: Port 3306| CoreDB
WPN -->|TCP: Port 6379 / DB N| CoreRedis
end
%% Proxy to WP App Connections
IngressProxy -->|Reverse Proxy / HTTPS Term| WP1
IngressProxy -->|Reverse Proxy / HTTPS Term| WPN
%% Observability Stack
subgraph NetMon["Docker / Podman Network: monitoring-net (Isolated)"]
NodeExp["node_exporter:9100\n(Host CPU, RAM, Disk I/O)"]
CAdvisor["cadvisor:8080\n(Per-container cgroup usage)"]
Prometheus[("prometheus:9090\n(Time Series DB)")]
Grafana["grafana:3000\n(Pre-provisioned Dashboards)"]
NodeExp -->|Scraped| Prometheus
CAdvisor -->|Scraped| Prometheus
Prometheus -->|Datasource| Grafana
end
IngressRule -->|Port 3000| Grafana
%% Styling
classDef security fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#b71c1c;
classDef proxy fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#1b5e20;
classDef app fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:#0d47a1;
classDef db fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100;
classDef monitor fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px,color:#4a148c;
class F2B,IngressRule,SSHD security;
class IngressProxy proxy;
class WP1,WPN app;
class CoreDB,CoreRedis db;
class Grafana,Prometheus,NodeExp,CAdvisor monitor;
```

794
wp-engine.sh Normal file
View File

@ -0,0 +1,794 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="/server/websites"
PROXY_DIR="${BASE_DIR}/proxy"
CORE_DIR="${BASE_DIR}/core"
TEMPLATE_DIR="${BASE_DIR}/template"
SITES_DIR="${BASE_DIR}/sites"
MONITORING_DIR="${BASE_DIR}/monitoring"
STATE_FILE="${BASE_DIR}/.engine-settings"
PROXY_NETWORK="proxy-net"
BACKEND_NETWORK="shared-backend"
MONITORING_NETWORK="monitoring-net"
if [ "$EUID" -ne 0 ]; then
echo "[!] Error: Root privileges required." >&2
exit 1
fi
# ==============================================================================
# PROMPT & CONFIGURATION HELPERS
# ==============================================================================
prompt_config() {
if [ -z "${SSH_PORT_ARG:-}" ]; then
read -rp "Enter non-standard SSH port [Default: 48222]: " SSH_PORT_ARG
SSH_PORT_ARG=${SSH_PORT_ARG:-48222}
fi
if [ -z "${ENGINE_ARG:-}" ]; then
echo "Select Container Runtime:"
echo " 1) Docker"
echo " 2) Podman"
read -rp "Choice [1-2, Default: 1]: " engine_choice
case "${engine_choice}" in
2) ENGINE_ARG="podman" ;;
*) ENGINE_ARG="docker" ;;
esac
fi
if [ -z "${PROXY_ARG:-}" ]; then
echo "Select Ingress Proxy:"
echo " 1) Nginx Proxy Manager (NPM - Web UI on :81)"
echo " 2) Traefik v3 (Label discovery)"
echo " 3) Caddy (Automated SSL & lightweight Caddyfile)"
read -rp "Choice [1-3, Default: 1]: " proxy_choice
case "${proxy_choice}" in
2) PROXY_ARG="traefik" ;;
3) PROXY_ARG="caddy" ;;
*) PROXY_ARG="npm" ;;
esac
fi
mkdir -p "${BASE_DIR}"
cat <<EOF > "${STATE_FILE}"
ENGINE="${ENGINE_ARG}"
PROXY_TYPE="${PROXY_ARG}"
SSH_PORT="${SSH_PORT_ARG}"
EOF
}
load_state() {
if [ -f "${STATE_FILE}" ]; then
# shellcheck source=/dev/null
source "${STATE_FILE}"
else
echo "[!] System uninitialized. Running setup prompts..."
prompt_config
# shellcheck source=/dev/null
source "${STATE_FILE}"
fi
}
compose_exec() {
local dir="$1"
shift
if [ "${ENGINE}" = "podman" ]; then
(cd "${dir}" && podman-compose "$@")
else
(cd "${dir}" && docker compose "$@")
fi
}
cli_exec() {
if [ "${ENGINE}" = "podman" ]; then
podman "$@"
else
docker "$@"
fi
}
# ==============================================================================
# 1. BARE-HOST PROVISIONING & HARDENING
# ==============================================================================
run_host_bootstrap() {
echo "=========================================================="
echo " [1/6] Installing Host Packages"
echo "=========================================================="
apt-get update -y
apt-get install -y apt-transport-https ca-certificates curl gnupg \
lsb-release ufw fail2ban nftables openssl jq
echo "=========================================================="
echo " [2/6] Setting up Container Engine: ${ENGINE}"
echo "=========================================================="
if [ "${ENGINE}" = "podman" ]; then
apt-get install -y podman podman-compose podman-docker
systemctl enable --now podman
else
if ! command -v docker &>/dev/null; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg" | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
fi
fi
echo "=========================================================="
echo " [3/6] Hardening SSH on Port ${SSH_PORT}"
echo "=========================================================="
if systemctl is-active --quiet ssh.socket; then
sed -i -E "s/^ListenStream=.*/ListenStream=${SSH_PORT}/" /lib/systemd/system/ssh.socket || true
systemctl daemon-reload
systemctl restart ssh.socket
fi
mkdir -p /etc/ssh/sshd_config.d/
cat <<EOF > /etc/ssh/sshd_config.d/99-hardened.conf
Port ${SSH_PORT}
PermitRootLogin prohibit-password
PasswordAuthentication yes
X11Forwarding no
MaxAuthTries 4
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
systemctl restart ssh || systemctl restart sshd
echo "=========================================================="
echo " [4/6] Configuring Firewall & Isolation Rules"
echo "=========================================================="
ufw default deny incoming
ufw default allow outgoing
ufw allow "${SSH_PORT}"/tcp comment 'Hardened SSH'
ufw allow 80/tcp comment 'HTTP Ingress'
ufw allow 443/tcp comment 'HTTPS Ingress'
[ "${PROXY_TYPE}" = "npm" ] && ufw allow 81/tcp comment 'NPM Admin'
[ "${PROXY_TYPE}" = "traefik" ] && ufw allow 8080/tcp comment 'Traefik Dashboard'
ufw allow 3000/tcp comment 'Grafana Dashboard'
if [ "${ENGINE}" = "docker" ] && ! grep -q "BEGIN UFW AND DOCKER" /etc/ufw/after.rules; then
cat <<'EOF' >> /etc/ufw/after.rules
# ================================
# BEGIN UFW AND DOCKER PROTECTION
# ================================
*filter
:ufw-user-forward - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16
-A DOCKER-USER -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j RETURN
-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP
COMMIT
# ================================
# END UFW AND DOCKER PROTECTION
# ================================
EOF
fi
ufw --force enable
ufw reload
echo "=========================================================="
echo " [5/6] Scaffolding Networks & Fail2ban Jails"
echo "=========================================================="
mkdir -p "${PROXY_DIR}/logs" "${CORE_DIR}/data/mariadb" "${CORE_DIR}/data/redis" "${CORE_DIR}/config/mariadb"
mkdir -p "${TEMPLATE_DIR}" "${SITES_DIR}" "${MONITORING_DIR}"
cli_exec network inspect "${PROXY_NETWORK}" >/dev/null 2>&1 || cli_exec network create "${PROXY_NETWORK}"
cli_exec network inspect "${BACKEND_NETWORK}" >/dev/null 2>&1 || cli_exec network create "${BACKEND_NETWORK}"
cli_exec network inspect "${MONITORING_NETWORK}" >/dev/null 2>&1 || cli_exec network create "${MONITORING_NETWORK}"
touch "${PROXY_DIR}/logs/access.log"
cat <<EOF > /etc/fail2ban/jail.d/sshd.local
[sshd]
enabled = true
port = ${SSH_PORT}
filter = sshd
maxretry = 3
findtime = 600
bantime = 3600
banaction = ufw
EOF
cat <<'EOF' > /etc/fail2ban/filter.d/ingress-wordpress.conf
[Definition]
failregex = ^<HOST>.*"(POST|GET) /wp-login\.php.*" (401|403|200)
^<HOST>.*"(POST|GET) /xmlrpc\.php.*" (401|403|200)
^<HOST>.*"GET .*(?:\.env|\.git|wp-config\.php|eval-stdin\.php).*".*
ignoreregex =
EOF
cat <<EOF > /etc/fail2ban/jail.d/ingress-wordpress.local
[ingress-wordpress]
enabled = true
port = http,https
filter = ingress-wordpress
logpath = ${PROXY_DIR}/logs/*.log
maxretry = 4
findtime = 600
bantime = 86400
banaction = ufw
EOF
systemctl enable --now fail2ban
systemctl restart fail2ban
echo "=========================================================="
echo " [6/6] Launching Ingress Proxy: ${PROXY_TYPE}"
echo "=========================================================="
if [ "${PROXY_TYPE}" = "npm" ]; then
mkdir -p "${PROXY_DIR}/data" "${PROXY_DIR}/letsencrypt"
cat <<EOF > "${PROXY_DIR}/docker-compose.yml"
services:
ingress:
image: 'jc21/nginx-proxy-manager:latest'
container_name: ingress_proxy
restart: unless-stopped
ports:
- '80:80'
- '81:81'
- '443:443'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
- ./logs:/data/logs
networks:
- ${PROXY_NETWORK}
networks:
${PROXY_NETWORK}:
external: true
EOF
elif [ "${PROXY_TYPE}" = "traefik" ]; then
mkdir -p "${PROXY_DIR}/letsencrypt"
touch "${PROXY_DIR}/letsencrypt/acme.json"
chmod 600 "${PROXY_DIR}/letsencrypt/acme.json"
cat <<EOF > "${PROXY_DIR}/docker-compose.yml"
services:
ingress:
image: traefik:v3.1
container_name: ingress_proxy
restart: unless-stopped
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entryPoints.web.address=:80"
- "--entryPoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.tlschallenge=true"
- "--certificatesresolvers.myresolver.acme.email=admin@example.com"
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
- "--accesslog=true"
- "--accesslog.filepath=/logs/access.log"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./letsencrypt:/letsencrypt
- ./logs:/logs
networks:
- ${PROXY_NETWORK}
networks:
${PROXY_NETWORK}:
external: true
EOF
elif [ "${PROXY_TYPE}" = "caddy" ]; then
mkdir -p "${PROXY_DIR}/data" "${PROXY_DIR}/config"
touch "${PROXY_DIR}/Caddyfile"
cat <<EOF > "${PROXY_DIR}/docker-compose.yml"
services:
ingress:
image: caddy:2-alpine
container_name: ingress_proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./data:/data
- ./config:/config
- ./logs:/logs
networks:
- ${PROXY_NETWORK}
networks:
${PROXY_NETWORK}:
external: true
EOF
fi
compose_exec "${PROXY_DIR}" up -d
deploy_monitoring
}
# ==============================================================================
# 2. GRAFANA & OBSERVABILITY DEPLOYMENT
# ==============================================================================
deploy_monitoring() {
echo "=========================================================="
echo " Deploying Observability Stack (Grafana + Prometheus + cAdvisor)"
echo "=========================================================="
mkdir -p "${MONITORING_DIR}/prometheus" "${MONITORING_DIR}/grafana/provisioning/datasources"
# Prometheus Scrape Configuration
cat <<'EOF' > "${MONITORING_DIR}/prometheus/prometheus.yml"
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'node_exporter'
static_configs:
- targets: ['node_exporter:9100']
- job_name: 'cadvisor'
static_configs:
- targets: ['cadvisor:8080']
EOF
# Grafana Datasource Auto-provisioning
cat <<'EOF' > "${MONITORING_DIR}/grafana/provisioning/datasources/prometheus.yaml"
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
EOF
GRAFANA_SEC_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9')
echo "GRAFANA_ADMIN_PASS=${GRAFANA_SEC_PASS}" >> "${STATE_FILE}"
cat <<EOF > "${MONITORING_DIR}/docker-compose.yml"
services:
prometheus:
image: prom/prometheus:v2.54.0
container_name: prometheus
restart: unless-stopped
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
networks:
- ${MONITORING_NETWORK}
node_exporter:
image: prom/node-exporter:v1.8.2
container_name: node_exporter
restart: unless-stopped
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.rootfs=/rootfs'
- '--path.sysfs=/host/sys'
networks:
- ${MONITORING_NETWORK}
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
container_name: cadvisor
restart: unless-stopped
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks:
- ${MONITORING_NETWORK}
grafana:
image: grafana/grafana:11.2.0
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_SEC_PASS}
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
networks:
- ${MONITORING_NETWORK}
volumes:
prometheus_data:
grafana_data:
networks:
${MONITORING_NETWORK}:
external: true
EOF
compose_exec "${MONITORING_DIR}" up -d
echo "==> Monitoring stack operational. Grafana UI: http://<HOST_IP>:3000"
}
# ==============================================================================
# 3. SHARED DATABASE & OBJECT CACHE CORE
# ==============================================================================
init_shared_core() {
echo "==> Initializing centralized MariaDB & Redis services..."
mkdir -p "${CORE_DIR}/data/mariadb" "${CORE_DIR}/data/redis" "${CORE_DIR}/config/mariadb"
CORE_DB_ROOT_PW=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9')
CORE_REDIS_PW=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9')
cat <<EOF > "${CORE_DIR}/.env"
DB_ROOT_PASSWORD=${CORE_DB_ROOT_PW}
REDIS_PASSWORD=${CORE_REDIS_PW}
NEXT_REDIS_INDEX=0
EOF
chmod 600 "${CORE_DIR}/.env"
cat <<'EOF' > "${CORE_DIR}/config/mariadb/99-performance.cnf"
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
innodb_buffer_pool_size = 2G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
max_connections = 300
query_cache_type = 0
query_cache_size = 0
EOF
cat <<EOF > "${CORE_DIR}/docker-compose.yml"
services:
core_db:
image: mariadb:11.4
container_name: core_mariadb
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASSWORD}
volumes:
- ./data/mariadb:/var/lib/mysql
- ./config/mariadb/99-performance.cnf:/etc/mysql/conf.d/99-performance.cnf:ro
networks:
- ${BACKEND_NETWORK}
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
core_redis:
image: redis:7-alpine
container_name: core_redis
restart: unless-stopped
command: >
redis-server
--requirepass \${REDIS_PASSWORD}
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--save ""
volumes:
- ./data/redis:/data
networks:
- ${BACKEND_NETWORK}
networks:
${BACKEND_NETWORK}:
external: true
EOF
compose_exec "${CORE_DIR}" up -d
echo "==> Waiting for MariaDB health status..."
until cli_exec exec core_mariadb mariadb-admin ping -u root -p"${CORE_DB_ROOT_PW}" --silent >/dev/null 2>&1; do
sleep 2
done
echo "==> Core database and cache online."
}
# ==============================================================================
# 4. WORDPRESS SITE SCALING & PROVISIONING
# ==============================================================================
provision_site() {
local SITE_SLUG="${1:-}"
local DOMAIN="${2:-}"
local ADMIN_USER="${3:-admin}"
local ADMIN_EMAIL="${4:-admin@${DOMAIN}}"
if [ -z "${SITE_SLUG}" ] || [ -z "${DOMAIN}" ]; then
echo "Usage: $0 create <site-slug> <domain.com> [admin_user] [admin_email]"
exit 1
fi
load_state
if ! cli_exec ps --format '{{.Names}}' | grep -q "ingress_proxy"; then
run_host_bootstrap
fi
if [ ! -f "${CORE_DIR}/.env" ] || ! cli_exec ps --format '{{.Names}}' | grep -q "core_mariadb"; then
init_shared_core
fi
# shellcheck source=/dev/null
source "${CORE_DIR}/.env"
local SITE_DIR="${SITES_DIR}/${SITE_SLUG}"
if [ -d "${SITE_DIR}" ]; then
echo "[!] Error: Destination directory '${SITE_DIR}' already exists." >&2
exit 1
fi
local REDIS_INDEX="${NEXT_REDIS_INDEX:-0}"
local NEW_NEXT_INDEX=$((REDIS_INDEX + 1))
sed -i "s/^NEXT_REDIS_INDEX=.*/NEXT_REDIS_INDEX=${NEW_NEXT_INDEX}/" "${CORE_DIR}/.env"
local DB_NAME="wp_${SITE_SLUG//-/_}"
local DB_USER="usr_${SITE_SLUG//-/_}"
local DB_PASS=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9')
local WP_ADMIN_PASS=$(openssl rand -base64 18 | tr -dc 'a-zA-Z0-9!@#%^&*')
echo "==> Provisioning database credentials..."
cli_exec exec -i core_mariadb mariadb -u root -p"${DB_ROOT_PASSWORD}" <<EOF
CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'\%' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'%';
FLUSH PRIVILEGES;
EOF
mkdir -p "${SITE_DIR}/data/wp" "${SITE_DIR}/config"
cat <<'EOF' > "${SITE_DIR}/config/wordpress.ini"
file_uploads = On
memory_limit = 512M
upload_max_filesize = 128M
post_max_size = 128M
max_execution_time = 300
max_input_vars = 5000
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 60
EOF
# Generate compose file based on chosen proxy
if [ "${PROXY_TYPE}" = "traefik" ]; then
cat <<EOF > "${SITE_DIR}/docker-compose.yml"
services:
wordpress:
image: wordpress:6.7-apache
container_name: ${SITE_SLUG}_wp
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: core_mariadb:3306
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASS}
WORDPRESS_TABLE_PREFIX: wp_
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'core_redis');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_PASSWORD', '${REDIS_PASSWORD}');
define('WP_REDIS_DATABASE', ${REDIS_INDEX});
define('WP_CACHE_KEY_SALT', '${SITE_SLUG}_');
define('WP_CACHE', true);
define('DISALLOW_FILE_EDIT', true);
define('FORCE_SSL_ADMIN', true);
if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
\$_SERVER['HTTPS'] = 'on';
}
labels:
- "traefik.enable=true"
- "traefik.http.routers.${SITE_SLUG}.rule=Host(\`${DOMAIN}\`)"
- "traefik.http.routers.${SITE_SLUG}.entrypoints=websecure"
- "traefik.http.routers.${SITE_SLUG}.tls.certresolver=myresolver"
- "traefik.http.services.${SITE_SLUG}.loadbalancer.server.port=80"
volumes:
- ./data/wp:/var/www/html
- ./config/wordpress.ini:/usr/local/etc/php/conf.d/wordpress.ini:ro
networks:
- ${PROXY_NETWORK}
- ${BACKEND_NETWORK}
networks:
${PROXY_NETWORK}:
external: true
${BACKEND_NETWORK}:
external: true
EOF
else
# NPM and Caddy use standard ingress-connected containers
cat <<EOF > "${SITE_DIR}/docker-compose.yml"
services:
wordpress:
image: wordpress:6.7-apache
container_name: ${SITE_SLUG}_wp
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: core_mariadb:3306
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASS}
WORDPRESS_TABLE_PREFIX: wp_
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'core_redis');
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_PASSWORD', '${REDIS_PASSWORD}');
define('WP_REDIS_DATABASE', ${REDIS_INDEX});
define('WP_CACHE_KEY_SALT', '${SITE_SLUG}_');
define('WP_CACHE', true);
define('DISALLOW_FILE_EDIT', true);
define('FORCE_SSL_ADMIN', true);
if (isset(\$_SERVER['HTTP_X_FORWARDED_PROTO']) && \$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
\$_SERVER['HTTPS'] = 'on';
}
volumes:
- ./data/wp:/var/www/html
- ./config/wordpress.ini:/usr/local/etc/php/conf.d/wordpress.ini:ro
networks:
- ${PROXY_NETWORK}
- ${BACKEND_NETWORK}
networks:
${PROXY_NETWORK}:
external: true
${BACKEND_NETWORK}:
external: true
EOF
fi
chown -R 33:33 "${SITE_DIR}/data/wp"
compose_exec "${SITE_DIR}" up -d
# Automatic Caddy routing generation
if [ "${PROXY_TYPE}" = "caddy" ]; then
echo "==> Injecting ${DOMAIN} entry into Caddyfile..."
cat <<EOF >> "${PROXY_DIR}/Caddyfile"
${DOMAIN} {
reverse_proxy ${SITE_SLUG}_wp:80
log {
output file /logs/access.log
}
}
EOF
compose_exec "${PROXY_DIR}" exec ingress caddy reload --config /etc/caddy/Caddyfile || true
fi
echo "==> Awaiting core WordPress file deployment..."
until [ -f "${SITE_DIR}/data/wp/wp-config.php" ]; do
sleep 2
done
echo "==> Running automated WP-CLI bootstrap..."
cli_exec run --rm \
--user 33:33 \
--network "${BACKEND_NETWORK}" \
-v "${SITE_DIR}/data/wp:/var/www/html" \
wordpress:cli \
core install \
--url="https://${DOMAIN}" \
--title="${SITE_SLUG}" \
--admin_user="${ADMIN_USER}" \
--admin_password="${WP_ADMIN_PASS}" \
--admin_email="${ADMIN_EMAIL}" \
--skip-email
cli_exec run --rm \
--user 33:33 \
--network "${BACKEND_NETWORK}" \
-v "${SITE_DIR}/data/wp:/var/www/html" \
wordpress:cli \
rewrite structure '/%postname%/' --hard
cli_exec run --rm \
--user 33:33 \
--network "${BACKEND_NETWORK}" \
-v "${SITE_DIR}/data/wp:/var/www/html" \
wordpress:cli \
plugin install redis-cache --activate
cli_exec run --rm \
--user 33:33 \
--network "${BACKEND_NETWORK}" \
-v "${SITE_DIR}/data/wp:/var/www/html" \
wordpress:cli \
redis enable
chown -R 33:33 "${SITE_DIR}/data/wp/wp-content"
echo ""
echo "=========================================================="
echo " Site Provisioned Successfully!"
echo "=========================================================="
echo " Domain: https://${DOMAIN}"
echo " Administrator: ${ADMIN_USER}"
echo " Password: ${WP_ADMIN_PASS}"
echo " Database: ${DB_NAME}"
echo " DB User: ${DB_USER}"
echo " Redis Index: ${REDIS_INDEX}"
echo " Directory: ${SITE_DIR}"
if [ "${PROXY_TYPE}" = "npm" ]; then
echo " Action Required in NPM: Forward https://${DOMAIN} ->${SITE_SLUG}_wp:80"
elif [ "${PROXY_TYPE}" = "traefik" ]; then
echo " Traefik Status: Automatically routed via Docker labels."
elif [ "${PROXY_TYPE}" = "caddy" ]; then
echo " Caddy Status: Block appended to Caddyfile and reloaded automatically."
fi
echo "=========================================================="
}
# ==============================================================================
# CLI DISPATCHER
# ==============================================================================
ACTION="${1:-}"
shift || true
SSH_PORT_ARG=""
ENGINE_ARG=""
PROXY_ARG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--ssh-port)
SSH_PORT_ARG="$2"
shift 2
;;
--engine)
ENGINE_ARG="$2"
shift 2
;;
--proxy)
PROXY_ARG="$2"
shift 2
;;
*)
break
;;
esac
done
case "${ACTION}" in
--bootstrap)
prompt_config
load_state
run_host_bootstrap
;;
create)
provision_site "$@"
;;
*)
echo "Usage:"
echo " Interactive Bootstrap:"
echo " $0 --bootstrap"
echo ""
echo " Unattended Bootstrap:"
echo " $0 --bootstrap --ssh-port <port> --engine <docker|podman> --proxy <npm|traefik|caddy>"
echo ""
echo " Provision Site:"
echo " $0 create <slug> <domain> [admin_user] [admin_email]"
echo ""
echo "Examples:"
echo " $0 --bootstrap --ssh-port 48222 --engine docker --proxy caddy"
echo " $0 create teetimeprints teetimeprints.com mkell admin@teetimeprints.com"
exit 1
;;
esac