Skip to content

Repository files navigation

WordPress + Docker

A Docker Compose stack for WordPress — Nginx + PHP-FPM + MariaDB — built for local development and ready to run in production as a self-hosted deployment (Cloudflare Tunnel, hardened config, automated backups).


Going to production — pick ONE path

This template supports two independent production paths. They are not combined — pick the one that matches your hosting.

FTP Theme Deploy Self-Hosted (Cloudflare Tunnel)
What ships Only the theme + a DB dump, in a separate, leaner repo This entire repo, running as a live Docker stack
Where it runs Existing classic/shared FTP hosting Any server you control (VPS, homelab, NAS…) that can run Docker
Who serves traffic Your hosting provider (its own TLS/webserver) The nginx/php/db stack here, fronted by a cloudflared tunnel — no open ports, TLS at Cloudflare's edge
Details "Production Repo Workflow" below "Self-Hosted Production Release" below

If you're not sure: use FTP Theme Deploy if you already have shared hosting that only accepts FTP uploads; use Self-Hosted (Cloudflare Tunnel) if you want to run this same Docker stack live yourself.


Project Structure

├── .github/
│   └── workflows/
│       ├── release.yml          # Creates ZIP release on push to main
├── docker-compose.yml           # Services orchestration
├── docker-compose.prod.yml      # Production override: adds cloudflared, backup, hardened Nginx config
├── .env                         # Environment variables (generated by setup.sh)
├── .env.example                 # Environment variables template
├── .gitignore
├── Makefile                     # Quick commands
├── setup.sh                     # Interactive initialization script
├── logs/                        # Nginx logs (generated by setup.sh)
├── backups/                     # Backup output (generated by setup.sh, gitignored)
├── scripts/
│   ├── backup.sh                # Dumps db + archives public_html/, retention, optional remote sync
│   └── backup-restore.sh        # Restores db + files from a backup set (asks for confirmation)
├── backup/
│   ├── Dockerfile                # Alpine + mariadb-client + rsync + rclone image for the container scheduler
│   └── entrypoint.sh             # Writes BACKUP_SCHEDULE_CRON and runs crond in foreground
├── nginx/
│   ├── www.conf                 # Nginx virtual host configuration (local dev)
│   └── production.conf          # Nginx virtual host configuration (production, behind Cloudflare Tunnel)
├── php/
│   ├── Dockerfile               # Custom PHP-FPM image
│   └── php.ini                  # Custom PHP configuration
├── public_html/                 # WordPress webroot (generated by setup.sh)
├── src/
│   └── deploy.yml               # Deploy via FTP on push to main (modified and moved to ./github/workflows by setup.sh)
│   └── gitignore                # .gitignore file for deploy (moved to ./gitignore by setup.sh)
└── README.md

Requirements

  • Docker with the Compose plugin (v2) or docker-compose v1
  • curl
  • unzip
  • make

Installation

Option A — From Release (recommended)

  1. Download the latest .zip from the Releases page of the repository
  2. Extract the folder
  3. Run the setup script:
chmod +x setup.sh
./setup.sh

Option B — From Clone

git clone <repository-url>
cd <project-name>
chmod +x setup.sh
./setup.sh

Interactive Setup

The setup.sh script guides the installation step by step:

  1. Checks prerequisites (docker, curl, unzip)
  2. Asks for configuration parameters and generates the .env file
  3. Creates the public_html/, logs/ and .github/workflows directories
  4. Downloads the latest version of WordPress into public_html/
  5. Asks the theme path for deploy.yml file and move it to .github/workflows
  6. Move src/gitignore to .gitignore
  7. Asks whether to build and start the containers

Configure WordPress

On first access, the WordPress wizard will ask for database credentials. Use the values entered during setup:

Field Value
Database name value of MYSQL_DATABASE
User value of MYSQL_USER
Password value of MYSQL_PASSWORD
Database host db
Table prefix wp_ (or custom)

Environment Variables

The .env file is generated by setup.sh from .env.example.

Variable Description Default
PROJECT_NAME Project name / Compose stack my_website
NGINX_PORT Local port exposed by Nginx 8001
MYSQL_ROOT_PASSWORD MariaDB root password root
MYSQL_DATABASE WordPress database name my_website
MYSQL_USER Database user wpuser
MYSQL_PASSWORD Database user password wppassword
UID / GID Host user UID/GID (auto) detected
CLOUDFLARE_TUNNEL_TOKEN Production only — Cloudflare Tunnel token (empty)
PRODUCTION_DOMAIN Production only — public hostname routed through the tunnel (empty)
BACKUP_PATH Destination directory for backups ./backups
BACKUP_RETENTION_DAYS Days to keep backups before deletion 7
BACKUP_SCHEDULER host (cron on the host) or container (dedicated service in docker-compose.prod.yml) host
BACKUP_SCHEDULE_CRON Cron schedule used by the container scheduler 0 3 * * *
BACKUP_EXCLUDE_PATHS Space-separated paths (relative to public_html/) excluded from the files archive (empty)
BACKUP_REMOTE_PATH Optional remote destination for a post-backup sync — use s3:<bucket>/<prefix> for S3 (empty)
BACKUP_REMOTE_SYNC_CMD Sync command run as <cmd> <BACKUP_PATH> <BACKUP_REMOTE_PATH> rsync -az
BACKUP_REMOTE_FETCH_CMD Non-destructive copy command used by make backup-restore ... REMOTE=1 rclone copy
RCLONE_CONFIG_S3_* rclone remote s3 credentials/config (type, provider, access key, secret, region, endpoint) for S3 / S3-compatible storage (empty)

