WordPress on a VPS is not hard to start. It is hard to finish: the database credentials, the volume that has to survive an upgrade, and the one setting that sends wp-admin into an endless redirect the moment a reverse proxy sits in front of it.
The compose file
Three services: WordPress, MariaDB, and nothing else. Pin both image tags — the comments below say where to check the current release — so an upgrade is a line you change on purpose, not something that lands on a restart.
services:
db:
image: mariadb:11 # check hub.docker.com/_/mariadb/tags for the current point release
restart: unless-stopped
environment:
- MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MARIADB_DATABASE=wordpress
- MARIADB_USER=wordpress
- MARIADB_PASSWORD=${DB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:6-apache # check hub.docker.com/_/wordpress/tags for the current point release
restart: unless-stopped
depends_on: [db]
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
WORDPRESS_CONFIG_EXTRA: |
if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$$_SERVER['HTTPS'] = 'on';
}
define('FORCE_SSL_ADMIN', true);
volumes:
- wp_content:/var/www/html/wp-content
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
ports:
- "127.0.0.1:8080:80"
volumes:
db_data:
wp_content:
The credentials live in a .env file next to the compose file, never inline in the YAML:
printf 'DB_ROOT_PASSWORD=%s\nDB_PASSWORD=%s\n' "$(openssl rand -hex 24)" "$(openssl rand -hex 24)" > .env
chmod 600 .env
Notice the volume only covers /var/www/html/wp-content, not the whole install. That directory is themes, plugins and uploads — the parts that are actually yours. The rest of WordPress core lives inside the image, which is exactly what makes bumping the tag later a clean operation instead of a merge conflict with your own files.
HTTPS behind Caddy, and the redirect loop
blog.example.com {
reverse_proxy 127.0.0.1:8080
}
That's the whole file — Caddy requests and renews the certificate the moment it starts, provided the DNS A record already points at the machine. Caddy also forwards the original request scheme as the X-Forwarded-Proto header on every hop, which is where the redirect loop comes from.
WordPress talks to the wordpress container over plain HTTP inside the Docker network — TLS ended at Caddy, three hops back. Left alone, WordPress sees HTTP_X_FORWARDED_PROTO=https from the browser's real request but still thinks the connection to itself is http, and FORCE_SSL_ADMIN — which you want, so wp-admin never runs unencrypted — keeps redirecting a page it believes is already loading over HTTP to https, forever. The three lines in WORDPRESS_CONFIG_EXTRA above are the fix: they read the header Caddy already sends and set PHP's own $_SERVER['HTTPS'] before WordPress makes that decision, so FORCE_SSL_ADMIN sees a request that is genuinely HTTPS and stops looping. Skip those lines and the symptom is specific: wp-admin loads fine over plain HTTP direct to the container, then loops the instant Caddy is in front of it.
Notice every $_SERVER above is written as $$_SERVER in the compose file. That's not a typo: Compose does its own $VAR substitution over every string in docker-compose.yml, block scalars included, before the file is even parsed as YAML, and $_SERVER looks exactly like a reference to an environment variable named _SERVER. Leave the dollar signs single and Compose quietly substitutes an empty string for the (unset) _SERVER variable, and the broken PHP that results lands straight in wp-config.php — the site fatals instead of just failing to fix the redirect loop. Doubling the $ is how you tell Compose to leave it alone and pass a literal $ through to PHP.
The upload limit nobody remembers
WordPress's own media-upload cap is set by PHP, not by anything in wp-admin. The official image is built on php:apache, which reads every .ini file dropped into /usr/local/etc/php/conf.d/, so a small file mounted read-only does the job without touching the image:
file_uploads = On
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
post_max_size has to be equal to or larger than upload_max_filesize, or the smaller one silently wins and a "large" theme zip or plugin archive fails with no useful error. Caddy itself doesn't cap request body size by default the way some reverse proxies do, so with the Caddyfile above there's nothing on Caddy's side to raise — but if you ever add a request_body { max_size ... } directive to that block, that becomes a second ceiling above post_max_size, and the smallest number in the chain still wins.
Read this before you buy: the NAT catch for 443
A NAT IPv4 VPS gives you a handful of forwarded ports; whether 443 is among them depends on the plan, so check before you point DNS at the box — and whichever ports you get are forwarded to the machine, not to any one container. If this box only ever runs one site, that is a non-issue: Caddy binds 443, WordPress sits behind it, done. The catch shows up the moment you want a second HTTPS site or app on the same VPS — you cannot also bind a second process straight to 443, because the port only exists once at the network layer.
The fix is the one Caddy already gives you for free: run a single Caddy instance as the only thing bound to 443, and route by hostname. Add a second block to the same Caddyfile for a second site, pointed at a different 127.0.0.1:<port>, and Caddy picks the right backend from the Host header before anything reaches either app. What you cannot do is run WordPress's own container with a direct 443:443 port mapping once anything else needs that same port too.
If you specifically need a standalone IPv4 address with 443 all to itself, that exists but is arranged by e-mail, not something you self-service from the panel. Check what your plan forwards before you commit a domain to it — NAT IPv4, ports and forwarding and NAT IPv4 vs a dedicated IP cover the mechanics in more depth than a WordPress guide needs to.
Security basics that matter more than any plugin
None of this is a plugin problem, and installing one rarely fixes it:
- Few plugins. Every plugin is code you didn't audit, running with the same access as WordPress core. A site with five well-maintained plugins is safer than one with thirty, independent of what any of them individually claims to do for security.
-
Automatic minor updates, on. WordPress applies minor and security releases automatically by default; don't turn that off. If you want it explicit in the config anyway, add
define('WP_AUTO_UPDATE_CORE', 'minor');toWORDPRESS_CONFIG_EXTRAalongside the two lines above. -
A real admin username, not
admin. The install wizard already asks you to pick one — use it.adminis the first guess in every credential-stuffing list on the internet, and a unique username removes half of a brute-force attempt before it starts. -
Limit repeated login attempts. This can be a plugin, but it doesn't have to be:
fail2banwatching your access log for repeated hits onwp-login.phpdoes the same job at the network layer, without adding another piece of PHP to the site itself.
Backups: the volume and the database
Two things, and the database changes constantly so it needs its own step:
docker compose exec db sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" wordpress' > "wordpress-$(date +%F).sql"
That reads the root password straight out of the db container's own environment, so nothing sensitive touches your shell history. For the volume — themes, plugins, uploads, everything that isn't the database:
docker run --rm -v wordpress_wp_content:/data -v "$(pwd)":/backup alpine:3.20 \
tar czf "/backup/wp-content-$(date +%F).tar.gz" -C /data .
Adjust the volume name to whatever docker compose config --volumes actually prints for your project — Compose prefixes it with the project directory name by default. Copy both files off the VPS entirely; a dump sitting next to the instance it came from is not a backup, it's a file. back up your VPS covers what off-machine actually means in practice.
Updates: bump the tag, let WordPress do the rest
docker compose pull
docker compose up -d
Take the mariadb-dump above immediately before you do this, not after. WordPress runs its own database upgrade automatically the first time an admin loads a page on the new version — you don't run a separate migration command — but that upgrade is a one-way trip. Read the release notes for the version you're jumping to before you bump the tag, especially across a major version.
Sizing
WordPress plus MariaDB plus Caddy is a light stack for a small site with a caching plugin doing its job: 1 GiB of RAM is a workable floor, and disk is mostly the media library rather than the application itself.
The number changes once the site does more than serve pages. WooCommerce adds a real amount of database weight — orders, sessions, product variations — and a page builder like Elementor or Divi runs noticeably heavier PHP per request while you're editing, even if the public-facing page stays fast. 2 GiB is the realistic floor once either shows up, and it's worth moving before the site is slow rather than after.
On overnight.host
Full disclosure: this is what we sell. If you want the site without the sysadmin, the managed WordPress container comes with its own hostname and certificate.
One-click apps — EUR 4 to EUR 12 a month, hosted in Germany (EU). Eight apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Each customer gets an isolated Docker network and volume, plus a hostname under apps.overnight.host on a real wildcard certificate. Memory and CPU are capped per plan by the container runtime.
You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.
Order managed-wordpress → · One-click apps overview
FAQ
Why does wp-admin keep redirecting in a loop behind Caddy?
WordPress can see the request was HTTPS from the browser, but doesn't know its own connection to itself counts, so FORCE_SSL_ADMIN keeps trying to force a scheme it already thinks it's not using. Reading HTTP_X_FORWARDED_PROTO and setting $_SERVER['HTTPS'] before that check runs, as shown above, is what breaks the loop.
Can I run more than one HTTPS site on the same NAT IPv4 VPS?
Yes, but only through one process bound to 443. Put Caddy in front of everything and give it one block per hostname; each site's own container stays on a private port that only Caddy talks to. What doesn't work is two containers each trying to bind 443 directly.
What actually happens if I skip the uploads.ini?
Media uploads and plugin or theme installs fail past PHP's default limit — usually a fairly small number — with an error that looks like the file is corrupt rather than "too big." It's one of the more common "WordPress is broken" reports that's actually a PHP setting no one raised.
I already back up the wp-content volume — do I still need a separate database dump?
Yes. The volume has your files; the database has your posts, settings, and everything WooCommerce or any plugin stores as rows rather than files. A restore with one and not the other gets you a site with all its files and none of its content, or the reverse.
Is a managed WordPress container different from self-hosting?
Same application underneath. The difference is who holds the shell: a managed container gives you the site, a hostname and a certificate, and you don't get to edit uploads.ini or SSH in to run docker compose pull yourself. If you want that level of control, put it on a VPS instead.
Originally published at overnight.host. We run a small, honest hosting company on dedicated bare metal: Linux & Windows VPS, game servers, web hosting. Live status at up.overnight.host.
Top comments (0)