HomeStart / GuidesAnleitungen / Self-hosting WordPress on a VPS/ WordPress selbst hosten auf einem VPS

Self-hosting WordPress on a VPS: Docker Compose, HTTPS, and the redirect loop nobody explainsWordPress selbst hosten auf einem VPS: Docker Compose, HTTPS und die Redirect-Schleife, die niemand erklärt

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.WordPress auf einem VPS ist nicht schwer zu starten. Schwer ist es, fertig zu werden: die Datenbank-Zugangsdaten, das Volume, das ein Upgrade überstehen muss, und die eine Einstellung, die wp-admin in eine endlose Weiterleitung schickt, sobald ein Reverse-Proxy davorsteht.

The compose fileDie Compose-Datei

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.Drei Services: WordPress, MariaDB, und sonst nichts. Pinne beide Image-Tags fest — die Kommentare unten sagen dir, wo du das aktuelle Release nachschaust —, sodass ein Upgrade eine Zeile ist, die du bewusst änderst, und nicht etwas, das bei einem Neustart einfach passiert.

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:Die Zugangsdaten liegen in einer .env-Datei neben der Compose-Datei, niemals inline in der 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.Beachte, dass das Volume nur /var/www/html/wp-content abdeckt, nicht die gesamte Installation. Dieses Verzeichnis enthält Themes, Plugins und Uploads — die Teile, die tatsächlich dir gehören. Der Rest des WordPress-Kerns lebt im Image, und genau das macht das spätere Anheben des Tags zu einer sauberen Operation statt zu einem Merge-Konflikt mit deinen eigenen Dateien.

HTTPS behind Caddy, and the redirect loopHTTPS hinter Caddy, und die Redirect-Schleife

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.Das ist die komplette Datei — Caddy fordert das Zertifikat an und erneuert es, sobald es startet, vorausgesetzt der DNS-A-Eintrag zeigt bereits auf die Maschine. Caddy reicht außerdem das ursprüngliche Anfrageschema als X-Forwarded-Proto-Header bei jedem Hop weiter, und genau daher kommt die Redirect-Schleife.

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.WordPress spricht innerhalb des Docker-Netzwerks über einfaches HTTP mit dem wordpress-Container — TLS endete bei Caddy, drei Hops zuvor. Sich selbst überlassen, sieht WordPress zwar HTTP_X_FORWARDED_PROTO=https aus der echten Anfrage des Browsers, hält die Verbindung zu sich selbst aber trotzdem für http, und FORCE_SSL_ADMIN — das du willst, damit wp-admin niemals unverschlüsselt läuft — leitet eine Seite, von der es glaubt, sie lade bereits über HTTP, immer wieder auf https um, endlos. Die drei Zeilen in WORDPRESS_CONFIG_EXTRA oben sind die Lösung: Sie lesen den Header, den Caddy bereits sendet, und setzen PHPs eigenes $_SERVER['HTTPS'], bevor WordPress diese Entscheidung trifft, sodass FORCE_SSL_ADMIN eine Anfrage sieht, die tatsächlich HTTPS ist, und aufhört, sich im Kreis zu drehen. Lässt du diese Zeilen weg, ist das Symptom eindeutig: wp-admin lädt direkt gegen den Container über einfaches HTTP problemlos, und dreht sich im Kreis, sobald Caddy davorsteht.

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.Beachte, dass jedes $_SERVER oben in der Compose-Datei als $$_SERVER geschrieben ist. Das ist kein Tippfehler: Compose führt seine eigene $VAR-Substitution über jeden String in der docker-compose.yml durch, Block-Skalare eingeschlossen, noch bevor die Datei überhaupt als YAML geparst wird, und $_SERVER sieht genau wie ein Verweis auf eine Umgebungsvariable namens _SERVER aus. Lässt du die Dollarzeichen einfach, ersetzt Compose stillschweigend die (nicht gesetzte) Variable _SERVER durch einen leeren String, und das dabei entstehende kaputte PHP landet direkt in wp-config.php — die Seite bricht mit einem Fatal Error ab, statt nur daran zu scheitern, die Redirect-Schleife zu beheben. Das Verdoppeln des $ ist, wie du Compose sagst, es in Ruhe zu lassen und ein wörtliches $ an PHP durchzureichen.

The upload limit nobody remembersDas Upload-Limit, an das niemand denkt

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:WordPresses eigene Obergrenze für Medien-Uploads wird von PHP gesetzt, nicht von irgendetwas in wp-admin. Das offizielle Image basiert auf php:apache, das jede .ini-Datei einliest, die in /usr/local/etc/php/conf.d/ abgelegt wird, sodass eine kleine, read-only gemountete Datei die Aufgabe erledigt, ohne das Image anzufassen:

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.post_max_size muss genauso groß oder größer sein als upload_max_filesize, sonst gewinnt stillschweigend der kleinere Wert, und ein „großes“ Theme-ZIP oder Plugin-Archiv scheitert ohne brauchbare Fehlermeldung. Caddy selbst begrenzt die Body-Size einer Anfrage standardmäßig nicht so, wie es manche Reverse-Proxys tun, also gibt es bei der Caddyfile oben nichts, was du auf Caddys Seite anheben müsstest — fügst du dem Block aber jemals eine request_body { max_size ... }-Direktive hinzu, wird das zu einer zweiten Obergrenze über post_max_size, und die kleinste Zahl in der Kette gewinnt weiterhin.

Read this before you buy: the NAT catch for 443Lies das, bevor du kaufst: die NAT-Falle bei 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.Ein NAT-IPv4-VPS gibt dir eine Handvoll weitergeleiteter Ports; ob 443 dabei ist, hängt vom Tarif ab, also prüfe das, bevor du DNS auf die Box zeigen lässt — und welche Ports du auch bekommst, weitergeleitet wird an die Maschine, nicht an einen einzelnen Container. Läuft auf dieser Box nur eine Site, ist das kein Problem: Caddy bindet 443, WordPress steht dahinter, fertig. Die Falle zeigt sich in dem Moment, in dem du eine zweite HTTPS-Site oder -App auf demselben VPS willst — du kannst nicht auch einen zweiten Prozess direkt an 443 binden, weil der Port auf der Netzwerkebene nur einmal existiert.

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.Die Lösung ist die, die Caddy dir ohnehin schon kostenlos mitgibt: Lass eine einzige Caddy-Instanz das Einzige sein, das an 443 gebunden ist, und route nach Hostnamen. Füge derselben Caddyfile einen zweiten Block für eine zweite Site hinzu, der auf ein anderes 127.0.0.1:<port> zeigt, und Caddy wählt anhand des Host-Headers das richtige Backend, bevor überhaupt eine der beiden Apps erreicht wird. Was nicht geht, ist WordPress' eigenen Container mit einem direkten 443:443-Port-Mapping laufen zu lassen, sobald irgendetwas anderes denselben Port ebenfalls braucht.

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.Brauchst du ausdrücklich eine eigenständige IPv4-Adresse mit 443 ganz für sich, gibt es das — aber das wird per E-Mail eingerichtet, nicht selbst im Panel. Prüfe, was dein Plan weiterleitet, bevor du eine Domain darauf festlegst — NAT-IPv4, Ports und Weiterleitung und NAT-IPv4 vs. dedizierte IP behandeln die Mechanik ausführlicher, als eine WordPress-Anleitung braucht.

Security basics that matter more than any pluginSicherheits-Grundlagen, die wichtiger sind als jedes Plugin

None of this is a plugin problem, and installing one rarely fixes it:Nichts davon ist ein Plugin-Problem, und eines zu installieren behebt es selten:

Backups: the volume and the databaseBackups: das Volume und die Datenbank

Two things, and the database changes constantly so it needs its own step:Zwei Dinge, und die Datenbank ändert sich ständig, deshalb braucht sie einen eigenen Schritt:

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:Das liest das Root-Passwort direkt aus der eigenen Umgebung des db-Containers, sodass nichts Sensibles in deiner Shell-History landet. Für das Volume — Themes, Plugins, Uploads, alles, was nicht die Datenbank ist:

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.Passe den Volume-Namen an das an, was docker compose config --volumes für dein Projekt tatsächlich ausgibt — Compose stellt ihm standardmäßig den Namen des Projektverzeichnisses voran. Kopiere beide Dateien vollständig vom VPS herunter; ein Dump, der neben der Instanz liegt, aus der er stammt, ist kein Backup, sondern nur eine Datei. Backup deines VPS beschreibt, was „off-machine“ in der Praxis konkret bedeutet.

Updates: bump the tag, let WordPress do the restUpdates: den Tag anheben, den Rest macht WordPress

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.Erstelle den mariadb-dump von oben unmittelbar davor, nicht danach. WordPress führt sein eigenes Datenbank-Upgrade automatisch aus, sobald ein Admin zum ersten Mal eine Seite auf der neuen Version lädt — du führst keinen separaten Migrationsbefehl aus —, aber dieses Upgrade ist eine Einbahnstraße. Lies die Release Notes für die Version, zu der du springst, bevor du den Tag anhebst, besonders bei einem Sprung über eine Major-Version hinweg.

SizingDimensionierung

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.WordPress plus MariaDB plus Caddy ist ein leichter Stack für eine kleine Site, bei der ein Caching-Plugin seinen Job macht: 1 GiB RAM ist eine machbare Untergrenze, und der Speicherplatz geht größtenteils in die Mediathek statt in die Anwendung selbst.

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.Die Zahl ändert sich, sobald die Site mehr tut, als Seiten auszuliefern. WooCommerce bringt spürbares Datenbankgewicht mit — Bestellungen, Sessions, Produktvarianten —, und ein Page-Builder wie Elementor oder Divi lässt beim Bearbeiten spürbar schwereres PHP pro Request laufen, auch wenn die öffentliche Seite selbst schnell bleibt. 2 GiB sind die realistische Untergrenze, sobald eines von beidem dazukommt, und es lohnt sich, vorher umzuziehen statt hinterher, wenn die Site schon langsam ist.

On overnight.hostBei 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.Zur vollen Transparenz: Das ist, was wir verkaufen. Willst du die Site ohne die Systemadministration, kommt der gemanagte WordPress-Container mit eigenem Hostnamen und Zertifikat.

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.One-Click-Apps — 4 bis 12 EUR im Monat, gehostet in Deutschland (EU). Acht Apps: n8n, Uptime Kuma, Vaultwarden, Gitea, Nextcloud, Ghost, Managed WordPress, Private AI Chat. Jeder Kunde bekommt ein isoliertes Docker-Netzwerk und -Volume sowie einen Hostnamen unter apps.overnight.host mit einem echten Wildcard-Zertifikat. Arbeitsspeicher und CPU sind je Plan durch die Container-Runtime gedeckelt.

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.Du bestellst im Shop, zahlst per Karte (Stripe) oder SEPA-Überweisung, und deine Zugangsdaten werden dir per E-Mail zugeschickt, sobald der Dienst eingerichtet ist. Support läuft per E-Mail, von einer einzelnen Person betrieben, ohne garantierte Reaktionszeit. Alle Preise sind Endpreise. Gemäß § 19 UStG wird keine Umsatzsteuer ausgewiesen.

Order managed-wordpress → · One-click apps overviewManaged WordPress bestellen → · Übersicht One-Click-Apps

Written by the person who runs overnight.host: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at up.overnight.host.Geschrieben von der Person, die overnight.host betreibt: ein kleines, ehrliches Hosting-Unternehmen auf dedizierter Bare-Metal-Hardware — Linux-VPS, Gameserver, Webhosting. Live-Status unter up.overnight.host.

Technical guidance is informational. Plans, specifications and final prices are listed in the shop and can be ordered directly; VPS, game server, web hosting, one-click app and automation plans are provisioned automatically after payment. Custom configurations are still arranged by e-mail.Technische Hinweise dienen der Information. Pläne, Spezifikationen und Endpreise stehen im Shop und können direkt bestellt werden; VPS-, Gameserver-, Webhosting-, One-Click-App- und Automatisierungs-Pläne werden nach der Zahlung automatisch bereitgestellt. Sonderkonfigurationen werden weiterhin per E-Mail vereinbart.

FAQFAQ

Why does wp-admin keep redirecting in a loop behind Caddy?Warum leitet wp-admin hinter Caddy dauernd im Kreis weiter?

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.WordPress sieht zwar, dass die Anfrage vom Browser aus HTTPS war, weiß aber nicht, dass seine eigene Verbindung zu sich selbst mitzählt, also versucht FORCE_SSL_ADMIN immer wieder, ein Schema zu erzwingen, von dem es ohnehin schon glaubt, es nicht zu benutzen. HTTP_X_FORWARDED_PROTO auszulesen und $_SERVER['HTTPS'] zu setzen, bevor diese Prüfung läuft, wie oben gezeigt, ist das, was die Schleife durchbricht.

Can I run more than one HTTPS site on the same NAT IPv4 VPS?Kann ich mehr als eine HTTPS-Site auf demselben NAT-IPv4-VPS betreiben?

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.Ja, aber nur über einen einzigen Prozess, der an 443 gebunden ist. Stell Caddy vor alles und gib ihm einen Block pro Hostname; der eigene Container jeder Site bleibt auf einem privaten Port, mit dem nur Caddy spricht. Was nicht funktioniert, sind zwei Container, die beide versuchen, direkt an 443 zu binden.

What actually happens if I skip the uploads.ini?Was passiert eigentlich, wenn ich die uploads.ini weglasse?

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.Medien-Uploads sowie Plugin- oder Theme-Installationen scheitern oberhalb von PHPs Standardlimit — meist eine ziemlich kleine Zahl — mit einer Fehlermeldung, die eher nach einer kaputten Datei aussieht als nach „zu groß“. Es ist einer der häufigeren „WordPress ist kaputt“-Berichte, der eigentlich an einer PHP-Einstellung liegt, die niemand angehoben hat.

I already back up the wp-content volume — do I still need a separate database dump?Ich sichere das wp-content-Volume bereits — brauche ich trotzdem einen separaten Datenbank-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.Ja. Das Volume hat deine Dateien; die Datenbank hat deine Beiträge, Einstellungen und alles, was WooCommerce oder ein anderes Plugin als Zeilen statt als Dateien speichert. Eine Wiederherstellung mit nur einem von beiden gibt dir eine Site mit allen Dateien und ohne Inhalt — oder umgekehrt.

Is a managed WordPress container different from self-hosting?Unterscheidet sich ein gemanagter WordPress-Container vom 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.Darunter dieselbe Anwendung. Der Unterschied ist, wer die Shell hat: Ein gemanagter Container gibt dir die Site, einen Hostnamen und ein Zertifikat, aber du darfst nicht selbst uploads.ini bearbeiten oder dich per SSH einloggen, um docker compose pull auszuführen. Willst du diese Kontrolle, gehört es stattdessen auf einen VPS.

Ready to order?Bereit zu bestellen?

Prices are final totals; no VAT is shown (§19 UStG). Need something the shop does not list? Email us for a written offer.Alle Preise sind Endpreise ohne ausgewiesene USt. (§19 UStG). Du brauchst etwas, das nicht im Shop steht? Schreib uns für ein schriftliches Angebot.

Order now →Jetzt bestellen → Request a custom configIndividuelle Konfiguration anfragen