No per-service fees - one plan, unlimited appsFree tier available - start building today15% off your workspace - subscribe to our blogNo per-service fees - one plan, unlimited appsFree tier available - start building today15% off your workspace - subscribe to our blog
Miget x AIPlansEnterpriseCompareBlogDashboard
Start for Free
Blog/Docker/Containers/
·

WordPress on Docker Compose: The Production Details Tutorials Skip

Every WordPress Docker Compose tutorial gives you roughly the same twenty lines: the official image, a MySQL container, four environment variables, done. It works, in the sense that a browser shows the install wizard.

Then one of four things happens. You restart the stack and your uploads are gone. You put it behind a TLS proxy and the admin panel redirects forever. You install a plugin and it asks for FTP credentials. Or your database container comes up before it is ready and WordPress greets a visitor with a database connection error.

None of those are WordPress bugs. They are the things a minimal compose file leaves for you to discover in production. This post is the compose file that handles them, and why each part is there. Disclosure: we maintain this as an open-source template in our stack catalogue, and it deploys to Miget in one step - but the compose file is vanilla and runs anywhere Docker does.

The compose file

services:
  wordpress:
    image: wordpress:apache
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WORDPRESS_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';
        }
    ports:
      - "8080:80"
    volumes:
      - wpdata:/var/www/html
    depends_on:
      - db

  db:
    image: mysql:8
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
      MYSQL_DATABASE: wordpress
    volumes:
      - dbdata:/var/lib/mysql

volumes:
  wpdata:
  dbdata:

Two services, two named volumes, and one block of PHP that saves you an afternoon. The interesting parts are below.

1. The volume must be the whole WordPress directory

The most common mistake in tutorials is mounting only wp-content, or mounting nothing at all. Both leave you with a site that loses state.

Mount nothing and every upload, every installed plugin, and every theme edit lives in the container's writable layer. It survives restarts of that container, but not a docker compose down, not an image update, and not a redeploy. People discover this the day they update the image.

Mount only wp-content and you keep uploads and plugins, but you lose anything WordPress writes elsewhere - and WordPress writes elsewhere more than you would like, including its own core files during updates.

The official image expects /var/www/html to be persistent. Mount the whole thing:

volumes:
  - wpdata:/var/www/html

The entrypoint script is written for exactly this: on first start it copies WordPress into an empty volume, and on later starts it leaves your files alone and only updates core when the image version changes.

2. Behind a TLS proxy, WordPress needs to be told

This is the redirect loop that eats an evening. Your proxy terminates TLS and forwards plain HTTP to the container. WordPress sees an HTTP request, decides the visitor is on the wrong protocol, and issues a redirect to HTTPS. The proxy forwards that redirect. The browser tries again. WordPress sees HTTP again.

The fix is three lines of PHP in wp-config.php, injected through the image's WORDPRESS_CONFIG_EXTRA:

WORDPRESS_CONFIG_EXTRA: |
  if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $$_SERVER['HTTPS'] = 'on';
  }

Note the doubled $$: in a compose file, $ starts a variable substitution, so PHP variables have to be escaped or Docker will try to interpolate $_SERVER and hand you an empty string.

This matters on any managed platform, because they all terminate TLS in front of your container. It is the single most common reason a WordPress container that works locally breaks the moment it gets a real domain.

3. Plugin installs need a writable filesystem, and WordPress checks

When WordPress cannot write to its own directory, it does not fail with a permission error. It shows the FTP credentials dialog - a fallback from an era when PHP ran as a different user than the web server. In a container it means the filesystem is read-only or owned by the wrong user.

With the whole directory on a named volume, the official image handles ownership on first start and this simply does not come up. It comes up when people try to be clever: read-only root filesystems, bind mounts from the host with host ownership, or an init container that chowns the wrong path. If you see the FTP dialog, the message is "your web server user cannot write here" - not "please give me FTP".

There is a real trade-off underneath. A WordPress that can update itself is a WordPress whose code directory is writable, which is exactly what a hardened container image tries to prevent. WordPress made its choice years ago and self-updating is the norm; fighting it means giving up automatic core and plugin updates. Pick deliberately.

4. The database is ready later than the container

depends_on without a condition waits for the container to start, not for MySQL to accept connections. MySQL 8 spends its first boot initialising the data directory, which takes seconds - and in that window WordPress may already be serving a database connection error.

For a single-node stack, restart: unless-stopped covers it: WordPress crashes, Docker restarts it, and by the second or third attempt the database is up. It is ugly but it converges. If you want it clean, add a healthcheck and a condition:

  db:
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
      interval: 10s
      timeout: 5s
      retries: 12

  wordpress:
    depends_on:
      db:
        condition: service_healthy

What it costs to run

The stack is three moving parts - WordPress, MySQL, and the volumes - and wants about 2 GiB of RAM in total, split roughly evenly between PHP and MySQL, with 15 GB of disk for uploads and the database.

That sizing is the whole argument for self-hosting this particular application. WordPress.com's Business plan and WP Engine both charge per visit or per site, and both cap what you can install; a 2 GiB plan runs this stack with no visit ceiling, full plugin freedom, and room left over for other apps. On a flat-rate plan that is around $13/month, and the same plan holds your staging copy and whatever else you deploy.

Honest counterpoint: if you want somebody else to handle updates, backups and the security surface of a WordPress install, managed WordPress hosting is a legitimate product and $20-30/month buys you real work. Self-hosting is the right answer when you want plugin freedom and a bill that does not scale with traffic - not when you want to never think about it.

Two things to back up

The database and the uploads, separately. A database dump without wp-content is a site with no images; wp-content without the database is a folder of files. Both, on a schedule, tested by restoring somewhere.

Not the container. There is nothing in the container worth keeping. That is the point of putting state in volumes.

Frequently asked questions

Is WordPress on Docker production-ready?

Yes, with the four details above handled: the full /var/www/html on a persistent volume, the HTTPS-behind-proxy config, a writable code directory, and a database that WordPress waits for. The official image is maintained and widely run in production. What Docker does not solve for you is WordPress's own maintenance burden - updates and plugin hygiene are still yours.

Should I use MySQL or MariaDB?

Either works; the official image supports both. MySQL 8 is the reference configuration and what most tutorials assume. MariaDB tends to be lighter on memory, which matters if you are fitting the stack into a small plan.

Why does my WordPress redirect endlessly after adding HTTPS?

Because the container sees plain HTTP from the proxy and keeps redirecting to HTTPS. Set HTTPS in wp-config.php from the X-Forwarded-Proto header, as in the WORDPRESS_CONFIG_EXTRA block above. Remember the doubled $$ in compose, or the PHP variables will be eaten by Docker's own substitution.

Why does WordPress ask for FTP credentials when I install a plugin?

It cannot write to its own directory. That is a filesystem ownership or read-only mount problem, not an actual FTP requirement. With the whole WordPress directory on a named volume the official image sets ownership correctly on first start.

How much RAM does a WordPress Docker stack need?

Around 2 GiB total for WordPress plus MySQL is a comfortable working figure for a normal site, with the memory split roughly evenly between the two. You can run it in less, but MySQL is the first thing to suffer, and a starved database looks like a slow site rather than an obvious error.

Can I run this on a managed platform instead of a VPS?

Yes, if the platform accepts a compose file and gives you persistent volumes. Ours does - the WordPress template is this stack with a small overlay for RAM and volume sizes, deployable in one step - but the compose file itself is portable and nothing in it is platform-specific.

WordPress Docker Compose: A Production-Ready Setup