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).
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.
├── .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
- Docker with the Compose plugin (v2) or
docker-composev1 curlunzipmake
- Download the latest
.zipfrom the Releases page of the repository - Extract the folder
- Run the setup script:
chmod +x setup.sh
./setup.shgit clone <repository-url>
cd <project-name>
chmod +x setup.sh
./setup.shThe setup.sh script guides the installation step by step:
- Checks prerequisites (
docker,curl,unzip) - Asks for configuration parameters and generates the
.envfile - Creates the
public_html/,logs/and.github/workflowsdirectories - Downloads the latest version of WordPress into
public_html/ - Asks the theme path for deploy.yml file and move it to .github/workflows
- Move src/gitignore to .gitignore
- Asks whether to build and start the containers
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) |
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) |
| 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 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 statusAfter 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
# 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 productionTo 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) |
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:
- In the Cloudflare Zero Trust dashboard, create a tunnel
(connector type Cloudflared) and add a Public Hostname pointing to
http://nginx:80. - Copy the tunnel token into
.env:CLOUDFLARE_TUNNEL_TOKEN=<token> PRODUCTION_DOMAIN=<your public hostname> - 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.
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.
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_PATHConfigurable 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>).
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.
host(default) — run backups from a cron entry on the host.setup.sh/the Makefile never touch the system crontab automatically; add it yourself withcrontab -e:0 3 * * * cd /path/to/project && ./scripts/backup.sh all >> logs/backup.log 2>&1container— setBACKUP_SCHEDULER=containerandBACKUP_SCHEDULE_CRON(standard crontab syntax, e.g.0 3 * * *) in.env, thenmake prod-up. This activates thebackupservice indocker-compose.prod.yml(a small Alpine image withmariadb-client+rsync, mountingpublic_html/read-only andbackups/read-write) which runsscripts/backup.shon that schedule viacrond. Only available on the production stack, not the local dev one.
make backup-restore FILE_DB=backups/db_20260101_030000.sql.gz FILE_FILES=backups/files_20260101_030000.tar.gzDestructive: overwrites the current database and public_html/ contents. Asks for an
explicit yes confirmation before doing anything, listing both files first.
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 {} \;public_html/is mounted as bind-mount on bothnginxandphpcontainers: file changes are immediately visible without rebuild.- Nginx logs are available in the
logs/folder of the host. - The
db_datavolume persists data between restarts; to reset it usemake nuke. xmlrpc.phpand PHP execution insidewp-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.