diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bfd3bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# local artifacts +*.log +.DS_Store +.env +.env.* diff --git a/scripts/bs-label-workers.sh b/scripts/bs-label-workers.sh new file mode 100644 index 0000000..1bdb3cc --- /dev/null +++ b/scripts/bs-label-workers.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# label nodes into pools based on hostname: *wordpressPool*/*mysqlPool*/*redisPool*/*phpPool*/*nginxPool* +set -euo pipefail +docker node inspect -f '{{ .ID }} {{ .Description.Hostname }} {{ .Spec.Role }}' $(docker node ls -q) \ +| while read -r id host role; do + [[ "$role" == "worker" ]] || continue + case "$host" in + *wordPressPool*) val=wordpress ;; + *mysqlPool*) val=mysql ;; + *redisPool*) val=redis ;; + *phpPool*) val=php ;; + *nginxPool*) val=nginx ;; + *) val="" ;; + esac + [[ -n "$val" ]] && docker node update --label-add "pool=$val" "$id" +done +echo "Worker labels updated." diff --git a/scripts/bs-networks.sh b/scripts/bs-networks.sh new file mode 100644 index 0000000..9d6cdf5 --- /dev/null +++ b/scripts/bs-networks.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +docker network create --driver overlay --opt encrypted --attachable appNet >/dev/null 2>&1 || true +docker network create --driver overlay --opt encrypted --internal backendNet >/dev/null 2>&1 || true +echo "appNet + backendNet ready." diff --git a/scripts/bs-scaffold-site.sh b/scripts/bs-scaffold-site.sh new file mode 100644 index 0000000..cbcc49c --- /dev/null +++ b/scripts/bs-scaffold-site.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +DOMAIN="${1:-${DOMAIN:-}}" +if [[ -z "$DOMAIN" ]]; then + echo "Usage: $0 "; exit 1 +fi + +STACK="${STACK:-${DOMAIN%.*}}" # domain minus TLD +BASE="/mnt/data/sites/$DOMAIN" +TPL="/mnt/data/templates/site-stack.allinone.template.yml" +OUT="/mnt/data/stacks/site-stack.$DOMAIN.yml" + +mkdir -p "$BASE"/{code,uploads,plugins,themes,mu-plugins,nginx/conf.d,db,redis} +chown -R www-data:www-data "$BASE"/{code,uploads,plugins,themes,mu-plugins} || true +chown -R nobody:nogroup "$BASE/nginx" || true + +# Seed WordPress core if empty +if [ -z "$(ls -A "$BASE/code" 2>/dev/null)" ]; then + docker run --rm -v "$BASE/code":/target wordpress:php8.3-fpm \ + bash -lc 'shopt -s dotglob && cp -a /var/www/html/* /target/' + chown -R www-data:www-data "$BASE/code" +fi + +# vhost +VHOST="$BASE/nginx/conf.d/site.conf" +if [[ ! -f "$VHOST" ]]; then +cat > "$VHOST" <<'CONF' +server { + listen 80; + server_name __DOMAIN__; + root /var/www/html; + index index.php index.html; + + location / { try_files $uri $uri/ /index.php?$args; } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; + fastcgi_param HTTP_X_FORWARDED_HOST $host; + fastcgi_pass php:9000; + } + + client_max_body_size 50m; +} +CONF + sed -i "s/__DOMAIN__/$DOMAIN/g" "$VHOST" +fi + +# per-site secrets +ROOT_SEC="mysql_root_password_${DOMAIN}" +WP_SEC="mysql_wp_password_${DOMAIN}" +docker secret ls --format '{{.Name}}' | grep -qx "$ROOT_SEC" || openssl rand -base64 32 | docker secret create "$ROOT_SEC" - >/dev/null +docker secret ls --format '{{.Name}}' | grep -qx "$WP_SEC" || openssl rand -base64 32 | docker secret create "$WP_SEC" - >/dev/null + +# render stack +mkdir -p /mnt/data/stacks /mnt/data/templates +cp -n templates/site-stack.allinone.template.yml "$TPL" 2>/dev/null || true +sed "s/__DOMAIN__/$DOMAIN/g" "$TPL" > "$OUT" + +# deploy +docker stack deploy -c "$OUT" "$STACK" + +echo "Deployed stack '$STACK'. In NPM, proxy https://$DOMAIN -> http://${STACK}_nginx:80 on appNet." diff --git a/stacks/gateway-stack.yml b/stacks/gateway-stack.yml new file mode 100644 index 0000000..07e89e8 --- /dev/null +++ b/stacks/gateway-stack.yml @@ -0,0 +1,18 @@ +version: "3.9" +networks: { appNet: { external: true } } + +services: + npm: + image: jc21/nginx-proxy-manager:latest + networks: [appNet] + ports: ["80:80","443:443","81:81"] + volumes: + - /mnt/data/gateway/npm/data:/data + - /mnt/data/gateway/npm/letsencrypt:/etc/letsencrypt + deploy: + replicas: 2 + placement: + constraints: ["node.role == manager"] + resources: + limits: { cpus: "0.50", memory: 512M } + reservations: { cpus: "0.10", memory: 128M } diff --git a/templates/nginx-site.conf b/templates/nginx-site.conf new file mode 100644 index 0000000..94d08b4 --- /dev/null +++ b/templates/nginx-site.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name __DOMAIN__; + root /var/www/html; + index index.php index.html; + + location / { try_files $uri $uri/ /index.php?$args; } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; + fastcgi_param HTTP_X_FORWARDED_HOST $host; + fastcgi_pass php:9000; + } + + client_max_body_size 50m; +} diff --git a/templates/site-stack.allinone.template.yml b/templates/site-stack.allinone.template.yml new file mode 100644 index 0000000..132e5ad --- /dev/null +++ b/templates/site-stack.allinone.template.yml @@ -0,0 +1,142 @@ +version: "3.9" + +networks: + appNet: { external: true } + privNet: + driver: overlay + internal: true + attachable: false + driver_opts: { encrypted: "true" } + +secrets: + mysql_root_password___DOMAIN__: { external: true } + mysql_wp_password___DOMAIN__: { external: true } + +x-common-env: &common_env + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD_FILE: /run/secrets/mysql_wp_password___DOMAIN__ + WORDPRESS_DB_NAME: wordpress + WORDPRESS_CONFIG_EXTRA: | + if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $_SERVER['HTTPS'] = 'on'; + } + define('FORCE_SSL_ADMIN', true); + define('WP_CACHE_KEY_SALT', '__DOMAIN__'); + define('WP_REDIS_HOST', 'redis'); + +services: + db: + image: mysql:8.4 + command: ["--innodb-buffer-pool-size=192M","--max-connections=100","--skip-log-bin"] + environment: + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password___DOMAIN__ + MYSQL_PASSWORD_FILE: /run/secrets/mysql_wp_password___DOMAIN__ + secrets: [mysql_root_password___DOMAIN__, mysql_wp_password___DOMAIN__] + networks: [privNet] + volumes: + - type: bind + source: /mnt/data/sites/__DOMAIN__/db + target: /var/lib/mysql + deploy: + placement: { constraints: ["node.labels.pool == mysql","node.role == worker"] } + resources: + limits: { cpus: "0.50", memory: 512M } + reservations: { cpus: "0.10", memory: 256M } + healthcheck: + test: ["CMD-SHELL","MYSQL_PWD=$$(cat /run/secrets/mysql_root_password___DOMAIN__) mysqladmin ping -h 127.0.0.1 -uroot"] + interval: 20s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + command: ["redis-server","--appendonly","yes","--maxmemory","96mb","--maxmemory-policy","allkeys-lru"] + networks: [privNet] + volumes: + - type: bind + source: /mnt/data/sites/__DOMAIN__/redis + target: /data + deploy: + placement: { constraints: ["node.labels.pool == redis","node.role == worker"] } + resources: + limits: { cpus: "0.20", memory: 192M } + reservations: { cpus: "0.05", memory: 64M } + healthcheck: + test: ["CMD","redis-cli","ping"] + interval: 20s + timeout: 3s + retries: 5 + + php: + image: wordpress:php8.3-fpm + environment: *common_env + secrets: [mysql_wp_password___DOMAIN__] + networks: [appNet, privNet] + volumes: + - type: bind + source: /mnt/data/sites/__DOMAIN__/code + target: /var/www/html + - type: bind + source: /mnt/data/sites/__DOMAIN__/uploads + target: /var/www/html/wp-content/uploads + - type: bind + source: /mnt/data/sites/__DOMAIN__/plugins + target: /var/www/html/wp-content/plugins + - type: bind + source: /mnt/data/sites/__DOMAIN__/themes + target: /var/www/html/wp-content/themes + - type: bind + source: /mnt/data/sites/__DOMAIN__/mu-plugins + target: /var/www/html/wp-content/mu-plugins + tmpfs: ["/tmp"] + security_opt: ["no-new-privileges:true"] + cap_drop: ["ALL"] + ulimits: { nofile: 65536, nproc: 4096 } + deploy: + placement: { constraints: ["node.labels.pool == php","node.role == worker"] } + resources: + limits: { cpus: "0.50", memory: 512M } + reservations: { cpus: "0.10", memory: 128M } + + nginx: + image: nginx:stable-alpine + networks: [appNet] + depends_on: [php] + volumes: + - type: bind + source: /mnt/data/sites/__DOMAIN__/code + target: /var/www/html:ro + - type: bind + source: /mnt/data/sites/__DOMAIN__/nginx/conf.d + target: /etc/nginx/conf.d + read_only: true + tmpfs: ["/var/cache/nginx","/var/run"] + security_opt: ["no-new-privileges:true"] + cap_drop: ["ALL"] + ulimits: { nofile: 65536 } + deploy: + placement: { constraints: ["node.labels.pool == nginx","node.role == worker"] } + resources: + limits: { cpus: "0.25", memory: 256M } + reservations: { cpus: "0.05", memory: 64M } + + wp-cron: + image: wordpress:cli + environment: *common_env + secrets: [mysql_wp_password___DOMAIN__] + networks: [privNet] + working_dir: /var/www/html + volumes: + - type: bind + source: /mnt/data/sites/__DOMAIN__/code + target: /var/www/html + command: ["bash","-lc","while true; do wp cron event run --due-now || true; sleep 60; done"] + security_opt: ["no-new-privileges:true"] + deploy: + placement: { constraints: ["node.labels.pool == wp","node.role == worker"] } + resources: + limits: { cpus: "0.10", memory: 128M } + reservations: { cpus: "0.05", memory: 64M }