Skip to content

Latest commit

Β 

History

History
375 lines (281 loc) Β· 13.7 KB

README.md

File metadata and controls

375 lines (281 loc) Β· 13.7 KB

wordpress-docker

Copier template for WordPress deployments on DigitalOcean droplets with Docker, Caddy, and OpenTofu.

Migration status (bootstrap pattern)

See docs/INFRA_BOOTSTRAP_PATTERN.md for the shared pattern + 6-step migration checklist. This project's state today:

Layer Status Notes
Layer 1 (DO Spaces remote state) Done template/terraform/backend.tf.jinja + template/terraform/init.sh.jinja.
Layer 2 (bootstrap-secret rotation) Done rotate-bootstrap-secret.sh embedded in template/terraform/templates/cloud-init.yaml.jinja. Logs in with v1, mints v2 via Infisical API, atomically swaps the auth file, revokes v1.
Path C (thin cloud-init + ansible) Done Cloud-init handles only first-boot bootstrap. template/ansible/deploy.yml.jinja owns compose + Caddyfile + Wordfence WAF + backup script + cron + docker compose up. template/scripts/deploy.sh.jinja is a thin ansible-playbook wrapper.
Infisical CLI install Current β€” uses artifacts-cli.infisical.com apt repo.

Overview

This template generates production-ready WordPress infrastructure with:

  • Docker Compose stack (WordPress + MariaDB + Caddy)
  • OpenTofu/Terraform for DigitalOcean provisioning
  • Caddy reverse proxy with automatic TLS (Let's Encrypt)
  • Wordfence WAF auto-configuration for Caddy + PHP-FPM
  • Skinny backups (database + wp-content only, not full disk)
  • Infisical integration for secrets management (required)
  • DigitalOcean monitoring alerts

Quick Start

Prerequisites

Generate a New Site

# Install copier if needed
pipx install copier

# Generate a new site
copier copy ./wordpress-docker ../sites/my-new-site

# Or with answers file
copier copy ./wordpress-docker ../sites/my-new-site --data-file answers.yaml

Example Answers File

# answers.yaml
project_name: my-awesome-site
domain: awesome.com
domain_style: apex
do_region: nyc3
droplet_size: s-2vcpu-2gb-amd
enable_wordfence_waf: true
enable_skinny_backups: true
enable_monitoring: true
alert_email: alerts@awesome.com
infisical_project_id: "your-project-id"
infisical_environment: "prod"

Template Features

Domain Styles

Style Primary URL Behavior
apex https://example.com . redirects to apex
www https://www.example.com apex redirects to www.

Wordfence WAF

The template auto-configures Wordfence Web Application Firewall for Caddy + PHP-FPM:

  • Creates .user.ini with auto_prepend_file directive
  • Blocks direct web access to .user.ini in Caddyfile
  • Works with Caddy (which doesn't support .htaccess like Apache)

See Wordfence WAF README for details.

Skinny Backups

Instead of full disk snapshots (20% extra cost on DigitalOcean), this template uses "skinny backups":

  • Database dump (MariaDB)
  • wp-content/ (themes, plugins, uploads)
  • Configuration files (Caddyfile, .env, compose.yaml)
  • Container state information

Backups are compressed, stored locally, and can be pushed to remote storage (DO Spaces, S3).

Infisical Integration

Required secrets management via Infisical:

  • Store database credentials in Infisical (MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD, MYSQL_ROOT_PASSWORD, DOMAIN)
  • Cloud-init installs Infisical CLI and writes Machine Identity auth file
  • Layer 2 bootstrap-secret rotation (v1 β†’ v2) happens automatically on first boot

What this achieves (ADR-006):

  • Zero application secrets on disk β€” only the Machine Identity reaches the node (Layer 2 rotates even that on first boot); infra creds are injected as TF_VAR_* by itofu.sh, never written to terraform.tfvars.
  • In-container secret fetch β€” infisical run is the container entrypoint, fetching secrets in-process at every container start. Secrets are NOT in the compose environment: block, so they don't appear in docker inspect.
  • Bounce-to-refresh β€” docker restart re-fetches secrets from Infisical (no redeploy needed). This enables consumer-side auto-rotation: rotate a secret in Infisical, bounce the container, it loads the new value.
  • Centralized management β€” edit a secret in Infisical; the next docker restart picks it up.
  • Multi-container secret duplication β€” MariaDB and WordPress each see secrets under their expected env var names (e.g., MYSQL_PASSWORD for MariaDB, WORDPRESS_DB_PASSWORD for WordPress). Same values, different names in Infisical.

See Infisical Integration for setup instructions.

Directory Structure

wordpress-docker/
β”œβ”€β”€ copier.yaml              # Template configuration
β”œβ”€β”€ README.md                # This file
β”œβ”€β”€ docs/
β”‚   └── INFISICAL_INTEGRATION.md
└── template/
    β”œβ”€β”€ README.md.jinja      # Generated site README
    β”œβ”€β”€ CHANGELOG.md.jinja
    β”œβ”€β”€ .gitignore.jinja
    β”œβ”€β”€ docker/
    β”‚   β”œβ”€β”€ compose.prod.yaml.jinja
    β”‚   β”œβ”€β”€ compose.local.yaml.jinja
    β”‚   β”œβ”€β”€ Caddyfile.jinja
    β”‚   β”œβ”€β”€ Caddyfile.local
    β”‚   β”œβ”€β”€ .env.example
    β”‚   β”œβ”€β”€ .env.prod.example
    β”‚   └── wordfence-waf/
    β”‚       β”œβ”€β”€ .user.ini.jinja
    β”‚       └── README.md
    β”œβ”€β”€ scripts/
    β”‚   β”œβ”€β”€ deploy.sh.jinja
    β”‚   β”œβ”€β”€ backup.sh.jinja
    β”‚   β”œβ”€β”€ restore.sh.jinja
    β”‚   └── pull-prod.sh.jinja
    └── terraform/
        β”œβ”€β”€ backend.tf.jinja
        β”œβ”€β”€ init.sh.jinja
        β”œβ”€β”€ main.tf.jinja
        β”œβ”€β”€ variables.tf.jinja
        β”œβ”€β”€ outputs.tf.jinja
        β”œβ”€β”€ monitoring.tf.jinja
        β”œβ”€β”€ versions.tf.jinja
        β”œβ”€β”€ terraform.tfvars.example.jinja
        └── templates/
            └── cloud-init.yaml.jinja

Generated Site Structure

After running copier copy, you'll have:

my-new-site/
β”œβ”€β”€ README.md                 # Site-specific documentation
β”œβ”€β”€ CHANGELOG.md              # Version history
β”œβ”€β”€ .gitignore.jinja
β”œβ”€β”€ docker/
β”‚   β”œβ”€β”€ compose.prod.yaml     # Production Docker Compose
β”‚   β”œβ”€β”€ compose.local.yaml    # Local development
β”‚   β”œβ”€β”€ Caddyfile             # Production Caddy config
β”‚   β”œβ”€β”€ Caddyfile.local       # Local Caddy (HTTP only)
β”‚   β”œβ”€β”€ .env.example
β”‚   β”œβ”€β”€ .env.prod.example
β”‚   └── wordfence-waf/
β”‚       β”œβ”€β”€ .user.ini         # Wordfence WAF config
β”‚       └── README.md
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ deploy.sh             # Deploy updates
β”‚   β”œβ”€β”€ backup.sh             # Create backups
β”‚   β”œβ”€β”€ restore.sh            # Restore from backup
β”‚   └── pull-prod.sh          # Pull production data to local dev
β”œβ”€β”€ terraform/
β”‚   β”œβ”€β”€ backend.tf             # DO Spaces remote state backend
β”‚   β”œβ”€β”€ init.sh                # Reads Spaces creds from tfvars, runs tofu init
β”‚   β”œβ”€β”€ main.tf                # Infrastructure
β”‚   β”œβ”€β”€ variables.tf
β”‚   β”œβ”€β”€ outputs.tf
β”‚   β”œβ”€β”€ monitoring.tf
β”‚   β”œβ”€β”€ versions.tf.jinja
β”‚   β”œβ”€β”€ terraform.tfvars.example
β”‚   └── templates/
β”‚       └── cloud-init.yaml
β”œβ”€β”€ backups/                  # Local backup storage
└── logs/                     # Local logs (gitignored)

Day-to-Day Operations

Every generated site includes four scripts under scripts/. Here's when to use each:

pull-prod.sh β€” Pull Production to Local Dev

Use when: Starting local development, debugging a production issue, or validating a change before applying it to production.

# Full pull: DB + wp-content (overwrites local stack)
./scripts/pull-prod.sh

# DB only (faster; skips large wp-content transfer)
./scripts/pull-prod.sh --db-only

# wp-content only
./scripts/pull-prod.sh --content-only

# Download dump but don't import (local stack may be down)
./scripts/pull-prod.sh --no-import

How it works:

  1. SSHes to production and dumps the DB using docker inspect to get the real container password (not .env β€” they can diverge if .env was updated after the container started)
  2. SCPs the dump to ./wordpress.sql (gitignored)
  3. Streams wp-content directly from prod container to local container
  4. Imports the DB and rewrites siteurl/home to http://localhost:8080

backup.sh β€” Snapshot Backup

Use when: Before any risky change, or on a schedule (cron). Creates a compressed archive of DB + wp-content + config on the droplet.

# From your machine β€” prompts to download after
./scripts/backup.sh root@your-droplet-ip

# On the droplet directly (cron job)
./scripts/backup.sh

The backup script reads the DB password from docker inspect on the running container, not from .env. This ensures the dump succeeds even if .env was changed after the container was started.

restore.sh β€” Restore from Backup

Use when: Disaster recovery, rolling back after a bad deployment, or testing a backup before applying it to production.

# Restore to production
./scripts/restore.sh root@your-droplet-ip ./backups/backup-20260501.tar.gz

# Restore to local stack (test the backup first)
./scripts/restore.sh local ./backups/backup-20260501.tar.gz

Local restore automatically:

  • Imports the DB using local .env credentials
  • Rewrites siteurl/home to http://localhost:8080
  • Restores wp-content into the running container

deploy.sh β€” Deploy Updates

Use when: Pushing code changes, config updates, or image upgrades to production.

INFISICAL_PROJECT_ID=<id> ./scripts/deploy.sh root@your-droplet-ip

The deploy script is a thin wrapper around ansible-playbook. It:

  • Requires INFISICAL_PROJECT_ID env var
  • Auto-installs community.docker==3.13.0 collection if missing
  • Executes ansible/deploy.yml which uploads compose + Caddyfile + backup script + cron and reconciles the stack

Script Quick Reference

Script Runs on When to use
pull-prod.sh Local Local dev with real data; debug prod issues locally
backup.sh Local or droplet Before risky changes; scheduled snapshots
restore.sh Local or droplet Disaster recovery; rollback; test a backup
deploy.sh Local Deploy changes to production

Existing Sites

Pre-generated configurations for existing sites:

Site Domain Domain Style Status
burnedout-xyz burnedout.xyz apex Production
ptoken-agency ptoken.agency www Production

Migration from Standalone Repos

If you have existing standalone WordPress repos (like the original burnedout.xyz and ptoken.agency):

  1. Generate a new site with matching configuration
  2. Copy terraform state files (terraform.tfstate*)
  3. Update terraform.tfvars with existing credentials
  4. Deploy to verify compatibility
  5. Archive the old repository

Compliance

This template follows WeOwn AI Infrastructure standards:

  • SOC2: Encrypted credentials, audit logging, backup procedures
  • ISO/IEC 42001: Documented deployment, change management
  • Security: TLS 1.3, security headers, WAF ready, no root containers

Infisical Outage Procedures

If Infisical Cloud becomes unavailable, deployments and backups will fail. See INFISICAL_OUTAGE_RUNBOOK.md for emergency procedures including:

  • Manual deployment without Infisical
  • Local-only backup creation
  • Emergency restore procedures
  • Recovery steps when Infisical comes back online

Contributing

When modifying the template:

  1. Test with copier copy . /tmp/test-site --data-file test-answers.yaml
  2. Verify generated files render correctly
  3. Run helm lint / tofu validate on generated output
  4. Update documentation if adding features

Troubleshooting

Copier Template Errors

# Validate template syntax
copier copy . /tmp/test --defaults --overwrite

Generated Terraform Invalid

cd generated-site/terraform
tofu init
tofu validate

Caddyfile Syntax Errors

# Test Caddyfile locally
docker run --rm -v ./docker/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile

Related Documentation


Secret injection pattern

Secrets reach this service at runtime via Infisical. The standard is documented in docs/INFRA_BOOTSTRAP_PATTERN.md β†’ Runtime secret injection and .github/ADR-006-in-container-infisical-injection.md: host-side infisical run wrap today (refresh on redeploy, not on a bare docker restart) β†’ moving toward in-container infisical run for bounce-to-refresh, with auto-reload, automatic rotation, single-use tokens, and a clean K8s/K3s migration. No app secrets on disk or in git (D247); only the project-scoped Machine Identity lives on the node.