From a26d1831eec0e12fd9f1f2106896b5f8089116bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 20:57:18 +0000 Subject: [PATCH] =?UTF-8?q?Add=20services/wordpress.sh=20=E2=80=94=20multi?= =?UTF-8?q?-site=20WordPress=20with=20shared=20MariaDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New service: self-hosted WordPress, sized for running several independent sites the way a hosting company would, not just one blog. - Multi-site from the start: every site requires a name (no unnamed "first instance" special case like mattermost's — there's no backward-compat reason to special-case one here) and gets its own directory/container/port, but all sites share ONE MariaDB container (chain-installed on first site, reused by every other one) instead of a dedicated database container per site — same resource-sharing idea as services/coturn.sh, just scoped to WordPress's own sites rather than shared across different services. Each site gets its own database + user within that shared instance. - E-commerce is just WooCommerce, a normal WordPress plugin — no separate infrastructure. PHP memory_limit/upload_max_filesize/ post_max_size are pre-tuned (256M/64M/64M) so a product-catalog import doesn't hit default-image limits on the first try. - wp-cli (official wordpress:cli image, run as a one-off container sharing the site's html volume) does the initial WordPress core install non-interactively — title, admin account — so there's no browser setup wizard to remember per site. Falls back to printing the exact manual command if the site wasn't ready in time. - Auto-scans for a free host port per site (multiple sites can't all bind 8090), matching the "auto-scanned free ports for extras" idea already used by mattermost's multi-instance support. - DB and admin passwords are reused across reruns (checked against the DB-password-regeneration bug class already fixed elsewhere in this repo, e.g. PR #265) — verified via a real update-mode rerun that the credential doesn't change. - setup.sh: is_installed() gets a wordpress case — every site is named from the first one on, so there's never a plain $DOCKER_DIR/wordpress directory the default case could match against. - README.md: added to the utilities services table + copiable list per CLAUDE.md's three-step rule for new services. Also fixed `coturn` being in the homelab row's prose but missing from the copiable list block below it — a pre-existing gap from when coturn.sh was merged. Verified end-to-end via non-interactive dry runs against a fake docker shim (no live daemon in this environment): 3 sites installed in sequence get 3 distinct databases, 3 distinct auto-scanned ports, the shared DB is only set up once, and an update-mode rerun preserves the existing DB password rather than regenerating it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug --- README.md | 4 +- services/wordpress.sh | 572 ++++++++++++++++++++++++++++++++++++++++++ setup.sh | 4 + 3 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 services/wordpress.sh diff --git a/README.md b/README.md index 9fbf20e..7a3da93 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network | | `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` | -| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy`, `wordpress` (multi-site, shared MariaDB — blogs, business sites, e-commerce via WooCommerce) | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | @@ -89,6 +89,7 @@ homelab caddy crowdsec authelia + coturn homeassistant asterisk pstn-trunk @@ -131,6 +132,7 @@ utilities watchyourlan watchtower wg-easy + wordpress media arm diff --git a/services/wordpress.sh b/services/wordpress.sh new file mode 100644 index 0000000..4cf86b2 --- /dev/null +++ b/services/wordpress.sh @@ -0,0 +1,572 @@ +#!/bin/bash +# services/wordpress.sh — Self-hosted WordPress sites, multi-site, shared MariaDB. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash wordpress.sh +# (Docker must already be installed when run standalone) +# +# Every WordPress site gets its own directory/containers, but all sites on +# this box share ONE MariaDB container (chain-installed on first need, same +# "one shared thing instead of N heavy duplicates" idea as services/coturn.sh +# — just scoped to WordPress's own sites rather than shared across different +# services). Each site gets its own database + credentials within that +# shared instance instead of a dedicated MariaDB container per site. +# +# E-commerce (WooCommerce) is just a normal WordPress plugin — no separate +# infrastructure needed. PHP upload/memory limits are tuned upfront so it +# works well the first time instead of hitting default-image limits on the +# first product-image import. + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } + + _SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + _COMMON="$_SELF_DIR/../lib/common.sh" + + if [[ -f "$_COMMON" ]]; then + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_reinstall_mode() { + local _var="$1" _r + if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi + echo " Existing install detected. Choose:" + echo " r) Reinstall in place — refresh image/compose, keep database and settings" + echo " f) Full install — re-run every prompt from scratch" + echo " c) Cancel — leave everything as-is [default]" + read -r -p " Choice [r/f/c, Enter=cancel]: " _r + case "${_r,,}" in + r) eval "$_var='update'" ;; + f) eval "$_var='fresh'" ;; + *) eval "$_var='cancel'" ;; + esac + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block_upstream="$_upstream" + [[ "$_mode" == "remote" ]] && _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true + } + fi + + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service wordpress utilities "Self-hosted WordPress sites (multi-site, shared MariaDB) — blogs, business sites, e-commerce via WooCommerce" 8090 + +# ── Shared MariaDB, chain-installed the first time any site needs it ──────── +# Out-param (not `local`): WP_DB_ROOT_PASS — read after this returns. +_wordpress_ensure_shared_db() { + local DB_DIR="$DOCKER_DIR/wordpress-db" + WP_DB_ROOT_PASS="" + + if [ -f "$DB_DIR/.env" ]; then + WP_DB_ROOT_PASS="$(grep '^MYSQL_ROOT_PASSWORD=' "$DB_DIR/.env" | cut -d= -f2-)" + [ -n "$WP_DB_ROOT_PASS" ] && return 0 + fi + + log_info "No shared WordPress database yet — setting one up (used by every WordPress site on this box)..." + mkdir -p "$DB_DIR/data" + ensure_docker_dir_ownership "$DB_DIR" + + WP_DB_ROOT_PASS="$(generate_password 32)" + + # Dedicated network so WordPress site containers can reach the shared DB + # without joining caddy_net (that one's for Caddy<->service HTTP traffic). + docker network inspect wordpress_net &>/dev/null || docker network create wordpress_net &>/dev/null + + cat > "$DB_DIR/docker-compose.yml" << 'WPDBCOMPOSE' +name: wordpress-db + +services: + wordpress-db: + image: mariadb:11 + container_name: wordpress-db + hostname: wordpress-db + restart: unless-stopped + env_file: .env + volumes: + - ./data:/var/lib/mysql + networks: + - wordpress_net + +networks: + wordpress_net: + external: true +WPDBCOMPOSE + + cat > "$DB_DIR/.env" << WPDBENV +# Shared MariaDB for every WordPress site on this box — each site gets its +# own database + user within this one instance instead of a dedicated +# MariaDB container per site (same resource-sharing idea as coturn, just +# scoped to WordPress's own sites). Changing this breaks every site's DB +# connection until each site's .env is updated to match. +MYSQL_ROOT_PASSWORD=$WP_DB_ROOT_PASS +MARIADB_AUTO_UPGRADE=1 +WPDBENV + chmod 600 "$DB_DIR/.env" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DB_DIR" + + ( cd "$DB_DIR" && docker compose up -d ) \ + && log_success "Shared WordPress database started" \ + || { log_warning "Failed to start the shared WordPress database — check: cd $DB_DIR && docker compose logs"; return 1; } + + local _tries=0 + until docker exec wordpress-db mysqladmin ping -uroot -p"$WP_DB_ROOT_PASS" --silent &>/dev/null || [ "$_tries" -ge 30 ]; do + sleep 1; _tries=$((_tries + 1)) + done + + write_readme "$DB_DIR" << WPDBREADME +# wordpress-db — shared MariaDB for every WordPress site + +One MariaDB instance shared by every WordPress site on this box +(\`services/wordpress.sh\`) — each site gets its own database and user +within this instance instead of a dedicated MariaDB container per site. + +Root credentials: \`.env\` (\`MYSQL_ROOT_PASSWORD\`, chmod 600). + +## Manage +\`\`\`bash +docker compose up -d +docker compose down +docker compose logs -f +docker exec -it wordpress-db mysql -uroot -p +\`\`\` + +Deleting this stops every WordPress site on the box — check +\`~/docker/wordpress-*\` for what depends on it before removing. +WPDBREADME +} + +install_wordpress() { + require_docker || return 1 + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ WORDPRESS │" + echo "│ Self-hosted WordPress site — blog, business site, or store │" + echo "│ (WooCommerce is just a plugin — install it from the WP admin │" + echo "│ after setup, no extra infrastructure needed for e-commerce) │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would prompt for a site name (directory/container naming)" + echo "[DRY-RUN] Would ensure the shared wordpress-db MariaDB container exists" + echo "[DRY-RUN] (chain-installed on first WordPress site, reused by every other site)" + echo "[DRY-RUN] Would create this site's database + credentials in that shared instance" + echo "[DRY-RUN] Would create \$DOCKER_DIR/wordpress- with docker-compose.yml + .env" + echo "[DRY-RUN] Would tune PHP memory_limit/upload_max_filesize for WooCommerce-readiness" + echo "[DRY-RUN] Would auto-scan for a free host port for this site" + echo "[DRY-RUN] Would run wp-cli to install WordPress core non-interactively (site title," + echo "[DRY-RUN] admin account) instead of leaving a setup wizard for a browser to finish" + echo "[DRY-RUN] Would offer a Caddy reverse proxy and to start the site" + return 0 + fi + + # ── Site name (used for directory/container naming) ───────────────────── + local SITE_NAME="" + while [ -z "$SITE_NAME" ]; do + prompt_text "Site name (letters/numbers/hyphens, e.g. 'myblog' or 'client-store'):" "" SITE_NAME + SITE_NAME="$(echo "$SITE_NAME" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-')" + SITE_NAME="${SITE_NAME#-}"; SITE_NAME="${SITE_NAME%-}" + if [ -z "$SITE_NAME" ]; then + if [ "$UNATTENDED" = true ]; then + SITE_NAME="site1" + else + log_warning "Site name required." + fi + fi + done + + local DIR="$DOCKER_DIR/wordpress-$SITE_NAME" + local CONTAINER="wordpress-$SITE_NAME" + + # ── Existing install (this exact site)? Offer update-in-place ─────────── + if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then + local MODE="" + prompt_reinstall_mode MODE + case "$MODE" in + update) + log_info "Refreshing '$SITE_NAME''s image/compose only — database, domain, and" + log_info "credentials are left exactly as they are." + ( cd "$DIR" && docker compose pull && docker compose up -d ) + log_success "'$SITE_NAME' refreshed" + return 0 + ;; + cancel) + log_info "Leaving '$SITE_NAME' as-is." + return 0 + ;; + fresh) ;; + esac + fi + + # ── Shared MariaDB ──────────────────────────────────────────────────────── + _wordpress_ensure_shared_db || return 1 + + # ── This site's database + credentials within the shared instance ─────── + local WP_DB_NAME="wp_${SITE_NAME//-/_}" + local WP_DB_USER="wp_${SITE_NAME//-/_}" + local WP_DB_PASS="" + [ -f "$DIR/.env" ] && WP_DB_PASS="$(grep '^WORDPRESS_DB_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" + [ -n "$WP_DB_PASS" ] || WP_DB_PASS="$(generate_password 24)" + + if docker exec wordpress-db mysql -uroot -p"$WP_DB_ROOT_PASS" -e \ + "CREATE DATABASE IF NOT EXISTS \`$WP_DB_NAME\`; \ + CREATE USER IF NOT EXISTS '$WP_DB_USER'@'%' IDENTIFIED BY '$WP_DB_PASS'; \ + GRANT ALL PRIVILEGES ON \`$WP_DB_NAME\`.* TO '$WP_DB_USER'@'%'; \ + FLUSH PRIVILEGES;" &>/dev/null; then + log_success "Database '$WP_DB_NAME' ready on the shared MariaDB instance" + else + log_warning "Could not create the database — is wordpress-db running? Check: docker logs wordpress-db" + return 1 + fi + + echo "" + local WP_SITE_TITLE="" WP_ADMIN_USER="" WP_ADMIN_EMAIL="" + prompt_text "Site title:" "$SITE_NAME" WP_SITE_TITLE + prompt_text "Admin username:" "admin" WP_ADMIN_USER + prompt_text "Admin email:" "" WP_ADMIN_EMAIL + local WP_ADMIN_PASS="" + [ -f "$DIR/.env" ] && WP_ADMIN_PASS="$(grep '^WP_ADMIN_PASSWORD=' "$DIR/.env" | cut -d= -f2-)" + [ -n "$WP_ADMIN_PASS" ] || WP_ADMIN_PASS="$(generate_password 16)" + + # ── Free host port (multiple sites can't all bind the same one) ───────── + local WEB_PORT=8090 + while docker ps -a --format '{{.Ports}}' 2>/dev/null | grep -q ":${WEB_PORT}->"; do + WEB_PORT=$((WEB_PORT + 1)) + done + + mkdir -p "$DIR/html" "$DIR/uploads-ini.d" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local TZ_VAL="${SITE_TZ:-UTC}" + + # PHP tuning for WooCommerce/media-heavy sites out of the box — default + # image limits (2M uploads, 128M memory) are a common first-run surprise + # otherwise, especially importing a product catalog. + cat > uploads-ini.d/uploads.ini << 'PHPINI' +file_uploads = On +memory_limit = 256M +upload_max_filesize = 64M +post_max_size = 64M +max_execution_time = 300 +PHPINI + + # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): + # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, + # then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net. + local _CADDY_MODE="${CADDY_MODE:-none}" + [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" + [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" + + local _CADDY_NET_LINE="" _CADDY_NET_SECTION="" + if [ "$_CADDY_MODE" = "local" ]; then + _CADDY_NET_LINE=" - caddy_net +" + _CADDY_NET_SECTION=" + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +" + fi + + cat > docker-compose.yml << WPCOMPOSE +name: $CONTAINER + +services: + wordpress: + image: wordpress:php8.3-apache + container_name: $CONTAINER + hostname: $CONTAINER + restart: unless-stopped + env_file: .env + volumes: + - ./html:/var/www/html + - ./uploads-ini.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro + ports: + - "${WEB_PORT}:80" + networks: + - wordpress_net +${_CADDY_NET_LINE} +networks: + wordpress_net: + external: true +${_CADDY_NET_SECTION} +WPCOMPOSE + + cat > .env << WPENV +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +WORDPRESS_DB_HOST=wordpress-db +WORDPRESS_DB_NAME=$WP_DB_NAME +WORDPRESS_DB_USER=$WP_DB_USER +WORDPRESS_DB_PASSWORD=$WP_DB_PASS + +# Only consulted by wp-cli during initial setup below, not read by the +# wordpress:apache image itself (unlike Nextcloud's image, WordPress's +# official image has no built-in "create admin from env vars" feature). +WP_SITE_TITLE=$WP_SITE_TITLE +WP_ADMIN_USER=$WP_ADMIN_USER +WP_ADMIN_PASSWORD=$WP_ADMIN_PASS +WP_ADMIN_EMAIL=$WP_ADMIN_EMAIL +WPENV + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + log_success "'$SITE_NAME' configured at $DIR (port $WEB_PORT)" + + configure_caddy_for_service "WordPress - $SITE_NAME" "${CONTAINER}:80" "$SITE_NAME" + + write_readme "$DIR" << MD +# WordPress — $SITE_NAME + +Self-hosted WordPress site. Database lives on the shared \`wordpress-db\` +MariaDB instance (\`~/docker/wordpress-db\`) used by every WordPress site on +this box — not a dedicated database container for this site alone. + +- Web UI: http://localhost:${WEB_PORT} +- Admin user: \`$WP_ADMIN_USER\` +- Admin password: see \`WP_ADMIN_PASSWORD\` in \`.env\` +- Site files: \`html/\` +- PHP limits: \`uploads-ini.d/uploads.ini\` (256M memory, 64M uploads — + raise further here if a specific import still hits a limit) + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## wp-cli +Run any wp-cli command against this site without installing wp-cli on the +host: +\`\`\`bash +docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ + --env-file $DIR/.env wordpress:cli wp +\`\`\` + +## E-commerce (WooCommerce) +No separate infrastructure needed — WooCommerce is a normal WordPress +plugin. Install it from Plugins → Add New in the WP admin, or via wp-cli: +\`\`\`bash +docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\ + --env-file $DIR/.env wordpress:cli wp plugin install woocommerce --activate +\`\`\` +The PHP limits above (256M memory, 64M uploads) were already sized with +WooCommerce's own recommendations in mind, so product/image imports work +without hitting default-image limits on the first try. + +## Backup +Back up \`html/\` (site files/plugins/themes/media) and this site's +database on \`wordpress-db\` (\`docker exec wordpress-db mysqldump -uroot -p +$WP_DB_NAME > backup.sql\`) — \`.env\` holds the root password. +MD + + local START_WP="" + prompt_yn "Start '$SITE_NAME' now? (y/n):" "y" START_WP + if [[ "$START_WP" =~ ^[Yy]$ ]]; then + if docker compose up -d; then + log_success "'$SITE_NAME' started" + + log_info "Waiting for WordPress to come up, then running wp-cli core install..." + local _tries=0 + until docker exec "$CONTAINER" curl -fs -o /dev/null http://localhost/ 2>/dev/null || [ "$_tries" -ge 30 ]; do + sleep 1; _tries=$((_tries + 1)) + done + + if docker run --rm --network wordpress_net \ + -v "$DIR/html:/var/www/html" \ + -e WORDPRESS_DB_HOST=wordpress-db \ + -e WORDPRESS_DB_NAME="$WP_DB_NAME" \ + -e WORDPRESS_DB_USER="$WP_DB_USER" \ + -e WORDPRESS_DB_PASSWORD="$WP_DB_PASS" \ + wordpress:cli \ + core install \ + --url="http://localhost:${WEB_PORT}" \ + --title="$WP_SITE_TITLE" \ + --admin_user="$WP_ADMIN_USER" \ + --admin_password="$WP_ADMIN_PASS" \ + --admin_email="$WP_ADMIN_EMAIL" \ + --skip-email &>/dev/null; then + log_success "WordPress installed — no browser setup wizard needed" + else + log_warning "wp-cli install didn't complete (WordPress may not have been ready yet, or was" + log_warning "already installed). Finish setup in the browser, or retry manually:" + log_warning " docker run --rm --network wordpress_net -v $DIR/html:/var/www/html \\" + log_warning " -e WORDPRESS_DB_HOST=wordpress-db -e WORDPRESS_DB_NAME=$WP_DB_NAME \\" + log_warning " -e WORDPRESS_DB_USER=$WP_DB_USER -e WORDPRESS_DB_PASSWORD=$WP_DB_PASS \\" + log_warning " wordpress:cli core install --url=http://localhost:${WEB_PORT} \\" + log_warning " --title=\"$WP_SITE_TITLE\" --admin_user=$WP_ADMIN_USER \\" + log_warning " --admin_password= --admin_email=$WP_ADMIN_EMAIL" + fi + else + log_warning "Failed to start — check: docker compose logs" + fi + fi + + echo "" + echo " Access at: http://localhost:${WEB_PORT}" + echo " Admin user: $WP_ADMIN_USER" + echo " Admin pass: $WP_ADMIN_PASS" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wordpress diff --git a/setup.sh b/setup.sh index d1371ea..58d9fba 100755 --- a/setup.sh +++ b/setup.sh @@ -99,6 +99,10 @@ is_installed() { pstn-trunk) [ -f "$DOCKER_DIR/asterisk-digital-ocean/config/asterisk/pstn-trunk-pjsip.conf" ] || [ -f "$DOCKER_DIR/asterisk/config/asterisk/pstn-trunk-pjsip.conf" ] ;; sms-inbound) [ -f /opt/sms-inbound/settings.env ] ;; ssh-config) false ;; # repeatable management tool, never shows [installed] + # Every WordPress site is named from the first one on (no plain + # $DOCKER_DIR/wordpress dir the default case below could match) — + # [installed] means "at least one site exists", not any specific one. + wordpress) compgen -G "$DOCKER_DIR/wordpress-*" >/dev/null 2>&1 ;; *) [ -e "$DOCKER_DIR/$1" ] ;; esac }