<?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>Middleware – Serverküche</title><link>https://serverkueche.de/en/tags/middleware/</link><description>Middleware – 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>Thu, 01 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/middleware/index.xml" rel="self" type="application/rss+xml"/><item><title>Hardening Traefik: Security Headers, Rate Limit, BasicAuth &amp; IP Allowlist</title><link>https://serverkueche.de/en/tutorials/traefik-middlewares-hardening/</link><pubDate>Thu, 01 Oct 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/traefik-middlewares-hardening/</guid><description>Using Traefik v3 middlewares properly: set security headers, throttle requests, protect services with BasicAuth and an IP allowlist – all verified with curl.</description><content:encoded><![CDATA[<p>Your Traefik routes requests and fetches certificates – but it does not protect anything yet. Four
middlewares change that: security headers, a rate limit, BasicAuth and an IP allowlist. We build them
one at a time and <strong>measure with <code>curl</code> after every step</strong> what actually changed.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>At the end you have four reusable middlewares and a chain you can attach to any service:</p>
<table>
	<thead>
			<tr>
					<th>Middleware</th>
					<th>Effect</th>
					<th>Measured</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>sec-headers</code></td>
					<td>HSTS, clickjacking and MIME protection</td>
					<td>5 new response headers</td>
			</tr>
			<tr>
					<td><code>rate</code></td>
					<td>limits requests per IP</td>
					<td>40 parallel requests → 13× <code>200</code>, 27× <code>429</code></td>
			</tr>
			<tr>
					<td><code>auth</code></td>
					<td>BasicAuth in front of the service</td>
					<td>without password <code>401</code>, with it <code>200</code></td>
			</tr>
			<tr>
					<td><code>only-me</code></td>
					<td>only your IP gets in</td>
					<td>foreign IP <code>403</code>, your own <code>200</code></td>
			</tr>
	</tbody>
</table>
<p>Tested with <strong>Traefik v3.7.8</strong> on Debian 13. Important if you are migrating: in Traefik v3 the
allowlist is called <code>ipAllowList</code> – the v2 name <code>ipWhiteList</code> no longer exists, and configurations
using it break on upgrade.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <strong>running Traefik v3</strong> with a <code>proxy</code> network and working TLS, as built in
<a href="/en/tutorials/traefik-reverse-proxy/">Traefik as a reverse proxy</a></li>
<li>A service behind it to practise on. We use <code>traefik/whoami</code> – a tiny container that echoes every
request including its headers, which makes it perfect for verification</li>
<li>A basic grasp of <a href="/en/tutorials/docker-compose-basics/">Docker Compose</a> and labels</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/traefik-middlewares-hardening/" data-content-piece="VPS 1000 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 1000 G12.5</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 128 GB SSD</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €14.50/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Middlewares cost virtually no resources – this runs on the smallest VPS.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12.5-iv-24m-eu?ref=44083#vps-1000-g12.5-iv-12m-eu" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 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"
     data-voucher-pool="vps-1000" data-voucher-codes="[&#34;6877nc17905136544&#34;,&#34;6877nc17905136543&#34;,&#34;6877nc17905136542&#34;,&#34;6877nc17905136541&#34;,&#34;6877nc17905136540&#34;,&#34;6877nc17905133999&#34;,&#34;6877nc17905133998&#34;,&#34;6877nc17905133997&#34;,&#34;6877nc17905133996&#34;,&#34;6877nc17905133995&#34;,&#34;6877nc17905133994&#34;,&#34;6877nc17905133993&#34;,&#34;6877nc17905133992&#34;,&#34;6877nc17905133991&#34;,&#34;6877nc17905133990&#34;]">
  <p class="flex flex-wrap items-center gap-x-2 gap-y-1">
    <span>💶 <span class="font-semibold">1 month free</span> for new netcup customers:</span>
    <button type="button" data-voucher-code data-track-voucher="6877nc17905136544"
            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">6877nc17905136544</button>
    <button type="button" data-voucher-next hidden
            class="rounded border border-herb-500/40 px-2 py-0.5 text-xs text-herb-800 hover:bg-white dark:text-herb-400 dark:hover:bg-slate-800">another code</button>
  </p>
  <p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
    Single use, valid for VPS 1000 G12.5.
    <a href="https://www.netcup.com/en/checkout/cart?ref=44083" rel="sponsored noopener" target="_blank"
       data-track-event="Affiliate|netcup: Gutschein einlösen|VPS 1000 G12.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><p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-herb-500/30 pt-2">
    <span>💶 <span class="font-semibold">€5 voucher</span> 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">always valid · not for domains or VPS Lite</span>
  </p>