Docker Services

Service Image Description
nginx nginx:latest Web server / reverse proxy
php custom (php:8.3-fpm) PHP-FPM with WP extensions
db mariadb:lts MariaDB database

docker-compose.prod.yml adds two more services on top, only started via make prod-up:

Service Image Description
cloudflared cloudflare/cloudflared:latest Zero Trust Tunnel — reverse-proxies public traffic to nginx, no open ports
backup custom (backup/Dockerfile) Optional cron loop running scripts/backup.sh — only started when BACKUP_SCHEDULER=container (profile backup)

The site is accessible at http://localhost:<NGINX_PORT>.


Make Commands

make help           # Show all available commands

# Containers
make up             # Start containers
make down           # Stop containers
make restart        # Restart all containers
make ps             # Container status

# Env
make env-encrypt    # Encrypt the .env file with passphrase to upload to repository
make env-decrypt    # Extract the encrypted .env file from repository

# Logs
make logs           # All services
make logs-nginx
make logs-php
make logs-db

# Shell
make shell-php      # Bash in PHP container
make shell-db       # MariaDB client
make shell-nginx    # Shell in Nginx container

# WordPress
make wp-cli CMD="plugin list"  # Run WP-CLI commands
make wp-permissions  # Restore correct ownership/permissions on public_html/

# Database
make db-backup      # Dump in root (ready for commit in production repo)
make db-restore FILE=name_backup.sql

# Backup (db + files) — see "Backup & Restore" below
make backup                # Full backup (db + files) into BACKUP_PATH
make backup-db              # Database-only backup
make backup-files           # Files-only backup (public_html/)
make backup-restore FILE_DB=... FILE_FILES=... [REMOTE=1]  # ⚠️  Restore db + files (REMOTE=1 fetches from BACKUP_REMOTE_PATH first)
make backup-list            # List backups available in BACKUP_PATH
make backup-list-remote     # List backups available in BACKUP_REMOTE_PATH

# Cleanup
make clean          # Stop containers and remove logs
make nuke           # ⚠️  Remove containers, volumes and logs

# Production (Cloudflare Tunnel) — see "Self-Hosted Production Release" (path 2) below
make prod-up        # Start the production stack (hardened Nginx + Cloudflare Tunnel)
make prod-down      # Stop the production stack
make prod-restart   # Restart the production stack
make prod-logs      # Follow logs of the production stack (incl. cloudflared)
make prod-ps        # Production stack container status

Production Repo Workflow (path 1)

After setup, the developer initializes a separate repo that contains only the files to version and deploy:

├── .github/
│   └── workflows/
│       └── deploy.yml       # included in ZIP, ready to use
├── public_html/
├── my_website.sql           # dump generated by make db-backup
└── .gitignore

Daily Workflow

# 1. Export the database dump to the root of the production repo
make db-backup

# 2. In the production repo: commit and push
git add .
git commit -m "update"
git push origin main
# → deploy.yml triggers and deploys via FTP to production

Variables required by deploy.yml

To configure in the Settings → Secrets and variables of the production repo:

Type Name Description
Secret FTP_SERVER FTP server hostname
Secret FTP_USERNAME FTP username
Secret FTP_PASSWORD FTP password
Variable WORKING_DIRECTORY Local theme path (e.g. public_html/wp-content/themes/your-theme)
Variable FTP_SERVER_DIR Remote theme path on server (e.g. wp-content/themes/your-theme)
Variable FTP_PORT FTP port (e.g. 21)
Variable FTP_PROTOCOL Protocol (e.g. ftp or ftps)

Self-Hosted Production Release (Cloudflare Tunnel, path 2)

This is a separate, unrelated path from the FTP "Production Repo Workflow" above: instead of deploying a theme to shared hosting, this runs the same Docker stack from this repo in production, fronted by a Cloudflare Zero Trust Tunnel that reverse-proxies straight to the nginx container. No public ports need to be opened and no TLS certificates need to be managed locally — Cloudflare terminates TLS at its edge.

Full step-by-step instructions (creating the tunnel, dashboard configuration, wp-config.php hardening) are in the production-release Claude Code skill (.claude/skills/production-release/SKILL.md). Summary:

  1. In the Cloudflare Zero Trust dashboard, create a tunnel (connector type Cloudflared) and add a Public Hostname pointing to http://nginx:80.
  2. Copy the tunnel token into .env:
    CLOUDFLARE_TUNNEL_TOKEN=<token>
    PRODUCTION_DOMAIN=<your public hostname>
    
  3. Start it:
    make prod-up

