Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Applications Difficulty: Intermediate

Running WordPress Cleanly with Docker & Traefik

Set up WordPress with Docker and MariaDB behind Traefik – with HTTPS, an isolated database and an honest update strategy.

· 8 min read ·Duration: approx. 60 minutes
Table of contents

WordPress powers a large part of the web – and is notorious for hacked installations. The difference between “runs and is secure” and “becomes a spam cannon” lies in a clean setup and honest maintenance. This recipe delivers both: WordPress with Docker, an isolated database, HTTPS – and a clear statement of what you have to do on an ongoing basis.

What are we building?

A production-ready WordPress installation with WordPress 7.1.3 (PHP 8.5) and MariaDB 11.8 behind Traefik. The database runs in an isolated internal network with no internet access, WordPress gets its HTTPS from Traefik, and we cleanly solve the classic reverse-proxy pitfall (redirect loop / mixed content). By the end you have a running blog – and you know how to keep it secure.

WordPress or Hugo?

If you “only” need a website or blog without interactive features, a static site with Hugo is safer and lower-maintenance to run (no database, no attack surface). WordPress is worth it when you need its huge plugin ecosystem, editorial workflows or shop/membership features. This tutorial takes WordPress honestly, including its maintenance burden.

Prerequisites

🍳 Recommendation Ad

VPS 2000 G12.5

8 vCores · 16 GB RAM · 256 GB SSD

from €26.92/month

WordPress with a database and PHP runs comfortably on the VPS 2000.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 2000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

Step by step

Step 1: Two containers, two networks

WordPress consists of two parts: the PHP application and a MariaDB database. A clean setup separates the two and isolates the database – it should only be reachable by WordPress and needs no internet itself. That’s exactly what the two networks are for (see Understanding Docker networks): the public proxy network between WordPress and Traefik, and an internal network between WordPress and the database.

Diagram: browser via HTTPS to Traefik, from there via HTTP with X-Forwarded-Proto to WordPress, WordPress via SQL to MariaDB on the internal network
Traefik terminates TLS and tells WordPress the HTTPS origin via X-Forwarded-Proto; the MariaDB sits on the internal network with no internet access

Terminal
mkdir -p /opt/wordpress && cd /opt/wordpress

Step 2: The Compose file

Replace YOUR_DOMAIN and all passwords with your own values:

YAML
services:
  db:
    image: mariadb:11.8
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: YOUR_DB_PASSWORD
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - db_data:/var/lib/mysql
    networks: [intern]
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      start_interval: 2s
      interval: 30s
      timeout: 5s
      retries: 5

  wordpress:
    image: wordpress:7.1.3-php8.5-apache
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: YOUR_DB_PASSWORD
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_CONFIG_EXTRA: |
        if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { $$_SERVER['HTTPS'] = 'on'; }
        define('WP_HOME', 'https://YOUR_DOMAIN');
        define('WP_SITEURL', 'https://YOUR_DOMAIN');
    volumes:
      - wp_data:/var/www/html
    networks: [proxy, intern]
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.wp.rule=Host(`YOUR_DOMAIN`)"
      - "traefik.http.routers.wp.entrypoints=websecure"
      - "traefik.http.routers.wp.tls.certresolver=le"
      - "traefik.http.routers.wp.middlewares=wp-only-me"
      - "traefik.http.middlewares.wp-only-me.ipallowlist.sourcerange=YOUR_IPV4/32,YOUR_IPV6_PREFIX::/64"
      - "traefik.http.services.wp.loadbalancer.server.port=80"

volumes:
  db_data: {}
  wp_data: {}
networks:
  proxy:
    external: true
  intern:
    internal: true

Four details are crucial here:

  • depends_on: condition: service_healthy – WordPress only starts once the database reports ready via the health check. Without it the first start fails in a race condition.
  • intern is internal: true – the database is only on this network and therefore has no internet access; WordPress reaches it at the hostname db.
  • WORDPRESS_CONFIG_EXTRA – the most important part (next step).
  • wp-only-me lets only your own address through until setup is finished; everyone else gets 403 Forbidden. As long as no admin account exists, the instance otherwise belongs to whoever opens the page first – and scanners sometimes find new hostnames through the public certificate transparency logs within seconds of the start. Look up your addresses on your own computer, not on the server: curl -4 -s https://ifconfig.me for IPv4, curl -6 -s https://ifconfig.me for IPv6 (take the first four blocks). If you have no IPv6, drop the second entry including the comma – but only then: browsers prefer IPv6, and an IPv4-only list locks you out. More on this in Hardening Traefik.

The reverse-proxy pitfall: detecting HTTPS

Traefik terminates TLS and talks to WordPress internally over HTTP. WordPress therefore thinks it’s running insecurely and generates http:// links – which leads to mixed-content errors or a redirect loop (ERR_TOO_MANY_REDIRECTS). The line if (... HTTP_X_FORWARDED_PROTO ... === 'https') { $_SERVER['HTTPS'] = 'on'; } tells WordPress the original connection was HTTPS. Together with WP_HOME/WP_SITEURL that’s the clean solution. In Compose the PHP variables must be written as $$, otherwise Docker interprets them as its own variables.

Step 3: Start

Terminal
docker compose up -d

Docker starts the database first, waits for its health check and then starts WordPress. Check:

