<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Cms – Serverküche</title><link>https://serverkueche.de/en/tags/cms/</link><description>Cms – Neueste Beiträge von Serverküche</description><generator>Hugo</generator><language>en-US</language><managingEditor>feedback@serverkueche.de (Serverküche)</managingEditor><webMaster>feedback@serverkueche.de (Serverküche)</webMaster><copyright>2026 Serverküche</copyright><lastBuildDate>Tue, 22 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/cms/index.xml" rel="self" type="application/rss+xml"/><item><title>Running WordPress Cleanly with Docker &amp; Traefik</title><link>https://serverkueche.de/en/tutorials/wordpress-with-docker/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/wordpress-with-docker/</guid><description>Set up WordPress with Docker and MariaDB behind Traefik – with HTTPS, an isolated database and an honest update strategy instead of a maintenance nightmare.</description><content:encoded><![CDATA[<p>WordPress powers a large part of the web – and is notorious for hacked installations. The difference between &ldquo;runs and is secure&rdquo; and &ldquo;becomes a spam cannon&rdquo; 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.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>A production-ready WordPress installation with <strong>WordPress 7.1.0</strong> (PHP 8.5) and <strong>MariaDB 11.8</strong> behind <a href="/en/tutorials/traefik-reverse-proxy/">Traefik</a>. The database runs in an <strong>isolated internal network</strong> 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.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-sky-300 bg-sky-50 dark:border-sky-800 dark:bg-sky-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">ℹ️</span>WordPress or Hugo?
  </p>
  <div class="prose-kitchen text-sm">If you &ldquo;only&rdquo; need a website or blog without interactive features, a static site <a href="/en/tutorials/host-your-website-with-hugo/">with Hugo</a> 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.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server with <a href="/en/tutorials/traefik-reverse-proxy/">Traefik</a> running and <a href="/en/tutorials/docker-compose-basics/">Docker Compose</a></li>
<li>A <a href="/en/tutorials/connect-domain-to-server/">domain pointing at the server</a> – <code>YOUR_DOMAIN</code> below</li>
<li>A bit more RAM than simple services need – WordPress + database + PHP run comfortably from the VPS 2000 up</li>
</ul>
<div class="not-prose my-6 overflow-hidden rounded-xl border border-paprika-200 bg-paprika-50 dark:border-paprika-800 dark:bg-paprika-900/20"
     data-track-content data-content-name="Affiliate-Box · /en/tutorials/wordpress-with-docker/" data-content-piece="VPS 2000 G12.5">
  <div class="flex items-center justify-between border-b border-paprika-200 bg-paprika-100 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-paprika-700 dark:border-paprika-800 dark:bg-paprika-900/40 dark:text-paprika-300">
    <span>🍳 Recommendation</span>
    <span title="Links marked with * are affiliate links.">Ad</span>
  </div>
  <div class="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
    <div>
      <p class="text-lg font-bold text-slate-900 dark:text-white">VPS 2000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">8 vCores · 16 GB RAM · 256 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €26.92/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">WordPress with a database and PHP runs comfortably on the VPS 2000.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-2000-g12.5-iv-24m-eu?ref=44083#vps-2000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 2000 G12.5 · {page}"
   class="inline-flex shrink-0 items-center justify-center rounded-lg bg-paprika-600 px-5 py-2.5 font-semibold text-white transition-colors hover:bg-paprika-700">
  Go to netcup →
</a>

  </div><div class="px-4 pb-4"><div class="not-prose my-3 rounded-lg border border-herb-500/40 bg-herb-50 px-3 py-2 text-sm text-slate-700 dark:bg-herb-900/20 dark:text-slate-200">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1">
    <span>💶 <strong>€5 voucher</strong> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="36nc17844976032"
            title="Click to copy" class="cursor-pointer rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 hover:ring-2 hover:ring-herb-500/40 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</button>
    <span class="text-xs text-slate-500 dark:text-slate-400">(not for domains or VPS Lite)</span>
  </p>
  <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|€5 · {page}"
       class="font-medium text-herb-800 underline underline-offset-2 hover:text-herb-900 dark:text-herb-400">Redeem in the cart →</a>
  </p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-two-containers-two-networks">Step 1: Two containers, two networks</h3>