This uses docker-compose.prod.yml as an override on top of docker-compose.yml — it adds a cloudflared service and swaps Nginx to nginx/production.conf (security headers, gzip, rate-limited wp-login.php, real client IP trusted from cloudflared). Manage it with make prod-down / make prod-restart / make prod-logs / make prod-ps.

Required wp-config.php hardening

Cloudflare terminates TLS at its edge, so public_html/wp-config.php needs to trust the forwarded scheme and be locked down before going live. Add this before the /* That's all, stop editing! */ line:

define( 'WP_DEBUG', false );
define( 'DISALLOW_FILE_EDIT', true );
define( 'FORCE_SSL_ADMIN', true );
if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https' ) {
    $_SERVER['HTTPS'] = 'on';
}

Without the HTTP_X_FORWARDED_PROTO check, WordPress sees plain HTTP from nginx (TLS already ended at Cloudflare) and generates insecure URLs / redirect loops with FORCE_SSL_ADMIN on.


Backup & Restore

Automated backup of the database and public_html/, unrelated to make db-backup/make db-restore above (those stay dedicated to the FTP production repo workflow). Full operational details are in the backup Claude Code skill (.claude/skills/backup/SKILL.md); summary below.

scripts/backup.sh dumps the database (mariadb-dump via docker compose exec) and archives public_html/ into timestamped, gzip-compressed files:

make backup          # db_<timestamp>.sql.gz + files_<timestamp>.tar.gz in BACKUP_PATH
make backup-db       # database only
make backup-files    # files only
make backup-list     # list what's in BACKUP_PATH

Configurable via .env: BACKUP_PATH (destination), BACKUP_RETENTION_DAYS (older backups are deleted automatically), BACKUP_EXCLUDE_PATHS (space-separated paths under public_html/ to skip, e.g. cache directories), and an optional post-backup sync to BACKUP_REMOTE_PATH using whatever tool BACKUP_REMOTE_SYNC_CMD names (default rsync -az; swap for rclone sync or anything else that takes <src> <dest>).

Remote storage — S3 and S3-compatible

rclone is bundled in the backup image and is the recommended way to back up to S3 or an S3-compatible bucket (Cloudflare R2, Backblaze B2, MinIO, Wasabi, etc.) — set BACKUP_REMOTE_PATH=s3:<bucket>/<prefix>, BACKUP_REMOTE_SYNC_CMD=rclone sync, and the RCLONE_CONFIG_S3_* credential vars in .env. See the backup Claude Code skill (.claude/skills/backup/SKILL.md) for concrete AWS S3 vs S3-compatible examples, and make backup-list-remote / make backup-restore ... REMOTE=1 for browsing/restoring from it.

Scheduling — BACKUP_SCHEDULER

  • host (default) — run backups from a cron entry on the host. setup.sh/the Makefile never touch the system crontab automatically; add it yourself with crontab -e:
    0 3 * * * cd /path/to/project && ./scripts/backup.sh all >> logs/backup.log 2>&1
    
  • container — set BACKUP_SCHEDULER=container and BACKUP_SCHEDULE_CRON (standard crontab syntax, e.g. 0 3 * * *) in .env, then make prod-up. This activates the backup service in docker-compose.prod.yml (a small Alpine image with mariadb-client + rsync, mounting public_html/ read-only and backups/ read-write) which runs scripts/backup.sh on that schedule via crond. Only available on the production stack, not the local dev one.

Restore

make backup-restore FILE_DB=backups/db_20260101_030000.sql.gz FILE_FILES=backups/files_20260101_030000.tar.gz

Destructive: overwrites the current database and public_html/ contents. Asks for an explicit yes confirmation before doing anything, listing both files first.


Permissions

The files in public_html/ are owned by the host user, which is the user running PHP-FPM in the container. If after manual operations the permissions are incorrect, run make wp-permissions, or do it manually:

sudo chown -R $USER:$USER public_html/
find public_html/ -type d -exec chmod 755 {} \;
find public_html/ -type f -exec chmod 644 {} \;

Notes

  • public_html/ is mounted as bind-mount on both nginx and php containers: file changes are immediately visible without rebuild.
  • Nginx logs are available in the logs/ folder of the host.
  • The db_data volume persists data between restarts; to reset it use make nuke.
  • xmlrpc.php and PHP execution inside wp-content/uploads/ are blocked at Nginx level.

Caution

The default docker-compose.yml stack is for local development only — do not expose it directly to the internet.

This setup prioritizes developer convenience over security and stability. It may include:

  • Hardcoded credentials or insecure defaults
  • Debug modes and verbose logging enabled
  • No rate limiting, HTTPS, or production-level hardening

To go to production, use the documented "Self-Hosted Production Release (Cloudflare Tunnel)" path above (docker-compose.prod.yml + make prod-up), set strong, unique database credentials in .env, and apply the wp-config.php hardening from the production-release skill. Don't just point the dev stack at a public IP/port.

About

A Docker Compose stack for WordPress — Nginx + PHP-FPM + MariaDB — built for local development and ready to run in production as a self-hosted deployment (Cloudflare Tunnel, hardened config, automated backups).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages