Production-ready wordpress hosting on docker

Page contents

One of the largest foundations for websites today is wordpress, with its ever increasing set of features and usability improvements. But while using the wordpress interface itself has become incredibly easy, most users are still locked into preconfigured hosting services - but with the rise of container technologies, hosting and maintaining your own wordpress site has become easier than ever.

Preparing the system

We are assuming you have a linux distribution installed on your server, with docker installed (see here for instructions). We also assume that you are running commands as root for simplicity; if not, adjust by prepending sudo before scripts as needed. Now, we create a new installation location for our wordpress setup:

mkdir -p /opt/wordpress/backups
cd /opt/wordpress

Now, create the main docker compose file:

docker-compose.yml

services:
 wordpress:
   image: wordpress:6
   restart: unless-stopped
   depends_on:
     db:
       condition: service_healthy
   environment:
     WORDPRESS_DB_HOST: db:3306
     WORDPRESS_DB_USER: ${DB_USER:?You must set a value for DB_USER in the .env file!}
     WORDPRESS_DB_PASSWORD: ${DB_PASSWORD:?You must set a value for DB_PASSWORD in the .env file!}
     WORDPRESS_DB_NAME: ${DB_NAME:?You must set a value for DB_NAME in the .env file!}
   volumes:
     - $PWD/html:/var/www/html
   labels:
     - "traefik.enable=true"
     - "traefik.http.routers.wordpress.rule=Host(`${DOMAIN:?You must set a value for DOMAIN in the .env file!}`)"
     - "traefik.http.routers.wordpress.entrypoints=websecure"
     - "traefik.http.routers.wordpress.tls.certresolver=letsencrypt"

 db:
   image: mariadb:11
   restart: unless-stopped
   environment:
     MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?You must set a value for DB_ROOT_PASSWORD in the .env file!}
     MARIADB_DATABASE: ${DB_NAME:?You must set a value for DB_NAME in the .env file!}
     MARIADB_USER: ${DB_USER:?You must set a value for DB_USER in the .env file!}
     MARIADB_PASSWORD: ${DB_PASSWORD:?You must set a value for DB_PASSWORD in the .env file!}
   volumes:
     - db_data:/var/lib/mysql
   healthcheck:
    test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-u${DB_USER}", "-p${DB_PASSWORD}"]
    interval: 10s
    timeout: 5s
    retries: 5
    start_period: 30s

 reverse-proxy:
   image: traefik:v2
   restart: always
   command:
     - "--providers.docker=true"
     - "--entrypoints.web.address=:80"
     - "--entrypoints.websecure.address=:443"
     - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL:?You must set a value for EMAIL in the .env file!}"
     - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
     - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
   ports:
     - "80:80"
     - "443:443"
   volumes:
     - "/var/run/docker.sock:/var/run/docker.sock:ro"
     - "letsencrypt:/letsencrypt"
   labels:
     - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
     - "traefik.http.routers.http-catchall.rule=HostRegexp(`{host:.+}`)"
     - "traefik.http.routers.http-catchall.entrypoints=web"
     - "traefik.http.routers.http-catchall.middlewares=redirect-to-https"

volumes:
 db_data:
 letsencrypt:

This seems like a lot, but it's rather plain for a wordpress installation. It consists of 3 services: a wordpress container, a mariadb (mysql) database, and a traefik reverse proxy (to handle automatic SSL certificates).

The compose file already contains all the wiring needed, so all you need to do is create a tiny configuration file:

.env

DOMAIN=example.com
EMAIL=mail@example.com
DB_ROOT_PASSWORD=secureadminpassword
DB_USER=wordpress
DB_PASSWORD=secretpassword
DB_NAME=wordpress

You need to change the values of these variables to match your desired website domain and email address (to register with letsencrypt for free SSL certificates).

With these two files in place, all you need to do is start the containers:

docker-compose up -d

Wait until all containers are started, then open the domain in your browser. You might get a warning about the SSL certificate being invalid, which should resolve itself after a few seconds.

Making backups

One of the most important maintenance tasks for production-level hosting is creating backups.

In order to make this step as easy as possible, we will write a quick script for this task as well. Make sure to save this in the same directory as the docker-compose.yml file!

create_backup.sh

#!/bin/bash

set -e
source "$PWD/.env"
TEMPDIR=$(mktemp -d)
trap 'rm -r "$TEMPDIR"' EXIT
DATE=$(date +%c | sed 's/ /_/g; s/:/-/g')
echo "Ensuring containers are running..."
docker-compose up -d
echo "Backing up database..."
docker-compose exec db bash -c "mkdir -p /backups && mariadb-dump -u$DB_USER -p$DB_PASSWORD $DB_NAME" > "$TEMPDIR/db.sql"
echo "Backing up website files..."
cp -r "$PWD/html" "$TEMPDIR"
tar -czf "$PWD/backups/backup_$DATE.tar.gz" -C "$TEMPDIR" .
echo "Backup successfully created at backups/backup_$DATE.tar.gz"

The script will make backups of both the wordpress files (including themes, uploads etc) and the database contents. Backups are saved to the backups/ directory, and their contents are standardized so you can easily use them to move your website to a different server or paid hoster later if you like.

The last thing to do is give execution permissions to the script:

chmod +x create_backup.sh

To make a backup, simply run the script:

./create_backup.sh

The script will tell you what step it is processing and print the full location of the created backup archive at the end.

Automating scheduled backups

Now backups are important, but they are only useful if you have a reasonably recent backup available when you need one. Manually making backups every day isn't a great idea, so we will configure the server to automatically run the backup script every night.

Append an automatic execution job of the backup script:

(crontab -l 2>/dev/null; echo "0 2 * * * cd $PWD && ./create_backup.sh" && find $PWD/backups/ -name 'backup_*.tar.gz' -mtime +7 -delete) | crontab -

Make sure to run this command as root and from the same directory as the docker-compose.yml file!

The scheduled job runs every night at 2am, and also deletes backups older than 7 days to prevent the disk from filling up with old and unused backups.

Restoring from a backup

In order to get any use out of the backups we made earlier, we need a way to reliably restore them too. Keeping with the simplicity of automation, this can be turned into a bash script as well:

restore_backup.sh

#!/bin/bash

set -e
source "$PWD/.env"
[ "$#" -eq 1 ] || { echo "Usage: $0 <backup-file>"; exit 1; }
BACKUP_FILE="$1"
[ -f "$BACKUP_FILE" ] || { echo "Backup file '$BACKUP_FILE' does not exist."; exit 1; }
TEMPDIR=$(mktemp -d)
trap 'rm -r "$TEMPDIR"' EXIT
echo "Extracting backup..."
tar -xzf "$BACKUP_FILE" -C "$TEMPDIR"
echo "Removing old containers..."
docker compose down -v
echo "Restoring website files..."
rm -rf "$PWD/html"
cp -r "$TEMPDIR/html" "$PWD/html"
echo "Starting clean containers..."
docker compose up -d
echo "Waiting 5s for database to initialize..."
sleep 5
echo "Restoring database..."
docker compose exec -T db bash -c "mariadb -h localhost -u$DB_USER -p$DB_PASSWORD $DB_NAME" < "$TEMPDIR/db.sql"
echo "Backup restored!"

Don't forget to make the script executable:

chmod +x restore_backup.sh

Now all you need to do if you want ot restore a backup, you simply execute the script and pass it the backup archive file you want to recover:

./restore_backup backups/backup_Fri_18_Apr_2025_03-08-31_PM_CEST.tar.gz

This would then delete all currently running versions of the wordpress database and files and restore the contents of the backup archive backup_Fri_18_Apr_2025_03-08-31_PM_CEST.tar.gz, complete including a restart of all necessary containers.

After a few seconds, the wordpress page should be up and running again, with the state of the backup.

Updating wordpress

Since the wordpress installation is using container images, all you need to do in order to update is to pull the new image and run it:

docker-compose pull
docker-compose up -d

This will update to the latest version of the selected wordpress release. If you want to jump between releases, you should carefully read the release notes and migration guide for the new version, then find the line containing the wordpress image version in the file docker-compose.yml, for example:

image: wordpress:6

Change the last number to the wordpress release you want to update to, then run the docker-compose commands from above.

Remember to make a backup before upgrading wordpress or your database!


More articles

Breaking out of docker containers

How misconfigurations lead to privilege escalation

Handling duplicate content on websites

Avoiding chaotic search engine ranking for domains