<p>WordPress consists of two parts: the PHP application and a <strong>MariaDB</strong> database. A clean setup separates the two and isolates the database – it should only be reachable by WordPress and needs no internet itself. That&rsquo;s exactly what the two networks are for (see <a href="/en/tutorials/understanding-docker-networks/">Understanding Docker networks</a>): the public <code>proxy</code> network between WordPress and Traefik, and an <code>internal</code> network between WordPress and the database.</p>
<p><figure class="my-6"><img src="wordpress-schema.svg" alt="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" title="Traefik terminates TLS and tells WordPress the HTTPS origin via X-Forwarded-Proto; the MariaDB sits on the internal network with no internet access" loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Traefik terminates TLS and tells WordPress the HTTPS origin via X-Forwarded-Proto; the MariaDB sits on the internal network with no internet access</figcaption></figure></p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /opt/wordpress <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/wordpress</span></span></code></pre></div>
</div>
<h3 id="step-2-the-compose-file">Step 2: The Compose file</h3>
<p>Replace <code>YOUR_DOMAIN</code> and all passwords with your own values:</p>
<div class="sk-code">
  <span class="sk-code-head">YAML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">mariadb:11.8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_DATABASE</span><span class="p">:</span><span class="w"> </span><span class="l">wordpress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_USER</span><span class="p">:</span><span class="w"> </span><span class="l">wordpress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_RANDOM_ROOT_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">db_data:/var/lib/mysql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">intern]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;healthcheck.sh&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--connect&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--innodb_initialized&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_interval</span><span class="p">:</span><span class="w"> </span><span class="l">2s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">wordpress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">wordpress:7.1.0-php8.5-apache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">db</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">WORDPRESS_DB_HOST</span><span class="p">:</span><span class="w"> </span><span class="l">db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">WORDPRESS_DB_USER</span><span class="p">:</span><span class="w"> </span><span class="l">wordpress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">WORDPRESS_DB_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_DB_PASSWORD</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">WORDPRESS_DB_NAME</span><span class="p">:</span><span class="w"> </span><span class="l">wordpress</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">WORDPRESS_CONFIG_EXTRA</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">        if (isset($$_SERVER[&#39;HTTP_X_FORWARDED_PROTO&#39;]) &amp;&amp; $$_SERVER[&#39;HTTP_X_FORWARDED_PROTO&#39;] === &#39;https&#39;) { $$_SERVER[&#39;HTTPS&#39;] = &#39;on&#39;; }
</span></span></span><span class="line"><span class="cl"><span class="sd">        define(&#39;WP_HOME&#39;, &#39;https://YOUR_DOMAIN&#39;);
</span></span></span><span class="line"><span class="cl"><span class="sd">        define(&#39;WP_SITEURL&#39;, &#39;https://YOUR_DOMAIN&#39;);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">wp_data:/var/www/html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">proxy, intern]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">labels</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.enable=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.wp.rule=Host(`YOUR_DOMAIN`)&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.wp.entrypoints=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.wp.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.wp.loadbalancer.server.port=80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">db_data</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">wp_data</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">proxy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">external</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">intern</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">internal</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div>
</div>
<p>Three details are crucial here:</p>
<ul>
<li><strong><code>depends_on: condition: service_healthy</code></strong> – WordPress only starts once the database reports ready via the health check. Without it the first start fails in a race condition.</li>
<li><strong><code>intern</code> is <code>internal: true</code></strong> – the database is <em>only</em> on this network and therefore has no internet access; WordPress reaches it at the hostname <code>db</code>.</li>
<li><strong><code>WORDPRESS_CONFIG_EXTRA</code></strong> – the most important part (next step).</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>The reverse-proxy pitfall: detecting HTTPS
  </p>
  <div class="prose-kitchen text-sm">Traefik terminates TLS and talks to WordPress internally over HTTP. WordPress therefore thinks it&rsquo;s running insecurely and generates <code>http://</code> links – which leads to <strong>mixed-content errors</strong> or a <strong>redirect loop</strong> (<code>ERR_TOO_MANY_REDIRECTS</code>). The line <code>if (... HTTP_X_FORWARDED_PROTO ... === 'https') { $_SERVER['HTTPS'] = 'on'; }</code> tells WordPress the original connection was HTTPS. Together with <code>WP_HOME</code>/<code>WP_SITEURL</code> that&rsquo;s the clean solution. In Compose the PHP variables must be written as <code>$$</code>, otherwise Docker interprets them as its own variables.</div>
</div>
<h3 id="step-3-start">Step 3: Start</h3>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>Docker starts the database first, waits for its health check and then starts WordPress. Check:</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose ps</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">NAME                    IMAGE                           SERVICE     STATUS
</span></span><span class="line"><span class="cl">wordpress-db-1          mariadb:11.8                    db          Up (healthy)
</span></span><span class="line"><span class="cl">wordpress-wordpress-1   wordpress:7.1.0-php8.5-apache   wordpress   Up</span></span></code></pre></div>
</div>
<p>Verify from outside that WordPress responds with valid HTTPS (the 302 redirects to the install):</p>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -sI https://YOUR_DOMAIN/ <span class="p">|</span> head -1</span></span></code></pre></div>
</div>
<div class="sk-code">
  <span class="sk-code-head">Ausgabe</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">HTTP/2 302</span></span></code></pre></div>
</div>
<h3 id="step-4-the-famous-5-minute-install">Step 4: The famous 5-minute install</h3>
<p>Open <code>https://YOUR_DOMAIN/</code>. WordPress redirects to the install wizard. After the language selection you fill in the basics:</p>
<p><figure class="my-6"><img src="/en/tutorials/wordpress-with-docker/wp-install_hu_b47848176a26413b.webp" srcset="/en/tutorials/wordpress-with-docker/wp-install_hu_8b5e1c4e018763eb.webp 480w, /en/tutorials/wordpress-with-docker/wp-install_hu_b47848176a26413b.webp 768w, /en/tutorials/wordpress-with-docker/wp-install_hu_f4bb6ce882f2e5bb.webp 1200w, /en/tutorials/wordpress-with-docker/wp-install_hu_6b1fb4664d975647.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/wordpress-with-docker/wp-install_hu_47fd6c7bb056c68.webp"
    alt="The WordPress install wizard with fields for title, username, password and email" title="The 5-minute install: site title, admin account and email"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The 5-minute install: site title, admin account and email</figcaption></figure></p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-amber-400 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">⚠️</span>Don&#39;t use “admin” as the username
  </p>
  <div class="prose-kitchen text-sm">Do <strong>not</strong> choose the username <code>admin</code> – it&rsquo;s the first target of every brute-force attack on WordPress. Use your own name and a strong password. That&rsquo;s the simplest and most effective hardening measure of all.</div>
</div>
<p>After clicking &ldquo;Install WordPress&rdquo; and the first login you land in the dashboard:</p>
<p><figure class="my-6"><img src="/en/tutorials/wordpress-with-docker/wp-dashboard_hu_1692b0ee98741d7c.webp" srcset="/en/tutorials/wordpress-with-docker/wp-dashboard_hu_86c97ff520835c54.webp 480w, /en/tutorials/wordpress-with-docker/wp-dashboard_hu_1692b0ee98741d7c.webp 768w, /en/tutorials/wordpress-with-docker/wp-dashboard_hu_6eac735e1a9dba35.webp 1200w, /en/tutorials/wordpress-with-docker/wp-dashboard_hu_3faab59025abce14.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/wordpress-with-docker/wp-dashboard_hu_876e51fc7e0601a7.webp"
    alt="The WordPress dashboard after installation, version 7.1 with the Twenty Twenty-Five theme" title="The dashboard: here you manage posts, pages, design and plugins"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard: here you manage posts, pages, design and plugins</figcaption></figure></p>
<h3 id="step-5-the-website-is-live">Step 5: The website is live</h3>
<p>Your site is immediately public – with valid HTTPS from Traefik and no mixed-content errors thanks to the proxy configuration:</p>
<p><figure class="my-6"><img src="/en/tutorials/wordpress-with-docker/wp-frontend_hu_cbaf94c399fbe57.webp" srcset="/en/tutorials/wordpress-with-docker/wp-frontend_hu_2b176ffbcdd51711.webp 480w, /en/tutorials/wordpress-with-docker/wp-frontend_hu_cbaf94c399fbe57.webp 768w, /en/tutorials/wordpress-with-docker/wp-frontend_hu_5af1d3fe821db4dd.webp 1200w, /en/tutorials/wordpress-with-docker/wp-frontend_hu_413154f930c4ba93.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/wordpress-with-docker/wp-frontend_hu_2bc866196aa1e212.webp"
    alt="The frontend of the freshly installed WordPress site with the first post &ldquo;Hello world!&rdquo;" title="The frontend under your own domain – the default post \&#34;Hello world!\&#34;"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The frontend under your own domain – the default post \&#34;Hello world!\&#34;</figcaption></figure></p>
<h3 id="step-6-basic-hardening">Step 6: Basic hardening</h3>
<p>WordPress is functional out of the box but not optimally secured. The most important immediate measures:</p>
<ul>
<li><strong>Enable automatic updates:</strong> under <strong>Dashboard → Updates</strong> make sure at least security updates are applied automatically.</li>
<li><strong>Delete unnecessary default content:</strong> the &ldquo;Hello world!&rdquo; post, the sample page and the bundled example plugin (<code>Hello Dolly</code>) can go.</li>
<li><strong>Less is more with plugins:</strong> every plugin is additional attack surface. Only install what you really need, and only from a trustworthy source.</li>
<li><strong>Protect the login further:</strong> ideally put another hurdle in front of the WordPress login (<code>/wp-login.php</code>). A rate-limit/BasicAuth middleware in Traefik or a 2FA plugin work well. <a href="/en/tutorials/fail2ban-setup/">Fail2ban</a> can also evaluate WordPress login attempts.</li>
<li><strong>Keep an eye on XML-RPC:</strong> the file <code>xmlrpc.php</code> is a common target for brute force and DDoS. If you don&rsquo;t need it, block it via a Traefik middleware or plugin.</li>
</ul>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>Redirect loop (<code>ERR_TOO_MANY_REDIRECTS</code>) or mixed-content warnings.</strong> The HTTPS detection is missing. Check that the <code>WORDPRESS_CONFIG_EXTRA</code> block with <code>HTTP_X_FORWARDED_PROTO</code> and the <code>$$</code> signs is set exactly (see the warning in step 2), and restart the WordPress container.</p>
<p><strong>WordPress won&rsquo;t start, reports &ldquo;Error establishing a database connection&rdquo;.</strong> Usually a race condition or a password mismatch. Check that <code>depends_on: condition: service_healthy</code> is set and <code>WORDPRESS_DB_PASSWORD</code> matches <code>MARIADB_PASSWORD</code> exactly. Logs: <code>docker compose logs db</code>.</p>
<p><strong>The first start fails even though everything runs later.</strong> The database needs a few seconds to initialize on the very first start. The health check covers this; without it, another <code>docker compose up -d</code> helps.</p>
<p><strong>Media uploads fail for large files.</strong> PHP limits the upload size. Create your own <code>uploads.ini</code> and mount it to <code>/usr/local/etc/php/conf.d/</code> with e.g. <code>upload_max_filesize = 64M</code> and <code>post_max_size = 64M</code>.</p>
<p><strong>After a domain change the site points nowhere.</strong> <code>WP_HOME</code>/<code>WP_SITEURL</code> are hard-wired in the Compose file – for a new domain, change them there and restart the container (the values override the database setting).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>Honestly: WordPress is maintenance work.</strong> Unlike a static site, active code with a database runs here – core, themes and plugins get regular security updates that must be applied <strong>promptly</strong>. A neglected WordPress will reliably get hijacked. Enable automatic updates and check the update status monthly. Keep the container tag (<code>wordpress:7.1.0-php8.5-apache</code>) current via your normal <a href="/en/tutorials/keep-docker-stack-updated/">update process</a>.</li>
<li><strong>The backup has two parts – both are mandatory.</strong> WordPress consists of a <strong>database</strong> (posts, settings, users) <em>and</em> <strong>files</strong> (<code>wp_data</code> volume: uploads, themes, plugins). A Restic snapshot of the running MariaDB volume alone is no guarantee – back up the database additionally via a dump. <code>-T</code> matters here (without it the command hangs on the password prompt and leaves an <strong>empty</strong> file), as does putting the password directly after <code>-p</code>, with no space:</li>
</ul>
<div class="sk-code">
  <span class="sk-code-head">Terminal</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -T db mariadb-dump -u wordpress -p<span class="s1">&#39;YOUR_DB_PASSWORD&#39;</span> --single-transaction wordpress &gt; backup.sql</span></span></code></pre></div>
</div>
<p>Afterwards make sure the file isn&rsquo;t empty (<code>ls -l backup.sql</code>). The dump <strong>and</strong> the <code>wp_data</code> volume belong in your <a href="/en/tutorials/restic-backups/">Restic backup</a>.</p>
<ul>
<li><strong>Test the restore.</strong> 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.</li>
</ul>
]]></content:encoded></item></channel></rss>