commit c9d637c907f9ad817686c89b7f3075cff3781334 Author: Twooey Date: Thu Aug 27 09:50:18 2026 -0400 Week 1 infrastructure: Docker environments, deploy pipeline, bookstore-core scaffold Docker Compose environments for dev/staging/production (MariaDB, Redis, Caddy, Action Scheduler cron sidecar), an idempotent deploy script, git-hook-based deploy pipeline, backup/restore scripts, and the bookstore-core plugin stub with WooCommerce HPOS compatibility declared. Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2502ac4 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# Copy this to .env.dev / .env.staging / .env.production and fill in real +# values for that environment. The filled-in files are gitignored — never +# commit them. Same code everywhere; only these values differ (see +# design doc §01, Environments & deployment). + +# --- Site --- +# (WP_ENVIRONMENT_TYPE is NOT set here — it's hardcoded per environment in +# docker-compose.{dev,staging,production}.yml so it can't go stale.) +SITE_DOMAIN=staging.example.com +SITE_URL=https://staging.example.com +SITE_TITLE="Bookstore (staging)" + +# --- WordPress admin bootstrap (used only on first install) --- +WP_ADMIN_USER=admin +WP_ADMIN_PASSWORD=changeme +WP_ADMIN_EMAIL=admin@example.com + +# --- Database --- +DB_NAME=bookstore +DB_USER=bookstore +DB_PASSWORD=changeme +DB_ROOT_PASSWORD=changeme +WP_TABLE_PREFIX=wp_ + +# --- Staging only: basic-auth wall + noindex --- +# Generate the hash with: +# docker run --rm caddy:2-alpine caddy hash-password --plaintext 'your-password' +STAGING_BASIC_AUTH_USER=staging +STAGING_BASIC_AUTH_HASH= + +# --- Backups (deploy/backup.sh) --- +BACKUP_DIR=./backups +BACKUP_REMOTE= +BACKUP_RETENTION_DAYS=14 + +# --- Supplier adapters (design doc §04) --- +# staging: gutenberg_test production: booksrun,ingram +ACTIVE_SUPPLIER_ADAPTERS=gutenberg_test +BOOKSRUN_API_KEY= +INGRAM_API_KEY= +INGRAM_ACCOUNT_ID= + +# --- Payments (Helcim) --- +HELCIM_API_TOKEN= +HELCIM_ACCOUNT_ID= + +# --- Marketing --- +MAILERLITE_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c4fc3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.env +/.env.* +!/.env.example + +/backups/ +vendor/ +node_modules/ +*.log +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..76c39b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +ENV ?= dev +COMPOSE = docker compose -f docker-compose.yml -f docker-compose.$(ENV).yml --env-file .env.$(ENV) + +.PHONY: up down ps logs shell wp deploy backup + +up: + $(COMPOSE) up -d --build + +down: + $(COMPOSE) down + +ps: + $(COMPOSE) ps + +logs: + $(COMPOSE) logs -f + +shell: + $(COMPOSE) exec wordpress bash + +# make wp ENV=staging ARGS="plugin list" +wp: + $(COMPOSE) exec wordpress wp $(ARGS) --allow-root + +deploy: + ./deploy/deploy.sh $(ENV) + +backup: + ./deploy/backup.sh $(ENV) diff --git a/README.md b/README.md new file mode 100644 index 0000000..abd82b9 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Bookstore + +WordPress + WooCommerce bookstore. Design reference: see `docs/` for the +pre-launch plan and the technical design doc (`bookstore-core` schema, +interfaces, order state machine — the design doc is the source of truth for +*why* this repo is laid out the way it is). + +This week's scope (Week 1 of the 5-week plan): environments, deploy +pipeline, and the WooCommerce/HPOS plugin scaffold. Catalog schema, pricing, +supplier adapters, and the order state machine are Week 2+ and live under +`wp-content/plugins/bookstore-core/includes/`, currently empty. + +## Layout + +``` +docker-compose.yml base services: db, redis, wordpress, cron +docker-compose.{dev,staging,production}.yml per-environment overrides (caddy, mailhog) +docker/php/ app image: WP core image + wp-cli, composer, redis ext +docker/caddy/ one Caddyfile per environment +docker/cron/ Action Scheduler driver (system cron has no host to run on in Docker) +deploy/deploy.sh idempotent bring-up + WP/WooCommerce config +deploy/hooks/post-receive git-server hook: push to `staging`/`main` deploys +deploy/backup.sh, restore.sh off-host backup; restore is staging-only, on purpose +wp-content/plugins/bookstore-core/ the one plugin that owns business logic +wp-content/mu-plugins/ local-mail-catcher.php — routes mail to MailHog outside production +``` + +Same code runs in every environment; only `.env.` and which +`docker-compose..yml` you layer in differ (design doc §01). + +## Local development + +``` +cp .env.example .env.dev # fill in DB_PASSWORD etc.; localhost values are fine +make up ENV=dev +make deploy ENV=dev # installs WP, WooCommerce, activates bookstore-core, enables HPOS +``` + +Site is at `http://localhost:8080`. `make wp ENV=dev ARGS="plugin list"` runs +any wp-cli command; `make logs ENV=dev` tails everything; `make shell ENV=dev` +drops into the app container. + +## Staging (this is what goes on your Docker server) + +``` +cp .env.example .env.staging +# fill in SITE_DOMAIN, SITE_URL, DB_*, and: +docker run --rm caddy:2-alpine caddy hash-password --plaintext 'pick-a-password' +# -> paste result into STAGING_BASIC_AUTH_HASH + +make deploy ENV=staging +``` + +Staging is `noindex`'d and sits behind HTTP basic auth (Caddyfile.staging) +in addition to `blog_public=0` — two independent reasons search engines and +random visitors won't see it, per the launch gate. Mail never leaves the +box: it's caught by MailHog, viewable at `:8025`. + +### Theme: Blocksy + the Book Store starter site + +`deploy.sh` installs and activates the **Blocksy** theme and the free +**Blocksy Companion** plugin from wordpress.org automatically — nothing to +do here, in any environment. + +The **Book Store starter site** itself is a paid Companion Pro template +(Business plan, $99/year — design doc Appendix A), so it can't be scripted +against a public API the way the free theme can. One-time manual step per +environment, in wp-admin: + +1. Purchase/retrieve the Blocksy Business license key. +2. **Blocksy → General → License** → activate it. +3. **Blocksy → Extensions → Starter Sites** (Companion Pro) → import + **Book Store**. + +After that, the starter site's content and Customizer settings persist in +the database like any other WordPress content — a redeploy or a fresh +`deploy.sh` run doesn't touch it or need to repeat it. + +## Deploy pipeline + +This assumes your own bare Git server, not a hosted CI. The mechanism: + +1. A bare repo lives on the server (e.g. `/srv/git/bookstore.git`). +2. `deploy/hooks/post-receive` is copied into `.git/hooks/` and + made executable. Edit `DEPLOY_ROOT` at the top of it first. +3. Pushing to `staging` checks that branch out into + `$DEPLOY_ROOT/staging` and runs `deploy.sh staging`; pushing to `main` + deploys `$DEPLOY_ROOT/production` the same way. +4. `deploy.sh` is idempotent — it's safe to re-run and safe to be the thing + the hook calls on every push. + +You can also just run `./deploy/deploy.sh ` by hand from a checked-out +copy on the server; the hook is only automation on top of the same script. + +## Backups + +``` +./deploy/backup.sh staging # or production +``` + +Dumps the database and archives `wp-content/uploads`, gzips both, and syncs +to `$BACKUP_REMOTE` via `rclone` if set (configure `rclone config` on the +server first — this repo doesn't manage remote credentials). Before launch, +run a real restore drill: + +``` +./deploy/restore.sh ./backups/production/db-.sql.gz ./backups/production/uploads-.tar.gz +``` + +`restore.sh` only ever targets staging — there's no production argument, so +a restore drill can't accidentally overwrite the live site. + +## What's deliberately not here yet + +- Catalog tables, pricing engine, supplier adapters, order state machine — + Week 2–4, see the design doc. +- A real TLS-terminating public domain — Caddy will auto-provision certs + once `SITE_DOMAIN` points at a real host with 80/443 reachable; until + then `docker-compose.dev.yml` is the only one that works from + `localhost`. +- CI test/lint automation — nothing here runs tests yet because there's no + application code to test yet. diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..6c49165 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Off-host database + uploads backup (design doc §01, launch gate: "A full +# backup has been restored successfully into staging" — see restore.sh). +set -euo pipefail + +ENVIRONMENT="${1:?Usage: backup.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +ENV_FILE=".env.${ENVIRONMENT}" +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_DIR="${BACKUP_DIR:-./backups}/${ENVIRONMENT}" +mkdir -p "$BACKUP_DIR" + +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" + +echo "==> dumping database" +$COMPOSE exec -T db sh -c "exec mysqldump -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" \ + | gzip > "$BACKUP_DIR/db-${TIMESTAMP}.sql.gz" + +echo "==> archiving uploads" +$COMPOSE run --rm -T wordpress tar -czf - -C /var/www/html/wp-content uploads \ + > "$BACKUP_DIR/uploads-${TIMESTAMP}.tar.gz" + +if [[ -n "${BACKUP_REMOTE:-}" ]]; then + echo "==> syncing to off-host storage ($BACKUP_REMOTE)" + rclone copy "$BACKUP_DIR/db-${TIMESTAMP}.sql.gz" "$BACKUP_REMOTE/${ENVIRONMENT}/" + rclone copy "$BACKUP_DIR/uploads-${TIMESTAMP}.tar.gz" "$BACKUP_REMOTE/${ENVIRONMENT}/" +else + echo "==> BACKUP_REMOTE not set — backup stayed local only; configure rclone before launch" +fi + +echo "==> pruning local backups older than ${BACKUP_RETENTION_DAYS:-14} days" +find "$BACKUP_DIR" -type f -mtime "+${BACKUP_RETENTION_DAYS:-14}" -delete + +echo "==> backup complete: $BACKUP_DIR" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..5bbbcb0 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Builds and brings up an environment, then makes sure WordPress/WooCommerce +# are installed and configured to match design doc §01/§03/§07. Safe to run +# repeatedly — every step is idempotent. +set -euo pipefail + +ENVIRONMENT="${1:?Usage: deploy.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +case "$ENVIRONMENT" in + dev|staging|production) ;; + *) echo "environment must be 'dev', 'staging', or 'production'" >&2; exit 1 ;; +esac + +ENV_FILE=".env.${ENVIRONMENT}" +if [[ ! -f "$ENV_FILE" ]]; then + echo "missing $ENV_FILE — copy .env.example to $ENV_FILE and fill it in" >&2 + exit 1 +fi +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.${ENVIRONMENT}.yml --env-file ${ENV_FILE}" + +echo "==> building and starting ${ENVIRONMENT}" +$COMPOSE up -d --build + +echo "==> waiting for the wordpress container to come up" +for i in $(seq 1 30); do + if $COMPOSE exec -T wordpress php -v >/dev/null 2>&1; then + break + fi + if [[ "$i" -eq 30 ]]; then + echo "wordpress container did not become ready in time" >&2 + exit 1 + fi + sleep 2 +done + +echo "==> ensuring WordPress is installed" +if ! $COMPOSE exec -T wordpress wp core is-installed --allow-root; then + $COMPOSE exec -T wordpress wp core install \ + --url="${SITE_URL:?set SITE_URL in ${ENV_FILE}}" \ + --title="${SITE_TITLE:-Bookstore}" \ + --admin_user="${WP_ADMIN_USER:?set WP_ADMIN_USER in ${ENV_FILE}}" \ + --admin_password="${WP_ADMIN_PASSWORD:?set WP_ADMIN_PASSWORD in ${ENV_FILE}}" \ + --admin_email="${WP_ADMIN_EMAIL:?set WP_ADMIN_EMAIL in ${ENV_FILE}}" \ + --skip-email \ + --allow-root +fi + +echo "==> installing/activating theme (Blocksy — free; Book Store starter site needs a manual license + import, see README)" +$COMPOSE exec -T wordpress wp theme install blocksy --activate --allow-root + +echo "==> installing/activating required plugins" +$COMPOSE exec -T wordpress wp plugin install woocommerce redis-cache blocksy-companion --activate --allow-root +$COMPOSE exec -T wordpress wp plugin activate bookstore-core --allow-root +$COMPOSE exec -T wordpress wp redis enable --allow-root || true + +echo "==> enabling WooCommerce HPOS (custom order tables)" +$COMPOSE exec -T wordpress wp option update woocommerce_custom_orders_table_enabled yes --allow-root +$COMPOSE exec -T wordpress wp option update woocommerce_custom_orders_table_data_sync_enabled yes --allow-root + +if [[ "$ENVIRONMENT" != "production" ]]; then + echo "==> discouraging search engines (non-production)" + $COMPOSE exec -T wordpress wp option update blog_public 0 --allow-root +fi + +echo "==> flushing caches and rewrite rules" +$COMPOSE exec -T wordpress wp cache flush --allow-root +$COMPOSE exec -T wordpress wp rewrite flush --allow-root + +echo "==> deploy complete: ${ENVIRONMENT}" diff --git a/deploy/hooks/post-receive b/deploy/hooks/post-receive new file mode 100755 index 0000000..29374ec --- /dev/null +++ b/deploy/hooks/post-receive @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Install this at .git/hooks/post-receive on your own Git server +# (chmod +x it) and adjust DEPLOY_ROOT below. Pushing to `staging` deploys +# to the staging checkout; pushing to `main` deploys to production. +# +# git remote add origin @:/srv/git/bookstore.git +# git push origin staging +# +set -euo pipefail + +DEPLOY_ROOT="/srv/bookstore" + +while read -r oldrev newrev refname; do + branch="${refname#refs/heads/}" + + case "$branch" in + staging) target="$DEPLOY_ROOT/staging"; env="staging" ;; + main) target="$DEPLOY_ROOT/production"; env="production" ;; + *) echo "post-receive: ignoring push to $branch"; continue ;; + esac + + echo "post-receive: deploying $branch -> $env ($target)" + mkdir -p "$target" + git --work-tree="$target" --git-dir="$(pwd)" checkout -f "$branch" + ( cd "$target" && ./deploy/deploy.sh "$env" ) +done diff --git a/deploy/restore.sh b/deploy/restore.sh new file mode 100755 index 0000000..e301b6a --- /dev/null +++ b/deploy/restore.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Restores a backup INTO STAGING ONLY. Deliberately has no production path — +# the launch gate is "a full backup has been restored into staging," never +# production overwritten by a drill. +set -euo pipefail + +DB_DUMP="${1:?Usage: restore.sh }" +UPLOADS_ARCHIVE="${2:?Usage: restore.sh }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +ENV_FILE=".env.staging" +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.staging.yml --env-file ${ENV_FILE}" + +echo "!! this OVERWRITES the staging database and uploads !!" +read -r -p "type 'restore' to continue: " confirm +[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; } + +echo "==> restoring database" +gunzip -c "$DB_DUMP" | $COMPOSE exec -T db sh -c "exec mysql -u\"\$MARIADB_USER\" -p\"\$MARIADB_PASSWORD\" \"\$MARIADB_DATABASE\"" + +echo "==> restoring uploads" +ARCHIVE_DIR="$(cd "$(dirname "$UPLOADS_ARCHIVE")" && pwd)" +ARCHIVE_NAME="$(basename "$UPLOADS_ARCHIVE")" +$COMPOSE run --rm -T -v "${ARCHIVE_DIR}:/restore:ro" wordpress \ + tar -xzf "/restore/${ARCHIVE_NAME}" -C /var/www/html/wp-content + +echo "==> restore complete — verify the site before treating the drill as passed" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..b31dc68 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,29 @@ +x-env-type: &env-type + WP_ENVIRONMENT_TYPE: local + +services: + wordpress: + environment: *env-type + + cron: + environment: *env-type + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - wordpress + ports: + - "8080:80" + volumes: + - ./docker/caddy/Caddyfile.dev:/etc/caddy/Caddyfile:ro + - wp_core:/var/www/html:ro + - wp_uploads:/var/www/html/wp-content/uploads:ro + - wp_themes:/var/www/html/wp-content/themes:ro + - ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..781bdd4 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,32 @@ +x-env-type: &env-type + WP_ENVIRONMENT_TYPE: production + +services: + wordpress: + environment: *env-type + + cron: + environment: *env-type + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - wordpress + ports: + - "80:80" + - "443:443" + environment: + SITE_DOMAIN: ${SITE_DOMAIN} + volumes: + - ./docker/caddy/Caddyfile.production:/etc/caddy/Caddyfile:ro + - wp_core:/var/www/html:ro + - wp_uploads:/var/www/html/wp-content/uploads:ro + - wp_themes:/var/www/html/wp-content/themes:ro + - ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..bd19f5b --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,40 @@ +x-env-type: &env-type + WP_ENVIRONMENT_TYPE: staging + +services: + wordpress: + environment: *env-type + + cron: + environment: *env-type + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - wordpress + ports: + - "80:80" + - "443:443" + environment: + SITE_DOMAIN: ${SITE_DOMAIN} + STAGING_BASIC_AUTH_USER: ${STAGING_BASIC_AUTH_USER} + STAGING_BASIC_AUTH_HASH: ${STAGING_BASIC_AUTH_HASH} + volumes: + - ./docker/caddy/Caddyfile.staging:/etc/caddy/Caddyfile:ro + - wp_core:/var/www/html:ro + - wp_uploads:/var/www/html/wp-content/uploads:ro + - wp_themes:/var/www/html/wp-content/themes:ro + - ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core:ro + - caddy_data:/data + - caddy_config:/config + + mailhog: + image: mailhog/mailhog:v1.0.1 + restart: unless-stopped + ports: + - "8025:8025" + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..127ed95 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +# wordpress and cron are the same image and MUST share the same +# environment: the official image's wp-config.php reads getenv() live, at +# PHP runtime, in whichever container is executing — it does not bake +# values into the file once. If cron's env drifts from wordpress's (e.g. +# WORDPRESS_CONFIG_EXTRA missing), cron silently loses WP_REDIS_HOST, +# DISABLE_WP_CRON, etc. The anchor below is what prevents that drift. +x-bookstore-env: &bookstore-env + WORDPRESS_DB_HOST: db + WORDPRESS_DB_NAME: ${DB_NAME} + WORDPRESS_DB_USER: ${DB_USER} + WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} + WORDPRESS_TABLE_PREFIX: ${WP_TABLE_PREFIX:-wp_} + MAIL_CATCHER_HOST: mailhog + MAIL_CATCHER_PORT: 1025 + WORDPRESS_CONFIG_EXTRA: | + define('DISABLE_WP_CRON', true); + define('WP_REDIS_HOST', 'redis'); + define('WP_MEMORY_LIMIT', '256M'); + # Passed through for the bookstore-core plugin's supplier adapters / + # payment integration (design doc §04/§05) — not read by WordPress core. + ACTIVE_SUPPLIER_ADAPTERS: ${ACTIVE_SUPPLIER_ADAPTERS:-gutenberg_test} + BOOKSRUN_API_KEY: ${BOOKSRUN_API_KEY:-} + INGRAM_API_KEY: ${INGRAM_API_KEY:-} + INGRAM_ACCOUNT_ID: ${INGRAM_ACCOUNT_ID:-} + HELCIM_API_TOKEN: ${HELCIM_API_TOKEN:-} + HELCIM_ACCOUNT_ID: ${HELCIM_ACCOUNT_ID:-} + MAILERLITE_API_KEY: ${MAILERLITE_API_KEY:-} + +services: + db: + image: mariadb:11 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u$$MARIADB_USER -p$$MARIADB_PASSWORD --silent"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + wordpress: + build: + context: ./docker/php + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: *bookstore-env + volumes: + - wp_core:/var/www/html + - wp_uploads:/var/www/html/wp-content/uploads + - wp_themes:/var/www/html/wp-content/themes + - ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core + - ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins + + cron: + build: + context: ./docker/php + restart: unless-stopped + depends_on: + - wordpress + entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"] + environment: *bookstore-env + volumes: + - wp_core:/var/www/html + - wp_uploads:/var/www/html/wp-content/uploads + - wp_themes:/var/www/html/wp-content/themes + - ./wp-content/plugins/bookstore-core:/var/www/html/wp-content/plugins/bookstore-core + - ./wp-content/mu-plugins:/var/www/html/wp-content/mu-plugins + - ./docker/cron/entrypoint.sh:/usr/local/bin/cron-entrypoint.sh:ro + +volumes: + db_data: + redis_data: + wp_core: + wp_uploads: + wp_themes: diff --git a/docker/caddy/Caddyfile.dev b/docker/caddy/Caddyfile.dev new file mode 100644 index 0000000..f435659 --- /dev/null +++ b/docker/caddy/Caddyfile.dev @@ -0,0 +1,10 @@ +:80 { + root * /var/www/html + + request_body { + max_size 32MB + } + + php_fastcgi wordpress:9000 + file_server +} diff --git a/docker/caddy/Caddyfile.production b/docker/caddy/Caddyfile.production new file mode 100644 index 0000000..bbb3293 --- /dev/null +++ b/docker/caddy/Caddyfile.production @@ -0,0 +1,22 @@ +{$SITE_DOMAIN} { + encode gzip + + root * /var/www/html + + @no_cache path /cart* /checkout* /my-account* /wp-admin* /wp-login.php + header @no_cache Cache-Control "no-store, no-cache, must-revalidate" + + request_body { + max_size 32MB + } + + php_fastcgi wordpress:9000 + file_server + + log { + output file /data/access.log { + roll_size 50MiB + roll_keep 10 + } + } +} diff --git a/docker/caddy/Caddyfile.staging b/docker/caddy/Caddyfile.staging new file mode 100644 index 0000000..649f008 --- /dev/null +++ b/docker/caddy/Caddyfile.staging @@ -0,0 +1,22 @@ +{$SITE_DOMAIN} { + encode gzip + + # Launch gate: "No staging URLs are publicly indexed." Belt-and-suspenders + # with wp_option blog_public=0, which deploy.sh sets on staging. + basic_auth { + {$STAGING_BASIC_AUTH_USER} {$STAGING_BASIC_AUTH_HASH} + } + header X-Robots-Tag "noindex, nofollow, noarchive" + + root * /var/www/html + + @no_cache path /cart* /checkout* /my-account* /wp-admin* /wp-login.php + header @no_cache Cache-Control "no-store, no-cache, must-revalidate" + + request_body { + max_size 32MB + } + + php_fastcgi wordpress:9000 + file_server +} diff --git a/docker/cron/entrypoint.sh b/docker/cron/entrypoint.sh new file mode 100755 index 0000000..01c8d84 --- /dev/null +++ b/docker/cron/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# System-cron replacement for Action Scheduler / WP-Cron (design doc §01). +# WORDPRESS_CONFIG_EXTRA sets DISABLE_WP_CRON, so this loop is the only +# thing driving scheduled events in every environment. +set -eu + +echo "bookstore-core cron sidecar starting (60s interval)" + +while true; do + if ! wp cron event run --due-now --path=/var/www/html --allow-root 2>/tmp/cron-last-error.log; then + echo "cron run failed: $(cat /tmp/cron-last-error.log)" + fi + sleep 60 +done diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..87beea5 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,23 @@ +# PHP-FPM + WordPress core, plus the tooling deploy.sh and the cron +# sidecar need: wp-cli, composer, and the redis extension for object cache. +FROM wordpress:php8.3-fpm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + unzip \ + less \ + default-mysql-client \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +RUN curl -o /usr/local/bin/wp -sSL \ + https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \ + && chmod +x /usr/local/bin/wp + +COPY php.ini /usr/local/etc/php/conf.d/zz-bookstore.ini + +WORKDIR /var/www/html diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..8471b91 --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,13 @@ +[PHP] +upload_max_filesize = 32M +post_max_size = 32M +memory_limit = 256M +max_execution_time = 120 +max_input_vars = 3000 + +[opcache] +opcache.enable = 1 +opcache.memory_consumption = 128 +opcache.max_accelerated_files = 10000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 2 diff --git a/docs/online_bookstore_prelaunch_implementation_plan.docx b/docs/online_bookstore_prelaunch_implementation_plan.docx new file mode 100644 index 0000000..ec5016b Binary files /dev/null and b/docs/online_bookstore_prelaunch_implementation_plan.docx differ diff --git a/wp-content/mu-plugins/local-mail-catcher.php b/wp-content/mu-plugins/local-mail-catcher.php new file mode 100644 index 0000000..7a729f2 --- /dev/null +++ b/wp-content/mu-plugins/local-mail-catcher.php @@ -0,0 +1,22 @@ +isSMTP(); + $phpmailer->Host = getenv('MAIL_CATCHER_HOST') ?: 'mailhog'; + $phpmailer->Port = getenv('MAIL_CATCHER_PORT') ?: 1025; + $phpmailer->SMTPAuth = false; + $phpmailer->SMTPAutoTLS = false; +}); diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php new file mode 100644 index 0000000..ff32de4 --- /dev/null +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -0,0 +1,38 @@ +=8.1" + }, + "autoload": { + "psr-4": { + "Bookstore\\Core\\": "includes/" + } + } +} diff --git a/wp-content/plugins/bookstore-core/includes/.gitkeep b/wp-content/plugins/bookstore-core/includes/.gitkeep new file mode 100644 index 0000000..e69de29