Initial commit: wp-engine hardening script, README, and architecture docs
This commit is contained in:
commit
08fc8f66d0
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
```
|
||||
|
|
@ -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;
|
||||
```
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue