Copier template for WordPress deployments on DigitalOcean droplets with Docker, Caddy, and OpenTofu.
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. |
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
- Copier >= 9.0
- OpenTofu >= 1.5 (or Terraform)
- DigitalOcean account
- Minimus account (for container images)
# 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# 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"| Style | Primary URL | Behavior |
|---|---|---|
apex |
https://example.com |
. redirects to apex |
www |
https://www.example.com |
apex redirects to www. |
The template auto-configures Wordfence Web Application Firewall for Caddy + PHP-FPM:
- Creates
.user.iniwithauto_prepend_filedirective - Blocks direct web access to
.user.iniin Caddyfile - Works with Caddy (which doesn't support
.htaccesslike Apache)
See Wordfence WAF README for details.
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).
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_*byitofu.sh, never written toterraform.tfvars. - In-container secret fetch β
infisical runis the container entrypoint, fetching secrets in-process at every container start. Secrets are NOT in the composeenvironment:block, so they don't appear indocker inspect. - Bounce-to-refresh β
docker restartre-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 restartpicks it up. - Multi-container secret duplication β MariaDB and WordPress each see secrets under their expected env var names (e.g.,
MYSQL_PASSWORDfor MariaDB,WORDPRESS_DB_PASSWORDfor WordPress). Same values, different names in Infisical.
See Infisical Integration for setup instructions.
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
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)
Every generated site includes four scripts under scripts/. Here's when to use each:
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-importHow it works:
- SSHes to production and dumps the DB using
docker inspectto get the real container password (not.envβ they can diverge if.envwas updated after the container started) - SCPs the dump to
./wordpress.sql(gitignored) - Streams
wp-contentdirectly from prod container to local container - Imports the DB and rewrites
siteurl/hometohttp://localhost:8080
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.shThe backup script reads the DB password from
docker inspecton the running container, not from.env. This ensures the dump succeeds even if.envwas changed after the container was started.
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.gzLocal restore automatically:
- Imports the DB using local
.envcredentials - Rewrites
siteurl/hometohttp://localhost:8080 - Restores wp-content into the running container
Use when: Pushing code changes, config updates, or image upgrades to production.
INFISICAL_PROJECT_ID=<id> ./scripts/deploy.sh root@your-droplet-ipThe deploy script is a thin wrapper around ansible-playbook. It:
- Requires
INFISICAL_PROJECT_IDenv var - Auto-installs
community.docker==3.13.0collection if missing - Executes
ansible/deploy.ymlwhich uploads compose + Caddyfile + backup script + cron and reconciles the stack
| 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 |
Pre-generated configurations for existing sites:
| Site | Domain | Domain Style | Status |
|---|---|---|---|
| burnedout-xyz | burnedout.xyz | apex | Production |
| ptoken-agency | ptoken.agency | www | Production |
If you have existing standalone WordPress repos (like the original burnedout.xyz and ptoken.agency):
- Generate a new site with matching configuration
- Copy terraform state files (
terraform.tfstate*) - Update
terraform.tfvarswith existing credentials - Deploy to verify compatibility
- Archive the old repository
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
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
When modifying the template:
- Test with
copier copy . /tmp/test-site --data-file test-answers.yaml - Verify generated files render correctly
- Run
helm lint/tofu validateon generated output - Update documentation if adding features
# Validate template syntax
copier copy . /tmp/test --defaults --overwritecd generated-site/terraform
tofu init
tofu validate# Test Caddyfile locally
docker run --rm -v ./docker/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile- WeOwn AI Infrastructure
- DigitalOcean Droplet Docs
- Caddy Documentation
- OpenTofu Documentation
- Wordfence WAF
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.