Terminal
docker compose ps
Ausgabe
NAME                    IMAGE                           SERVICE     STATUS
wordpress-db-1          mariadb:11.8                    db          Up (healthy)
wordpress-wordpress-1   wordpress:7.1.3-php8.5-apache   wordpress   Up

Verify from outside that WordPress responds with valid HTTPS (the 302 redirects to the install):

Terminal
curl -sI https://YOUR_DOMAIN/ | head -1
Ausgabe
HTTP/2 302

Step 4: The famous 5-minute install

Open https://YOUR_DOMAIN/. WordPress redirects to the install wizard. After the language selection you fill in the basics:

The WordPress install wizard with fields for title, username, password and email
The 5-minute install: site title, admin account and email

Don't use “admin” as the username

Do not choose the username admin – it’s the first target of every brute-force attack on WordPress. Use your own name and a strong password. That’s the simplest and most effective hardening measure of all.

After clicking “Install WordPress” and the first login you land in the dashboard:

The WordPress dashboard after installation, version 7.1.3 with the Twenty Twenty-Five theme
The dashboard: here you manage posts, pages, design and plugins

Once the admin account exists, lift the restriction again: delete the two lines containing wp-only-me from compose.yaml and apply the change:

Terminal
docker compose up -d

Step 5: The website is live

Your site is immediately public – with valid HTTPS from Traefik and no mixed-content errors thanks to the proxy configuration:

The frontend of the freshly installed WordPress site with the first post “Hello world!”
The frontend under your own domain – the default post \"Hello world!\"

Step 6: Basic hardening

WordPress is functional out of the box but not optimally secured. The most important immediate measures:

  • Enable automatic updates: under Dashboard → Updates make sure at least security updates are applied automatically.
  • Delete unnecessary default content: the “Hello world!” post, the sample page and the bundled example plugin (Hello Dolly) can go.
  • Less is more with plugins: every plugin is additional attack surface. Only install what you really need, and only from a trustworthy source.
  • Protect the login further: ideally put another hurdle in front of the WordPress login (/wp-login.php). A rate-limit/BasicAuth middleware in Traefik or a 2FA plugin work well. Fail2ban can also evaluate WordPress login attempts.
  • Keep an eye on XML-RPC: the file xmlrpc.php is a common target for brute force and DDoS. If you don’t need it, block it via a Traefik middleware or plugin.

When things go wrong

The page answers with 403 Forbidden. The restriction from step 2 doesn’t let your address through. Usually the IPv6 is missing or has changed – look up both addresses again (see there), correct sourcerange and restart with docker compose up -d.

Redirect loop (ERR_TOO_MANY_REDIRECTS) or mixed-content warnings. The HTTPS detection is missing. Check that the WORDPRESS_CONFIG_EXTRA block with HTTP_X_FORWARDED_PROTO and the $$ signs is set exactly (see the warning in step 2), and restart the WordPress container.

WordPress won’t start, reports “Error establishing a database connection”. Usually a race condition or a password mismatch. Check that depends_on: condition: service_healthy is set and WORDPRESS_DB_PASSWORD matches MARIADB_PASSWORD exactly. Logs: docker compose logs db.

The first start fails even though everything runs later. The database needs a few seconds to initialize on the very first start. The health check covers this; without it, another docker compose up -d helps.

Media uploads fail for large files. PHP limits the upload size. Create your own uploads.ini and mount it to /usr/local/etc/php/conf.d/ with e.g. upload_max_filesize = 64M and post_max_size = 64M.

After a domain change the site points nowhere. WP_HOME/WP_SITEURL are hard-wired in the Compose file – for a new domain, change them there and restart the container (the values override the database setting).

Your problem is not listed? Search all error messages →

Maintenance & backups

  • Honestly: WordPress is maintenance work. Unlike a static site, active code with a database runs here – core, themes and plugins get regular security updates that must be applied promptly. A neglected WordPress will reliably get hijacked. Enable automatic updates and check the update status monthly. Keep the container tag (wordpress:7.1.3-php8.5-apache) current via your normal update process.
  • The backup has two parts – both are mandatory. WordPress consists of a database (posts, settings, users) and files (wp_data volume: uploads, themes, plugins). A Restic snapshot of the running MariaDB volume alone is no guarantee – back up the database additionally via a dump. Two details decide whether there is actually data in the file at the end: -T disables pseudo-TTY allocation so the dump lands in the file as a plain byte stream, and the password goes directly after -p, with no space:
Terminal
docker compose exec -T db mariadb-dump -u wordpress -p'YOUR_DB_PASSWORD' --single-transaction wordpress > backup.sql

If you write -p 'YOUR_DB_PASSWORD' with a space, mariadb-dump reads the password as a database name, asks for one interactively and gives up – leaving an empty file behind:

Ausgabe
Enter password: mariadb-dump: Got error: 1045: "Access denied for user 'wordpress'@'localhost' (using password: NO)" when trying to connect

So afterwards make sure the file isn’t empty (ls -l backup.sql). The dump and the wp_data volume belong in your Restic backup.

  • Test the restore. A backup that was never restored is just a hope – run a restore at least once as a test, so that in an emergency you know the dump and files fit together.

Last updated: Oct 31, 2026

You might also like