A step-by-step, copy-paste friendly lesson using a simple WordPress site
What you’ll build
- A WordPress site backed by a MariaDB database
- Containers run locally with persistent volumes so your data survives restarts
- Two ways to run it:
- The quick “docker run” way
- The clean “docker compose” way (recommended)
Works on Linux, macOS, and Windows (with Docker Desktop). Commands below use a terminal/PowerShell.
0) Prerequisites
Install Docker
- Linux (Debian/Ubuntu):
# Remove old versions (safe if none installed)
sudo apt-get remove -y docker docker-engine docker.io containerd runc || true
# Packages
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release
# Docker’s official repo
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# (Optional) Let your user run docker without sudo. Log out/in afterwards.
sudo usermod -aG docker "$USER"
- macOS / Windows:
Install Docker Desktop from docker.com and then open a terminal.
Verify install
docker --version
docker run --rm hello-world
You should see a friendly hello. If it runs, you’re set.
1) Docker Concepts in 60 Seconds
- Image: a recipe (template) for a container (e.g.,
wordpress:latest
). - Container: a running instance of an image.
- Volume: a persistent folder managed by Docker, survives container recreation.
- Port mapping:
-p 8080:80
means “expose container’s port 80 on my computer’s port 8080”. - Network: a private LAN for containers to talk to each other by name.
2) Quick Start (docker run) — 5 Commands
This gets you a working WordPress in minutes. Great for learning.
# 1) Create a private network for WordPress + DB
docker network create wpnet
# 2) Create volumes so data persists
docker volume create wp_data
docker volume create db_data
# 3) Start MariaDB (database)
docker run -d \
--name wp-db \
--network wpnet \
-e MARIADB_ROOT_PASSWORD=supersecretroot \
-e MARIADB_DATABASE=wordpress \
-e MARIADB_USER=wpuser \
-e MARIADB_PASSWORD=supersecretpw \
-v db_data:/var/lib/mysql \
mariadb:11
# 4) Start WordPress (web)
docker run -d \
--name wordpress \
--network wpnet \
-p 8080:80 \
-e WORDPRESS_DB_HOST=wp-db:3306 \
-e WORDPRESS_DB_USER=wpuser \
-e WORDPRESS_DB_PASSWORD=supersecretpw \
-e WORDPRESS_DB_NAME=wordpress \
-v wp_data:/var/www/html \
wordpress:latest
# 5) Check both are healthy-ish
docker ps
docker logs -f wordpress
Open: http://localhost:8080 (or http://127.0.0.1:8080
). Complete the WordPress setup screen.
Stop & Start later
docker stop wordpress wp-db
docker start wp-db wordpress
Tear down completely (data kept if you don’t remove volumes)
docker rm -f wordpress wp-db
# (Optional) To delete data permanently:
docker volume rm wp_data db_data
docker network rm wpnet
3) The Clean Way: docker compose (recommended)
Compose keeps everything in one config file and is easier to manage.
3.1) Make a project folder
mkdir -p ~/wp-demo && cd ~/wp-demo
3.2) Create a .env
file for secrets (easy to rotate/change)
cat > .env << 'EOF'
# Change these!
MARIADB_ROOT_PASSWORD=supersecretroot
MARIADB_DATABASE=wordpress
MARIADB_USER=wpuser
MARIADB_PASSWORD=supersecretpw
# Host port where you'll access WordPress
HOST_HTTP_PORT=8080
EOF
3.3) Create docker-compose.yml
# docker-compose.yml
services:
db:
image: mariadb:11
container_name: wp-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: ${MARIADB_DATABASE}
MARIADB_USER: ${MARIADB_USER}
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
networks:
- wpnet
wordpress:
image: wordpress:latest
container_name: wordpress
depends_on:
- db
restart: unless-stopped
ports:
- "${HOST_HTTP_PORT}:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: ${MARIADB_USER}
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD}
WORDPRESS_DB_NAME: ${MARIADB_DATABASE}
volumes:
- wp_data:/var/www/html
networks:
- wpnet
volumes:
db_data:
wp_data:
networks:
wpnet:
3.4) Bring it up
docker compose up -d
docker compose ps
Open: http://localhost:8080 (or the port you set in .env
).
3.5) Useful compose commands
# See logs (Ctrl+C to exit)
docker compose logs -f
# Stop / start
docker compose stop
docker compose start
# Recreate after changes (no data loss)
docker compose up -d --force-recreate
# Tear down (keeps volumes unless you add -v)
docker compose down
# Remove containers + network + volumes:
docker compose down -v
4) Managing Data (Backups & Restore)
Backup the database
# Save to current folder as wordpress-<date>.sql
docker exec wp-db sh -c 'exec mysqldump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' > "wordpress-$(date +%F).sql"
Restore the database
# Replace FILENAME with your .sql backup
docker exec -i wp-db sh -c 'exec mysql -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' < FILENAME.sql
Backup WordPress files (media, plugins, themes)
# Create a tar archive of the WordPress volume (requires container running)
docker run --rm -v wp_data:/data -v "$PWD":/backup alpine sh -c "cd /data && tar czf /backup/wp_files-$(date +%F).tar.gz ."
Restore WordPress files
# Stop WordPress to avoid file locks
docker stop wordpress
# Extract backup into the volume
docker run --rm -v wp_data:/data -v "$PWD":/backup alpine sh -c "cd /data && tar xzf /backup/wp_files-YYYY-MM-DD.tar.gz"
# Start it back up
docker start wordpress
5) Updating & Upgrading
Pull newer images and recreate containers
# docker run style
docker pull mariadb:11
docker pull wordpress:latest
docker stop wordpress wp-db
docker rm wordpress wp-db
# re-run the two `docker run` commands from earlier
# docker compose style (recommended)
docker compose pull
docker compose up -d
WordPress core, plugins, and themes can also be updated from the WP admin dashboard.
6) Inspecting, Logs, and Shell Access
# See running containers
docker ps
# Inspect a container (JSON)
docker inspect wordpress | less
# Follow logs
docker logs -f wordpress
docker logs -f wp-db
# Open a shell inside
docker exec -it wordpress bash
docker exec -it wp-db bash
7) Common Troubleshooting
“Port already in use”
# Find what’s using port 8080 and stop it OR change HOST_HTTP_PORT in .env
sudo lsof -i :8080
“Permission denied” when running docker
- You may need
sudo
on Linux, or add your user to thedocker
group and re-login:
sudo usermod -aG docker "$USER"
“Error establishing a database connection”
- Confirm env vars match between WordPress and DB.
- Check DB logs:
docker logs wp-db
Containers keep restarting
docker ps --format "table {{.Names}}\t{{.Status}}"
docker logs wordpress
docker logs wp-db
Fix the underlying env/ports/permissions, then:
docker compose up -d --force-recreate
8) Optional Niceties
Change the site port (e.g., 8000 instead of 8080)
- docker run: change
-p 8080:80
to-p 8000:80
- compose: set
HOST_HTTP_PORT=8000
in.env
, then:
docker compose up -d
Put containers on a custom network (already done)
- You can add more services to the
wpnet
network and they can reach each other by service name.
Run behind a reverse proxy (basic idea)
- If you use Nginx/Traefik/Caddy in another container, expose WordPress only to the proxy network and let the proxy handle TLS. (Out of scope here, but this is the next step.)
9) Cleaning Up
# Stop and remove containers
docker rm -f wordpress wp-db
# Remove the network
docker network rm wpnet # (docker run setup)
# Remove volumes (deletes data!)
docker volume rm wp_data db_data
With compose:
# In the project folder
docker compose down -v # removes containers, network, and volumes (data loss)
10) Quick Reference (cheatsheet)
# Start (compose)
docker compose up -d
# Stop / Start
docker compose stop
docker compose start
# Logs
docker compose logs -f
# Recreate after changes
docker compose up -d --force-recreate
# Update images + recreate
docker compose pull && docker compose up -d
# Backup DB
docker exec wp-db sh -c 'exec mysqldump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' > "wordpress-$(date +%F).sql"
# Shell into container
docker exec -it wordpress bash
11) Security Mini-Checklist
- Use strong, unique passwords in
.env
and WordPress admin. - Don’t publish your
.env
file to GitHub. - Keep images updated (
docker compose pull && docker compose up -d
). - Expose only necessary ports (ideally put WordPress behind a reverse proxy with TLS).
- Back up both database and files regularly.
12) What you learned
- Core Docker concepts (images, containers, volumes, ports, networks)
- How to run a multi-container WordPress app with
docker run
- How to manage it cleanly with docker compose
- How to backup/restore, update, and troubleshoot
Awesome—let’s level this up with some real-world upgrades: HTTPS, backups, wp-cli, phpMyAdmin, Makefiles, scheduled jobs, and basic production hardening.
13) Add HTTPS with a Reverse Proxy (Caddy)
Why Caddy? It’s the easiest to get automatic HTTPS from Let’s Encrypt with almost zero config.
13.1) What you need first
- A domain name, e.g.
blog.example.com
- DNS A record pointing
blog.example.com
→ your server’s public IP - Port 80 and 443 open to the internet on your server/firewall
13.2) Update your compose project
From your ~/wp-demo
directory, create or edit .env
and add your domain and an email for certs:
# .env
SITE_DOMAIN=blog.example.com
[email protected]
# existing env already here:
MARIADB_ROOT_PASSWORD=supersecretroot
MARIADB_DATABASE=wordpress
MARIADB_USER=wpuser
MARIADB_PASSWORD=supersecretpw
HOST_HTTP_PORT=8080
Create Caddyfile
:
# Caddyfile
{$SITE_DOMAIN} {
encode zstd gzip
reverse_proxy wordpress:80
}
Replace your docker-compose.yml
with this 3-service stack (DB + WP + Caddy). If you want to keep the earlier file, save this as docker-compose.proxy.yml
and run with -f
(shown below).
# docker-compose.proxy.yml
services:
db:
image: mariadb:11
container_name: wp-db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: ${MARIADB_DATABASE}
MARIADB_USER: ${MARIADB_USER}
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
networks:
- wpnet
wordpress:
image: wordpress:latest
container_name: wordpress
depends_on:
- db
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: ${MARIADB_USER}
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD}
WORDPRESS_DB_NAME: ${MARIADB_DATABASE}
volumes:
- wp_data:/var/www/html
networks:
- wpnet
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SITE_DOMAIN=${SITE_DOMAIN}
- ACME_AGREE=true
- EMAIL=${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # stores TLS certs
- caddy_config:/config
networks:
- wpnet
volumes:
db_data:
wp_data:
caddy_data:
caddy_config:
networks:
wpnet:
Bring it up:
docker compose -f docker-compose.proxy.yml up -d
Browse to https://blog.example.com. Caddy will fetch and renew certs automatically.
(You no longer need to expose port 8080—Caddy fronts everything.)
14) Optional: Add phpMyAdmin (DB GUI)
Add this service to your compose file (either compose variant):
phpmyadmin:
image: phpmyadmin:latest
container_name: pma
restart: unless-stopped
environment:
PMA_HOST: db
PMA_USER: ${MARIADB_USER}
PMA_PASSWORD: ${MARIADB_PASSWORD}
depends_on:
- db
networks:
- wpnet
# If using direct ports (no Caddy):
# ports:
# - "8081:80"
If using Caddy, add a route to the Caddyfile:
# Caddyfile
{$SITE_DOMAIN} {
encode zstd gzip
reverse_proxy /pma/* pma:80
reverse_proxy wordpress:80
}
Then:
docker compose up -d
Visit https://blog.example.com/pma/
.
15) WordPress CLI (wp-cli)
wp-cli lets you manage WordPress from the terminal: install plugins, create users, run updates.
15.1) One-off wp-cli container
docker run --rm -it \
--network wpnet \
-v wp_data:/var/www/html \
-w /var/www/html \
wordpress:cli php --version # sanity check
15.2) Useful commands
# Get WordPress info
docker run --rm -it --network wpnet -v wp_data:/var/www/html -w /var/www/html wordpress:cli wp core version
# Create an admin user
docker run --rm -it --network wpnet -v wp_data:/var/www/html -w /var/www/html wordpress:cli \
wp user create adminyou [email protected] --role=administrator --user_pass='ChangeThis!123'
# Install & activate a plugin
docker run --rm -it --network wpnet -v wp_data:/var/www/html -w /var/www/html wordpress:cli \
wp plugin install wordfence --activate
# Update core, plugins, themes
docker run --rm -it --network wpnet -v wp_data:/var/www/html -w /var/www/html wordpress:cli \
sh -c "wp core update && wp plugin update --all && wp theme update --all"
16) Backups You’ll Actually Use
16.1) Create a backup.sh
cat > backup.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
STAMP="$(date +%F_%H%M%S)"
OUTDIR="${1:-./backups}"
mkdir -p "$OUTDIR"
echo "[*] Dumping database..."
docker exec wp-db sh -c 'exec mysqldump -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' \
> "$OUTDIR/db-$STAMP.sql"
echo "[*] Archiving WordPress files..."
docker run --rm -v wp_data:/data -v "$OUTDIR":/backup alpine \
sh -c "cd /data && tar czf /backup/wp-$STAMP.tgz ."
echo "[✓] Backups written to $OUTDIR:"
ls -lh "$OUTDIR"
EOF
chmod +x backup.sh
16.2) Create a restore.sh
cat > restore.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
DB_DUMP="${1:-}"
WP_TAR="${2:-}"
if [[ -z "$DB_DUMP" || -z "$WP_TAR" ]]; then
echo "Usage: $0 <db-dump.sql> <wp-files.tgz>"
exit 1
fi
echo "[*] Restoring WordPress files..."
docker stop wordpress || true
docker run --rm -v wp_data:/data -v "$(pwd)":/backup alpine \
sh -c "rm -rf /data/* && tar xzf /backup/$WP_TAR -C /data"
echo "[*] Restoring database..."
docker exec -i wp-db sh -c 'exec mysql -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' < "$DB_DUMP"
echo "[*] Starting WordPress..."
docker start wordpress
echo "[✓] Restore complete."
EOF
chmod +x restore.sh
16.3) Schedule daily backups (Linux)
crontab -e
Add:
# Daily at 2:15 AM → ~/wp-demo/backups/
15 2 * * * cd ~/wp-demo && ./backup.sh >> backup.log 2>&1
17) Makefile = One-word Commands
cat > Makefile << 'EOF'
SHELL := /bin/bash
.PHONY: up down logs pull update backup restore shell-wp shell-db ps
up:
@[ -f docker-compose.proxy.yml ] && docker compose -f docker-compose.proxy.yml up -d || docker compose up -d
down:
@[ -f docker-compose.proxy.yml ] && docker compose -f docker-compose.proxy.yml down || docker compose down
logs:
@docker compose logs -f
pull:
@docker compose pull
update: pull
@docker compose up -d
backup:
@./backup.sh
restore:
@./restore.sh $(DB) $(FILES)
shell-wp:
@docker exec -it wordpress bash
shell-db:
@docker exec -it wp-db bash
ps:
@docker compose ps
EOF
Now you can do:
make up
make logs
make backup
make update
18) Dev vs. Prod: Compose Override
Use your stable production stack, and layer dev-only changes with an override file.
Create docker-compose.override.yml
for local development tweaks (bind mount code, enable xdebug, etc.). Example mounts your WordPress files to a local folder so you can edit them in a code editor:
# docker-compose.override.yml
services:
wordpress:
volumes:
- ./wp-code:/var/www/html
environment:
WP_DEBUG: "true"
Apply:
mkdir -p wp-code
docker compose -f docker-compose.proxy.yml -f docker-compose.override.yml up -d
19) Moving to a New Server (Migration)
19.1) On the old server
cd ~/wp-demo
./backup.sh ./backups
docker compose config > effective-compose.yml
Copy backups/
, effective-compose.yml
, .env
, and Caddyfile
to the new server (e.g., via scp
).
19.2) On the new server
mkdir -p ~/wp-demo && cd ~/wp-demo
# Copy in the files you moved...
docker compose -f effective-compose.yml up -d
./restore.sh backups/db-YYYY-MM-DD_HHMMSS.sql backups/wp-YYYY-MM-DD_HHMMSS.tgz
Update DNS to point your domain at the new server’s IP. Caddy will re-issue certs.
20) Healthchecks & Auto-restart
Add container-level healthchecks for better reliability:
wordpress:
image: wordpress:latest
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/wp-login.php >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
Check status:
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
21) Basic Security Hardening (quick wins)
- Strong passwords in
.env
and WordPress admin. - Keep plugins/themes minimal; delete ones you don’t use.
- Install a security plugin (e.g., Wordfence) with
wp-cli
(see §15). - Put WordPress behind Caddy/Traefik (TLS by default).
- Backups: test a restore at least once per quarter.
- Least privilege DB user (already using a non-root app user).
- Consider fail2ban on the host and/or WAF rules in your reverse proxy.
22) Monitoring (super simple)
Container status / restart count
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Restarts}}"
Tail logs with timestamps
docker compose logs -f --since=1h
(Next steps could include Prometheus + cAdvisor or Dozzle, but that’s beyond “dummies”.)
23) Quick “Disaster Drill”
- Stop everything:
docker compose down
- Remove volumes (simulate loss) (this deletes data):
docker volume rm wp_data db_data
- Recreate clean:
docker compose up -d
- Restore from your latest backup:
./restore.sh backups/db-YYYY-MM-DD_HHMMSS.sql backups/wp-YYYY-MM-DD_HHMMSS.tgz
You should be back online—confidence unlocked.
24) Common Gotchas (Extended)
- Mixed content after enabling HTTPS
In WP Admin → Settings → General, make sure both WordPress Address (URL) and Site Address (URL) start withhttps://
. If stuck, force URL with wp-cli:docker run --rm -it --network wpnet -v wp_data:/var/www/html -w /var/www/html wordpress:cli \ wp option update home 'https://YOURDOMAIN' docker run --rm -it --network wpnet -v wp_data:/var/www/html -w /var/www/html wordpress:cli \ wp option update siteurl 'https://YOURDOMAIN'
- Uploads 413 Request Entity Too Large (reverse proxy limit)
For Caddy, add:{$SITE_DOMAIN} { encode zstd gzip header { Request-Header "Expect-CT" "max-age=86400" } reverse_proxy wordpress:80 { flush_interval -1 } }
And bump WordPress/PHP limits in a customphp.ini
if needed:mkdir -p ./php-conf.d cat > ./php-conf.d/uploads.ini << 'EOF' upload_max_filesize = 64M post_max_size = 64M memory_limit = 256M EOF
Then mount it:wordpress: volumes: - wp_data:/var/www/html - ./php-conf.d:/usr/local/etc/php/conf.d
- DB “too many connections” after traffic spikes
Tweak MariaDB config via a custom file:mkdir -p mariadb-conf.d cat > mariadb-conf.d/tuning.cnf << 'EOF'
[mysqld]
max_connections=200 innodb_buffer_pool_size=512M EOF
Mount it:
db:
volumes:
- db_data:/var/lib/mysql
- ./mariadb-conf.d:/etc/mysql/conf.d
Then:
docker compose up -d --force-recreate