This project sets up a WordPress web-instance using Docker Compose with three main services, plus an optional Kopia backup service:
- Image:
wordpress:latest - Port: Exposed via Caddy
- Volumes: Persists WordPress content in the
wordpressvolume - Environment:
- Reads database credentials from
.env
- Reads database credentials from
- Networks:
frontend: connects to Caddybackend: connects to MySQL
- Image:
caddy:2.10 - Port Mapping: host
443β Caddy container port8081(browser URL uses port 443, e.g.https://localhostorhttps://yourdomain.com) - TLS: Uses
tls internalor Let's Encrypt depending on configuration - Config:
- Loaded from a generated
Caddyfilein theconf/directory
- Loaded from a generated
- Features:
- Automatic HTTPS (for real domains) or internal TLS for development
- Gzip compression
- Volumes:
- Persists TLS and configuration data via
caddy_dataandcaddy_config
- Persists TLS and configuration data via
- Security:
- Runs as read-only
- Uses
tmpfsfor temporary and cert-related directories - Requires
NET_ADMINcapability (needed for Caddyβs network config)
- Image:
mysql:5.7 - Volumes: Data persisted in
db_data - Environment:
- Fully controlled via
.env(e.g., DB name, user, root password)
- Fully controlled via
- Networks:
backend: communicates only with WordPress
- Image:
wordpress:cli - Profile:
toolsβ not started by./run.sh; runs on demand for install and admin tasks - Volumes: Shares the
wordpressvolume with the WordPress container (/var/www/html) - Database: Connects to MySQL over the
backendnetwork (does not mountdb_data) - User: Runs as
33:33to match file ownership in the WordPress container
- Image:
kopia/kopia:latest - Profile:
backupβ not started by./run.sh; runs on demand viabackup.shor cron - Mode: CLI-only (no web UI, no exposed port)
- Repository: Encrypted filesystem repo stored in the
kopia_datavolume (/data/repo) - Snapshots (read-only mounts):
wordpressβ/backup/wordpressdb_dataβ/backup/dbcaddy_dataβ/backup/caddy_datacaddy_configβ/backup/caddy_config
- Persistence: Config, cache, and logs in
kopia_config,kopia_cache, andkopia_logs
Traffic from your browser never hits WordPress directly. Caddy is the only service published to the host; it terminates HTTPS and forwards plain HTTP to WordPress on the internal Docker network.
The :8081 in the Caddyfile (localhost:8081) is Caddy's listen port inside the container. It is not a port you type in the browser. With the default mapping "443:8081", you open https://localhost (host port 443).
flowchart LR
subgraph browserSide [Your machine]
Browser["Browser\nhttps://localhost"]
end
subgraph host [Host]
Host443["Port 443\npublished by Docker"]
end
subgraph caddyContainer [Caddy container]
Caddy8081["Port 8081\nHTTPS / TLS termination\nsite block: HOSTNAME:8081"]
end
subgraph wpContainer [WordPress container]
WP80["Port 80\nApache + PHP\nofficial image default"]
end
Browser -->|"HTTPS encrypted"| Host443
Host443 -->|"Docker map 443:8081"| Caddy8081
Caddy8081 -->|"reverse_proxy wordpress:80\nHTTP on frontend network"| WP80
| Step | Where | Port | Protocol | Defined in |
|---|---|---|---|---|
| 1 | Browser | 443 | HTTPS | Standard HTTPS URL (https://localhost) |
| 2 | Host β Caddy | 443 β 8081 | HTTPS | docker-compose.yml ports: "443:8081" |
| 3 | Caddy listens | 8081 | HTTPS | conf/Caddyfile.template ${HOSTNAME}:8081 |
| 4 | Caddy β WordPress | 80 | HTTP | Caddyfile reverse_proxy wordpress:80 |
| 5 | WordPress serves | 80 | HTTP | wordpress:latest image (Apache default) |
WordPress has no host port mapping β it is only reachable as wordpress:80 from other containers on the frontend network (primarily Caddy).
To verify WordPress listens on 80:
docker inspect wordpress:latest --format '{{json .Config.ExposedPorts}}'
docker compose exec caddy wget -qO- http://wordpress:80 | head -5Before running the stack, create a .env file in the root directory with:
HOSTNAME=wp.local
EMAIL=hostmaster@fenker.eu
DB_NAME=wordpress
DB_USER=wordpress
DB_PASSWORD=your_db_password
DB_ROOT_PASSWORD=your_root_password
# Encryption password for the Kopia backup repository
KOPIA_PASSWORD=your_kopia_passwordTo start the services, run:
./run.shThe caddyfile needs to be rendered with the .env variables, which is done in the run.sh script.
Backups use Kopia in CLI-only mode: there is no long-running Kopia container and no web interface. The backup.sh script spins up a temporary Kopia container, creates snapshots, runs maintenance, and exits.
On the first run, backup.sh also creates the repository and sets default policies (zstd compression; retention: 7 latest, 14 daily, 8 weekly, 12 monthly). Later runs only snapshot and maintain.
From the project root:
./backup.shAdd a crontab entry on the host (adjust time and path as needed):
0 2 * * * /path/to/wordpress_template/backup.sh >> /var/log/kopia-backup.log 2>&1Edit with crontab -e.
All commands below use the backup profile and run Kopia in a one-off container. Run them from the project root.
Repository status (connected repo, storage usage):
docker compose --profile backup run --rm -T kopia repository statusList snapshots:
docker compose --profile backup run --rm -T kopia snapshot listShow a specific snapshot (paths, sizes, IDs):
docker compose --profile backup run --rm -T kopia snapshot list --allView retention/compression policy (global or per path):
docker compose --profile backup run --rm -T kopia policy get --global
docker compose --profile backup run --rm -T kopia policy get /backup/wordpressRestore a snapshot (replace <snapshot-id> with an ID from snapshot list):
mkdir -p ./restore
docker compose --profile backup run --rm -T \
-v "$(pwd)/restore:/restore" \
kopia snapshot restore <snapshot-id> /restoreRun maintenance manually (garbage collection, compaction):
docker compose --profile backup run --rm -T kopia maintenance run --fullView recent Kopia CLI logs (stored in the kopia_logs volume):
docker compose --profile backup run --rm --entrypoint sh kopia \
-c 'ls -lt /app/logs/cli-logs | head; tail -20 /app/logs/cli-logs/*.log'The database is backed up as a raw copy of the MySQL data directory. If MySQL is running during the snapshot, the backup may be inconsistent. For production, consider stopping the database briefly during backup or adding a mysqldump step before the Kopia snapshot.
On first deploy, WordPress shows a setup wizard at /wp-admin/install.php. Anyone who can reach the site before you do can create the admin account. To avoid that race, install WordPress privately before opening port 443 to the public:
- Start the stack with port 443 blocked (firewall), bound to
127.0.0.1only, or before DNS points at the server. - Run
wp core installvia thewpcliservice (creates admin account and closes the install wizard). - Verify login, then open the site to the public.
The wpcli service mounts the same wordpress volume and uses the same database credentials as the WordPress container. The --rm flag only removes the ephemeral container; data is stored in the named volumes.
With HOSTNAME=localhost in .env, Docker maps host port 443 to Caddy's internal port 8081. The Caddy site block is named localhost:8081, but that :8081 is inside the container β your browser URL does not include it.
Use in browser / --url |
Works? |
|---|---|
https://localhost |
Yes |
https://localhost:8081 |
No (port 8081 is not published on the host) |
https://172.x.x.x (container IP from logs) |
No (WordPress is not exposed directly; only Caddy is) |
Start the stack, wait until WordPress has copied its files into the volume, then install:
./run.sh
docker compose --profile tools run --rm wpcli core install \
--url="https://localhost" \
--title="Local WordPress" \
--admin_user=admin \
--admin_password='local-dev-password' \
--admin_email=admin@example.com \
--skip-emailOpen https://localhost/wp-login.php and log in with the credentials above. Accept the self-signed certificate warning if your browser shows one (Caddy tls internal for localhost).
The Apache ServerName / container IP messages in the WordPress logs are normal and unrelated to the site URL WordPress stores.
Use the same URL you will type in the browser. With the default port mapping 443:8081, that is https://yourdomain.com (no :8081 suffix):
docker compose --profile tools run --rm wpcli core install \
--url="https://yourdomain.com" \
--title="My Site" \
--admin_user=admin \
--admin_password='strong-password' \
--admin_email=you@example.com \
--skip-emailCheck whether WordPress is already installed:
docker compose --profile tools run --rm wpcli core is-installed && echo "installed" || echo "not installed"Other useful WP-CLI commands:
docker compose --profile tools run --rm wpcli user list
docker compose --profile tools run --rm wpcli plugin list
docker compose --profile tools run --rm wpcli option get siteurlIf you installed under a temporary hostname (e.g. localhost) and later change HOSTNAME in .env, update stored URLs before going public:
docker compose --profile tools run --rm wpcli search-replace \
'https://localhost' 'https://yourdomain.com' --all-tablesUser Login URL: https://<hostname>/wp-login.php