</div></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="1-measure-the-starting-point">1. Measure the starting point</h3>
<p>Before hardening anything, record what it looked like before. Create a test service –
<code>~/whoami/compose.yaml</code>, replacing <code>YOUR_DOMAIN</code>:</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">whoami</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">traefik/whoami:v1.12</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">networks</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">proxy]</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.whoami.rule=Host(`whoami.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.whoami.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.whoami.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.whoami.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">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></span></code></pre></div>
</div>
<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 ~/whoami <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/whoami <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Now look at the response headers:</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://whoami.YOUR_DOMAIN/</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 200
</span></span><span class="line"><span class="cl">content-type: text/plain; charset=utf-8
</span></span><span class="line"><span class="cl">date: Sat, 25 Jul 2026 07:01:27 GMT
</span></span><span class="line"><span class="cl">content-length: 351</span></span></code></pre></div>
</div>
<p>That is the whole truth: <strong>Traefik sets no security header at all on its own.</strong> TLS gives you an
encrypted transport – it does nothing against clickjacking, MIME sniffing or accidental HTTP.</p>
<h3 id="2-set-security-headers">2. Set security headers</h3>
<p>With Docker labels, middlewares are <strong>defined</strong> on any container and <strong>activated</strong> on a router. Add
these labels to the test service:</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="w">      </span><span class="c"># activate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.routers.whoami.middlewares=sec-headers&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># define</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=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.middlewares.sec-headers.headers.stsPreload=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.middlewares.sec-headers.headers.frameDeny=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.middlewares.sec-headers.headers.contentTypeNosniff=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.middlewares.sec-headers.headers.referrerPolicy=strict-origin-when-cross-origin&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.sec-headers.headers.permissionsPolicy=camera=(), microphone=(), geolocation=()&#34;</span></span></span></code></pre></div>
</div>
<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 class="o">&amp;&amp;</span> sleep <span class="m">5</span> <span class="o">&amp;&amp;</span> curl -sI https://whoami.YOUR_DOMAIN/</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 200
</span></span><span class="line"><span class="cl">content-type: text/plain; charset=utf-8
</span></span><span class="line"><span class="cl">permissions-policy: camera=(), microphone=(), geolocation=()
</span></span><span class="line"><span class="cl">referrer-policy: strict-origin-when-cross-origin
</span></span><span class="line"><span class="cl">strict-transport-security: max-age=31536000; includeSubDomains; preload
</span></span><span class="line"><span class="cl">x-content-type-options: nosniff
</span></span><span class="line"><span class="cl">x-frame-options: DENY</span></span></code></pre></div>
</div>
<p>Five headers more, and each has a purpose:</p>
<ul>
<li><strong><code>strict-transport-security</code></strong> (HSTS) tells the browser: talk to this domain over HTTPS only, for
<strong>a year</strong>. That closes the gap between typing <code>example.com</code> and the redirect to HTTPS.</li>
<li><strong><code>x-frame-options: DENY</code></strong> forbids embedding in foreign <code>&lt;iframe&gt;</code>s – the classic clickjacking
protection.</li>
<li><strong><code>x-content-type-options: nosniff</code></strong> stops the browser from „guessing&quot; the content type and, say,
executing an uploaded text file as a script.</li>
<li><strong><code>referrer-policy</code></strong> prevents your full internal URL from being sent along when someone clicks an
external link.</li>
<li><strong><code>permissions-policy</code></strong> disables camera, microphone and location for this site.</li>
</ul>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-paprika-400 bg-paprika-50 dark:border-paprika-700 dark:bg-paprika-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🔥</span>preload is a one-way street
  </p>
  <div class="prose-kitchen text-sm"><code>stsPreload=true</code> is the invitation to have your domain added to the browsers&rsquo; <strong>HSTS preload list</strong>
(you apply for the listing separately). Once it is there, browsers enforce HTTPS for the domain <strong>and
all its subdomains</strong> – even if your server has long stopped serving a certificate. Getting out takes
months. Only set <code>stsIncludeSubdomains</code> and <code>stsPreload</code> if you are sure that <strong>every</strong> subdomain will
permanently speak HTTPS. To start with, <code>stsSeconds</code> alone is enough.</div>
</div>
<p>Deliberately <strong>not</strong> in the list: <code>browserXssFilter</code>. The <code>X-XSS-Protection</code> header is considered
obsolete and modern browsers ignore it. A <code>contentSecurityPolicy</code> is missing too – it is effective,
but differs per application and reliably breaks interfaces when applied blindly.</p>
<h3 id="3-rate-limiting-against-brute-force-and-bots">3. Rate limiting against brute force and bots</h3>
<p>This middleware limits how many requests <strong>a single IP</strong> may make:</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="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.rate.ratelimit.average=5&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.rate.ratelimit.burst=10&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.rate.ratelimit.period=1s&#34;</span></span></span></code></pre></div>
</div>
<p><code>average=5</code> with <code>period=1s</code> means five requests per second on average. <code>burst=10</code> allows short
spikes above that – web pages do load several files at once.</p>
<p>Activate it and verify with real load. Important: test <strong>in parallel</strong>, otherwise you are too slow to
hit the limit at all:</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">seq <span class="m">40</span> <span class="p">|</span> xargs -P <span class="m">8</span> -I<span class="o">{}</span> curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://whoami.YOUR_DOMAIN/ <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> sort <span class="p">|</span> uniq -c</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">     13 200
</span></span><span class="line"><span class="cl">     27 429</span></span></code></pre></div>
</div>
<p>That is exactly how it should look: the first requests go through, after that Traefik answers with
<code>429 Too Many Requests</code> without troubling the service behind it at all.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Sensible values
  </p>
  <div class="prose-kitchen text-sm">For a normal web interface, 5–20 requests per second with a burst of 20–50 is realistic. Set it too
tight and you block real users loading a single page. After enabling it, watch the Traefik logs for
<code>429</code> – if they show up in everyday use, the limit is too strict.</div>
</div>
<h3 id="4-basicauth-for-services-without-their-own-login">4. BasicAuth for services without their own login</h3>
<p>Some tools have no login at all, or a weak one – a Traefik dashboard, a metrics endpoint, an admin
interface. BasicAuth puts a hurdle in front of them before the request ever reaches the application.</p>
<p>First create a password hash. No extra package needed, Docker will do:</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 run --rm httpd:2.4-alpine htpasswd -nbB koch <span class="s2">&#34;YOUR_PASSWORD&#34;</span></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">koch:$2y$05$IcOtS/jq6VR4haHO2Pvtl...</span></span></code></pre></div>
</div>
<p><code>-B</code> forces bcrypt (not the old MD5), <code>-nb</code> writes the result to standard output.</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>Every $ has to be doubled in Compose
  </p>
  <div class="prose-kitchen text-sm">bcrypt hashes contain <code>$</code> characters, and Docker Compose treats <code>$</code> as a variable. So enter the hash
with <strong>doubled</strong> dollar signs: <code>$2y$05$Ic…</code> becomes <code>$$2y$$05$$Ic…</code>. Forget that and parts of the hash
vanish silently, making the login fail every time – <code>docker compose config</code> shows you what Compose
really produces.</div>
</div>
<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="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.auth.basicauth.users=koch:$$2y$$05$$IcOtS/jq6VR4haHO2Pvtl...&#34;</span></span></span></code></pre></div>
</div>
<p>Verify – three cases, three answers:</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 -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://whoami.YOUR_DOMAIN/
</span></span><span class="line"><span class="cl">curl -s -o /dev/null -u koch:wrong -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://whoami.YOUR_DOMAIN/
</span></span><span class="line"><span class="cl">curl -s -o /dev/null -u koch:YOUR_PASSWORD -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://whoami.YOUR_DOMAIN/</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">401
</span></span><span class="line"><span class="cl">401
</span></span><span class="line"><span class="cl">200</span></span></code></pre></div>
</div>
<p>On the <code>401</code> the browser asks for credentials itself – Traefik sends
<code>www-authenticate: Basic realm=&quot;traefik&quot;</code> along with it.</p>
<p>BasicAuth is deliberately simple: no logout, no second factor, no user management. For „nobody but me
should even be able to knock here&quot; it is exactly right; for real user accounts across several services,
single sign-on is the way to go.</p>
<h3 id="5-ip-allowlist-only-from-home">5. IP allowlist: only from home</h3>
<p>Even stricter: allow requests only from certain networks. In Traefik v3 the middleware is called
<code>ipAllowList</code>:</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="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.only-me.ipallowlist.sourcerange=YOUR_IP/32&#34;</span></span></span></code></pre></div>
</div>
<p>Find your public IP with <code>curl -s https://ifconfig.me</code>. Separate several ranges with commas, e.g.
<code>sourcerange=203.0.113.5/32,198.51.100.0/24</code>.</p>
<p>The test shows both sides. With a foreign IP in the list Traefik answers:</p>
<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">403</span></span></code></pre></div>
</div>
<p>And with your own:</p>
<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">200</span></span></code></pre></div>
</div>
<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>Which IP does Traefik even see?
  </p>
  <div class="prose-kitchen text-sm">Traefik checks the IP of the <strong>direct</strong> connection. If something sits in front of it – Cloudflare, a
load balancer, another proxy – you see that component&rsquo;s IP, and an allowlist with your own address
locks you out. What actually arrives is revealed by <code>traefik/whoami</code> in the <code>X-Forwarded-For</code> field.
For such setups there is <code>ipallowlist.ipstrategy.depth</code> and <code>excludedips</code> – but first check how many
proxies really sit in front.</div>
</div>
<p>An IP allowlist is effective but inconvenient: on mobile data or in hotel Wi-Fi you no longer get in.
The more elegant variant for „only me&quot; is a <a href="/en/tutorials/wireguard-vpn-setup/">WireGuard VPN</a> – then
you allow the VPN network instead of changing public IPs.</p>
<h3 id="6-bundle-middlewares-into-a-chain">6. Bundle middlewares into a chain</h3>
<p>Several middlewares on one router are listed comma-separated – <strong>the order is the execution order</strong>.
So you don&rsquo;t repeat this for every service, there is <code>chain</code>:</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="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.hardened.chain.middlewares=sec-headers,rate,auth&#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.whoami.middlewares=hardened&#34;</span></span></span></code></pre></div>
</div>
<p>Verify that everything applies at once:</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 -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://whoami.YOUR_DOMAIN/
</span></span><span class="line"><span class="cl">curl -sI -u koch:YOUR_PASSWORD https://whoami.YOUR_DOMAIN/ <span class="p">|</span> grep -i strict-transport</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">401
</span></span><span class="line"><span class="cl">strict-transport-security: max-age=31536000; includeSubDomains; preload</span></span></code></pre></div>
</div>
<p>Without credentials <code>401</code>, with credentials you get the response <strong>and</strong> the headers. As a rule of
thumb for the order: <strong>cheap rejections first.</strong> An IP allowlist drops the request without checking
anything; the rate limit costs almost nothing; BasicAuth has to compute a bcrypt hash – and that is
deliberately slow. So <code>only-me</code>, then <code>rate</code>, then <code>auth</code>.</p>
<h3 id="7-the-most-important-use-case-the-traefik-dashboard">7. The most important use case: the Traefik dashboard</h3>
<p>Traefik ships a dashboard showing all routers, services and middlewares – genuinely useful for
debugging. It is <strong>off</strong> by default, and for good reason: it reveals the complete structure of your
server. This is exactly where BasicAuth pays off.</p>
<p>Enable the API in the Traefik stack:</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="w">    </span><span class="nt">command</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;--api.dashboard=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># … your existing parameters …</span></span></span></code></pre></div>
</div>
<p>And give Traefik labels for itself. The trick is the internal service <code>api@internal</code>:</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="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.dashboard.rule=Host(`traefik.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.dashboard.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.dashboard.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.routers.dashboard.service=api@internal&#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.dashboard.middlewares=dashboard-auth&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.middlewares.dashboard-auth.basicauth.users=admin:$$2y$$05$$IcOtS/jq6VR4haHO2Pvtl...&#34;</span></span></span></code></pre></div>
</div>
<p>After <code>docker compose up -d</code>, check <strong>both</strong> entry points – the dashboard and the API underneath it:</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 -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://traefik.YOUR_DOMAIN/dashboard/
</span></span><span class="line"><span class="cl">curl -s -o /dev/null -u admin:YOUR_PASSWORD -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://traefik.YOUR_DOMAIN/dashboard/
</span></span><span class="line"><span class="cl">curl -s -o /dev/null -w <span class="s2">&#34;%{http_code}\n&#34;</span> https://traefik.YOUR_DOMAIN/api/overview
</span></span><span class="line"><span class="cl">curl -s -u admin:YOUR_PASSWORD https://traefik.YOUR_DOMAIN/api/overview</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">401
</span></span><span class="line"><span class="cl">200
</span></span><span class="line"><span class="cl">401
</span></span><span class="line"><span class="cl">{&#34;http&#34;:{&#34;routers&#34;:{&#34;total&#34;:3,&#34;warnings&#34;:0,&#34;errors&#34;:0},&#34;services&#34;:{&#34;total&#34;:5, …</span></span></code></pre></div>
</div>
<p>The third line is the important one: the <strong>API is protected just like the interface</strong>. Had you attached
the middleware only to a <code>/dashboard</code> path, <code>/api/…</code> would still be open – and with it your entire
configuration readable. Because the router here matches the whole host, the login covers both.</p>
<div class="not-prose my-6 rounded-lg border-l-4 p-4 border-herb-400 bg-herb-50 dark:border-herb-700 dark:bg-herb-900/20">
  <p class="mb-1 flex items-center gap-2 font-semibold text-slate-900 dark:text-white">
    <span aria-hidden="true">🧑‍🍳</span>Tip
  </p>
  <div class="prose-kitchen text-sm">Even better than BasicAuth alone is the combination for the dashboard: <code>only-me</code> (IP allowlist) <strong>and</strong>
<code>dashboard-auth</code> as a chain. Then an attacker has to be on the right network before they may even guess a
password. For a dashboard you rarely need, that is the right level of hardening.</div>
</div>
<h3 id="8-define-middlewares-centrally-the-scalable-way">8. Define middlewares centrally (the scalable way)</h3>
<p>So far we defined everything through labels. With five services you copy the same lines five times –
and changing the HSTS duration means editing five files. Better: define them <strong>once, centrally, in the
file provider</strong>.</p>
<p>Add two startup parameters and a mount to the Traefik stack:</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="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># … your existing parameters …</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.file.directory=/dynamic&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.file.watch=true&#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="c"># … your existing mounts …</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./dynamic:/dynamic:ro</span></span></span></code></pre></div>
</div>
<p>Then the middlewares as YAML in <code>~/traefik/dynamic/middlewares.yml</code>:</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">http</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">middlewares</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">sec-headers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">headers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">stsSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">31536000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">stsIncludeSubdomains</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">stsPreload</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">frameDeny</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">contentTypeNosniff</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">referrerPolicy</span><span class="p">:</span><span class="w"> </span><span class="l">strict-origin-when-cross-origin</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">rate</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">rateLimit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">average</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 class="nt">burst</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">period</span><span class="p">:</span><span class="w"> </span><span class="l">1s</span></span></span></code></pre></div>
</div>
<p>Restart Traefik – afterwards you attach them to any service with the suffix <strong><code>@file</code></strong>:</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="w">      </span>- <span class="s2">&#34;traefik.http.routers.whoami.middlewares=sec-headers@file,rate@file&#34;</span></span></span></code></pre></div>
</div>
<p>Verified: headers and rate limit behave exactly as they did via labels (12× <code>200</code>, 18× <code>429</code> for 30
parallel requests in the test). The gain: <code>--providers.file.watch=true</code> picks up changes to the file
<strong>without a restart</strong>, and the HSTS duration now lives in exactly one place.</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>Where does a middleware come from?
  </p>
  <div class="prose-kitchen text-sm">Traefik appends the origin to every name: middlewares defined through labels are internally
<code>sec-headers@docker</code>, those loaded from files are <code>sec-headers@file</code>. Within the same provider you may
omit the suffix – <strong>across provider boundaries you may not.</strong> That is exactly what causes most
„middleware not found&quot; errors.</div>
</div>
<h3 id="9-which-middleware-for-which-service">9. Which middleware for which service?</h3>
<p>Not every service needs everything. The right chain depends on who should use it and what it already
brings along:</p>
<table>
	<thead>
			<tr>
					<th>Type of service</th>
					<th>Examples</th>
					<th>sensible chain</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Public, for everyone</td>
					<td>website, blog, status page</td>
					<td><code>sec-headers</code></td>
			</tr>
			<tr>
					<td>Public with a login</td>
					<td>Nextcloud, Immich, Vaultwarden</td>
					<td><code>sec-headers</code>, <code>rate</code></td>
			</tr>
			<tr>
					<td>Only for you, with a login</td>
					<td>Uptime Kuma, Grafana</td>
					<td><code>sec-headers</code>, <code>rate</code> (+ allowlist if possible)</td>
			</tr>
			<tr>
					<td>Without its own login</td>
					<td>Traefik dashboard, metrics endpoints</td>
					<td><code>only-me</code>, <code>rate</code>, <code>auth</code></td>
			</tr>
			<tr>
					<td>Dangerous if abused</td>
					<td>Dockge, Portainer, Adminer</td>
					<td><code>only-me</code>, <code>auth</code> – or not public at all</td>
			</tr>
	</tbody>
</table>
<p>Two rules behind that: <strong>no rate limit in front of services that need many parallel requests</strong> – Immich
during a phone backup or a media server while streaming will otherwise run into <code>429</code>. And: <strong>never put
BasicAuth in front of an app that has its own login</strong> if it has a mobile app – most apps cannot cope
with two stacked authentications.</p>
<p>For everything in the last row, the honest answer is usually not „one more middleware&quot; but: don&rsquo;t put
it on the open internet, reach it through
<a href="/en/tutorials/wireguard-vpn-setup/">WireGuard</a>.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The logs say „middleware … does not exist&quot; and the service answers with 404.</strong> Almost always the
provider suffix is missing: a middleware defined in the file provider is called <code>name@file</code> from the
perspective of Docker labels. Without the suffix Traefik looks for it among the Docker labels – and
finds nothing. The same applies the other way round for <code>name@docker</code>.</p>
<p><strong>BasicAuth rejects the correct password.</strong> The <code>$</code> characters in the bcrypt hash were interpreted as
variables by Docker Compose. In Compose they have to be <strong>doubled</strong> (<code>$$2y$$05$$…</code>). <code>docker compose config</code> shows the value that really arrives.</p>
<p><strong>The rate limit doesn&rsquo;t seem to work.</strong> Sequential <code>curl</code> calls are too slow: every process opens a
new connection, so you stay below the limit. Test in parallel, e.g. with
<code>seq 40 | xargs -P 8 -I{} curl …</code> – then the <code>429</code>s appear.</p>
<p><strong>After setting the IP allowlist you get 403 yourself.</strong> Traefik does not see your IP but that of the
proxy in front of it (Cloudflare, load balancer). Check with <code>traefik/whoami</code> what <code>X-Forwarded-For</code>
contains, and use <code>ipallowlist.ipstrategy.depth</code> for multi-stage setups.</p>
<p><strong>A v2 configuration with <code>ipWhiteList</code> stops working after the upgrade.</strong> In Traefik v3 the middleware
is called <code>ipAllowList</code>; the old name was removed, so the rule no longer applies. Rename every
occurrence when migrating.</p>
<p><strong>The browser still refuses plain HTTP even though you fixed the configuration.</strong> That is HSTS, not a
bug: the browser remembers <code>max-age</code> and enforces HTTPS even when your server no longer offers it. A
private window or a different browser helps for testing – and that is why the warning about <code>preload</code>
exists above.</p>

</div>
<p class="mt-3 text-sm text-slate-500 dark:text-slate-400">
  Your problem is not listed?
  <a href="/en/errors/" class="font-medium text-paprika-700 hover:underline dark:text-paprika-400">Search all error messages →</a>
</p>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Re-check after every Traefik update.</strong> Middlewares are configuration, and configuration ages: between
v2 and v3 <code>ipWhiteList</code> became <code>ipAllowList</code>, other fields got new names. After an update a quick pass
with <code>curl -sI</code> is worth it – do the expected headers still appear? Does the allowlist still answer
<code>403</code>? Two minutes that save you from silent ineffectiveness.</p>
<p><strong>Back up the <code>dynamic</code> directory.</strong> If you follow the file provider, it holds your entire hardening. It
belongs in the backup together with <code>compose.yaml</code> and <code>acme.json</code> – small, but painful to reconstruct.
How to automate that is covered in <a href="/en/tutorials/restic-backups/">Restic backups</a>.</p>
<p><strong>Allowlists age.</strong> Dynamic IP addresses of home connections change, office networks move. Take a look
at your <code>sourcerange</code> entries once a quarter and remove anything you can no longer place – an allowlist
containing strangers&rsquo; addresses is worse than none.</p>
<p><strong>Honest about the reach of these measures.</strong> Security headers protect your users&rsquo; browsers, not your
server. A rate limit slows brute force down, it does not prevent it. BasicAuth is a door with a lock,
not a user concept. What these four middlewares do <strong>not</strong> replace: a
<a href="/en/tutorials/harden-ssh/">hardened SSH access</a>, a
<a href="/en/tutorials/firewall-ufw-setup/">firewall</a>,
<a href="/en/tutorials/unattended-upgrades-automatic-updates/">automatic updates</a> and
<a href="/en/tutorials/fail2ban-setup/">fail2ban</a> or <a href="/en/tutorials/crowdsec-setup/">CrowdSec</a> for the layer
below. The middlewares are the layer that sits in front of your applications – not the only one.</p>
]]></content:encoded></item></channel></rss>