LINUXexpert.org

Docker for Dummies

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:
    1. The quick “docker run” way
    2. 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 the docker 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”

  1. Stop everything:
docker compose down
  1. Remove volumes (simulate loss) (this deletes data):
docker volume rm wp_data db_data
  1. Recreate clean:
docker compose up -d
  1. 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 with https://. 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 custom php.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

Other Recent Posts