<?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>Https – Serverküche</title><link>https://serverkueche.de/en/tags/https/</link><description>Https – 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>Sat, 18 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/https/index.xml" rel="self" type="application/rss+xml"/><item><title>Setting up Traefik: reverse proxy with automatic HTTPS</title><link>https://serverkueche.de/en/tutorials/traefik-reverse-proxy/</link><pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/traefik-reverse-proxy/</guid><description>Traefik as a reverse proxy in front of your containers, with automatic Let's Encrypt certificates: every app gets a domain and HTTPS via a few labels.</description><content:encoded><![CDATA[<p>This is the most important building block of the Serverküche. A <strong>reverse proxy</strong>
takes in all requests on ports 80 and 443 and distributes them to the right
container based on the domain – and <strong>Traefik</strong> fetches the HTTPS certificates fully
automatically from Let&rsquo;s Encrypt. From here on, every further app gets its domain and
its TLS with a few lines of labels, without you ever touching a certificate by hand
again.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Traefik v3</strong> runs as the central entry point on your server. It listens
on ports 80/443, detects new containers automatically via Docker labels, redirects
HTTP to HTTPS automatically, and obtains a valid <strong>Let&rsquo;s Encrypt certificate</strong> for
every domain. As the first app, we hang <code>whoami</code> behind the proxy – a tiny test
service that shows routing and TLS are working. A secured dashboard comes on top.</p>
<p>The pattern from this tutorial – a shared <code>proxy</code> network plus a few labels – repeats
afterwards in <strong>every</strong> app recipe.</p>
<p>A request always passes through the same four stations in Traefik – this vocabulary
helps you debug:</p>
<ol>
<li><strong>Entrypoint</strong> – the port on which the request arrives (<code>web</code> = 80,
<code>websecure</code> = 443).</li>
<li><strong>Router</strong> – decides based on a <strong>rule</strong> (usually <code>Host(...)</code>) whether this request
belongs to an app.</li>
<li><strong>Middleware</strong> <em>(optional)</em> – modifies the request along the way (e.g.
HTTPS redirect, basic auth, security headers).</li>
<li><strong>Service</strong> – the container that ultimately responds.</li>
</ol>
<p>Debugging mnemonic: &ldquo;<strong>Entrypoint → Router → Middleware → Service</strong>&rdquo;. If a request
ends up nowhere, it&rsquo;s almost always the router (wrong domain) or the network (service
not reachable) at fault.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li><a href="/en/tutorials/install-docker/">Docker + Compose installed</a> and the
<a href="/en/tutorials/docker-compose-basics/">Compose basics</a> understood</li>
<li>A <a href="/en/tutorials/connect-domain-to-server/">domain connected to the server</a>:
<code>YOUR_DOMAIN</code> and the subdomains must resolve to the server via A/AAAA</li>
<li><strong>Ports 80 and 443 are reachable from the internet</strong> – Let&rsquo;s Encrypt uses them to
verify that you own the domain. Open firewalls accordingly (see <a href="/en/tutorials/firewall-ufw-setup/">setting up a
firewall with UFW</a>).</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>No resolving domain, no certificate
  </p>
  <div class="prose-kitchen text-sm">Let&rsquo;s Encrypt only issues certificates for domains it can reach. Check <strong>beforehand</strong>
with <code>dig +short YOUR_DOMAIN</code> that your server IP comes back. If the record still
points nowhere, certificate issuance fails – that&rsquo;s the most common Traefik error of
all.</div>
</div>
<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-shared-proxy-network">Step 1: Create the shared proxy network</h3>
<p>Traefik and all apps must share a Docker network so Traefik can reach the containers.
We create it <strong>once</strong> and explicitly, so later stacks can simply dock onto 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">docker network create proxy</span></span></code></pre></div>
</div>
<p>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 network ls <span class="p">|</span> grep proxy</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">c442da869c47   proxy     bridge    local</span></span></code></pre></div>
</div>
<p>This network is independent of the individual Compose projects – that&rsquo;s why we later
include it as <code>external</code>.</p>
<h3 id="step-2-create-the-traefik-project">Step 2: Create the Traefik project</h3>
<p>Create a dedicated folder for Traefik and, inside it, the file where the certificates
are stored:</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 ~/traefik <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/traefik
</span></span><span class="line"><span class="cl">touch acme.json
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> acme.json</span></span></code></pre></div>
</div>
<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>acme.json needs 600
  </p>
  <div class="prose-kitchen text-sm">Without <code>chmod 600 acme.json</code>, <strong>Traefik skips the Let&rsquo;s Encrypt resolver</strong>: the
container does start, but issues no valid certificate – you land on Traefik&rsquo;s
self-signed emergency certificate. The log then reads <code>permissions 644 for /acme.json are too open, please use 600</code>. The file contains your private keys – only
the owner may read it.</div>
</div>
<h3 id="step-3-the-traefik-composeyaml">Step 3: The Traefik compose.yaml</h3>
<p>Now the central configuration. It&rsquo;s long, but every line has a purpose – the
explanation follows right below:</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">traefik</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:v3.7</span><span class="w">
</span></span></span><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"># Dashboard (secured in step 7)</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"># Docker as source; only containers with traefik.enable=true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.docker=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.docker.exposedbydefault=false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--providers.docker.network=proxy&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Entrypoints: 80 (HTTP) and 443 (HTTPS)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.web.address=:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.websecure.address=:443&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Redirect everything from HTTP to HTTPS automatically</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.web.http.redirections.entrypoint.to=websecure&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--entrypoints.web.http.redirections.entrypoint.scheme=https&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Let&#39;s Encrypt resolver named &#34;le&#34; via HTTP challenge</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.email=YOUR_EMAIL&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.storage=/acme.json&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.httpchallenge=true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;--certificatesresolvers.le.acme.httpchallenge.entrypoint=web&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</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;80:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;443:443&#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">/var/run/docker.sock:/var/run/docker.sock:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./acme.json:/acme.json</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></span><span class="line"><span class="cl"><span class="w">      </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">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></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>
<p>The most important blocks:</p>
<ul>
<li><strong><code>providers.docker</code> + <code>exposedbydefault=false</code></strong>: Traefik watches the Docker
socket, but only containers that explicitly carry <code>traefik.enable=true</code>. No service
is accidentally made public.</li>
<li><strong><code>providers.docker.network=proxy</code></strong>: tells Traefik which network it uses to reach
the containers – important when containers are attached to several networks.</li>
<li><strong><code>entrypoints web/websecure</code></strong>: ports 80 and 443. The two <code>redirections</code> lines send
every HTTP call automatically to HTTPS.</li>
<li><strong><code>certificatesresolvers.le</code></strong>: the Let&rsquo;s Encrypt resolver. Via the <strong>HTTP
challenge</strong>, Traefik proves to Let&rsquo;s Encrypt that the domain points to this server
and stores the certificate in <code>acme.json</code>. For this, <strong>port 80 must stay reachable
from outside</strong> – even if your app only runs over HTTPS, because the challenge comes
over HTTP. Let&rsquo;s Encrypt uses the <code>acme.email</code> solely for warnings about expiring
certificates; enter a real address.</li>
<li>The <strong>Docker socket</strong> is mounted read-only (<code>:ro</code>) – Traefik must read it, but not
write to it.</li>
</ul>
<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>Test with the staging server first
  </p>
  <div class="prose-kitchen text-sm">Let&rsquo;s Encrypt has strict <strong>rate limits</strong> for production certificates. While you&rsquo;re
still building, add
<code>--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory</code>
as a test. This delivers test certificates (shown as insecure in the browser) without
a limit. Once everything works, remove the line, <strong>empty <code>acme.json</code></strong> (<code>&gt; acme.json</code>)
and restart Traefik – then the real certificate comes.</div>
</div>
<p>Start Traefik:</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 up -d
</span></span><span class="line"><span class="cl">docker compose logs -f traefik</span></span></code></pre></div>
</div>
<p>The logs must show <strong>no</strong> <code>ERR</code> about ACME or the provider. <code>Ctrl+C</code> only ends the
following, not the container.</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>An empty log is a good sign
  </p>
  <div class="prose-kitchen text-sm">Traefik v3 writes <strong>only errors</strong> at the default log level. So an empty log output
means: everything is running. If you want to see more during setup (every detected
router, every ACME request), add <code>--log.level=INFO</code> to the <code>command</code> block and
restart.</div>
</div>
<h3 id="step-4-the-first-app-behind-traefik-whoami">Step 4: The first app behind Traefik (whoami)</h3>
<p><code>whoami</code> is a tiny service that returns the received request – perfect for testing. Own
folder, own <code>compose.yaml</code>:</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 ~/whoami <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/whoami</span></span></code></pre></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="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.11</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="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="l">proxy</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></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>
<p>These are the four labels you&rsquo;ll need again and again from now on:</p>
<ul>
<li><strong><code>traefik.enable=true</code></strong> – only then does Traefik touch the container.</li>
<li><strong><code>...routers.whoami.rule=Host(...)</code></strong> – at which domain this container responds.
<code>whoami</code> is a freely chosen router name (unique per container).</li>
<li><strong><code>...entrypoints=websecure</code></strong> – reachable over HTTPS (443).</li>
<li><strong><code>...tls.certresolver=le</code></strong> – fetch the certificate via the resolver <code>le</code> defined in
step 3.</li>
</ul>
<p>Important: the service has <strong>no <code>ports:</code></strong> – it&rsquo;s only reachable via Traefik, not
directly from outside. And it&rsquo;s on the <strong><code>proxy</code> network</strong>, otherwise Traefik won&rsquo;t
find it.</p>
<p>Create the DNS record <code>whoami.YOUR_DOMAIN</code> beforehand (A/AAAA to the server IP, <a href="/en/tutorials/connect-domain-to-server/">as in
the DNS tutorial</a>), then:</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 up -d</span></span></code></pre></div>
</div>
<p>Open <code>https://whoami.YOUR_DOMAIN</code> in the browser. On the first call, certificate
issuance takes a few seconds; after that you see a valid padlock icon and a text
output like:</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">Hostname: bfa4b8dee3d0
</span></span><span class="line"><span class="cl">IP: 127.0.0.1
</span></span><span class="line"><span class="cl">IP: 172.19.0.3
</span></span><span class="line"><span class="cl">RemoteAddr: 172.19.0.2:49734
</span></span><span class="line"><span class="cl">GET / HTTP/1.1
</span></span><span class="line"><span class="cl">Host: whoami.YOUR_DOMAIN</span></span></code></pre></div>
</div>
<p>The <code>Host:</code> line confirms that Traefik routed correctly to this container based on the
domain. Exactly this behavior – a request for <code>whoami.YOUR_DOMAIN</code> lands at the
whoami container, an unknown domain gets a <strong>404</strong> – is the heart of the reverse proxy.</p>
<p><strong>Check which CA issued the certificate.</strong> This separates &ldquo;HTTPS is running&rdquo; from &ldquo;I
only see Traefik&rsquo;s emergency certificate&rdquo;:</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"><span class="nb">echo</span> <span class="p">|</span> openssl s_client -connect whoami.YOUR_DOMAIN:443 -servername whoami.YOUR_DOMAIN 2&gt;/dev/null <span class="p">|</span> openssl x509 -noout -issuer</span></span></code></pre></div>
</div>
<p>As long as you use the <strong>staging</strong> server (as recommended in step 3), a test issuer
appears there – the browser still shows the certificate as insecure:</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">issuer=C=US, O=Let&#39;s Encrypt, CN=(STAGING) Ersatz Emmer YR2</span></span></code></pre></div>
</div>
<p>If <code>TRAEFIK DEFAULT CERT</code> appears here, the resolver fetched no certificate – then go
to troubleshooting below. If a Let&rsquo;s Encrypt issuer is there, the whole chain works.</p>
<h3 id="step-5-switch-to-the-real-certificate">Step 5: Switch to the real certificate</h3>
<p>Once staging runs cleanly, you fetch the real certificate that&rsquo;s valid in the browser.
Remove the <code>caserver</code> line from the <code>traefik</code> service (step 3), <strong>empty the staging
certificates</strong> and restart Traefik:</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">&gt; acme.json                 <span class="c1"># discards the staging certificates (chmod 600 stays)</span>
</span></span><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div>
</div>
<p>On the next call, Traefik fetches a fresh production certificate. In the <code>issuer</code> line
from above, the <code>(STAGING)</code> then disappears, and the browser shows a valid padlock.</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>Staging first, then production
  </p>
  <div class="prose-kitchen text-sm">Let&rsquo;s Encrypt has hard <strong>rate limits</strong> on production certificates (a few per domain per
week). Only switch to production once routing and challenge demonstrably work with
staging – otherwise you lock yourself out of the domain for hours.</div>
</div>
<h3 id="step-6-the-http-to-https-redirect">Step 6: The HTTP-to-HTTPS redirect</h3>
<p>You already enabled this globally in step 3 (the two <code>redirections</code> lines). Test:</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 http://whoami.YOUR_DOMAIN <span class="p">|</span> grep -iE <span class="s1">&#39;HTTP/|location&#39;</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">HTTP/1.1 308 Permanent Redirect
</span></span><span class="line"><span class="cl">location: https://whoami.YOUR_DOMAIN/</span></span></code></pre></div>
</div>
<p>So every unencrypted call is automatically redirected to HTTPS – you no longer have to
think about it in any app.</p>
<h3 id="step-7-secure-the-dashboard">Step 7: Secure the dashboard</h3>
<p>Traefik comes with a dashboard that shows which routers and services are active. Never
put it <strong>unprotected</strong> on the internet. We secure it with basic auth and hang it on a
dedicated subdomain. First create a user:</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">sudo apt install -y apache2-utils
</span></span><span class="line"><span class="cl">htpasswd -nbB admin YOUR_PASSWORD</span></span></code></pre></div>
</div>
<p>The output (<code>admin:$2y$05$...</code>) goes into the labels. <strong>In the <code>compose.yaml</code>, double
every <code>$</code></strong> (<code>$$</code>), otherwise Compose interprets it as a variable. Add to the
<code>traefik</code> 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="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$$...&#34;</span></span></span></code></pre></div>
</div>
<p>After <code>docker compose up -d</code> you reach the dashboard at <code>https://traefik.YOUR_DOMAIN</code>
– after a password prompt.</p>
<p>In the dashboard you see, under <strong>HTTP → Routers</strong>, every detected router (with its
<code>Host(...)</code> rule), under <strong>Services</strong> the containers behind them, and under
<strong>Middlewares</strong> your building blocks like <code>dashboard-auth</code>. A router turns <strong>green</strong>
when rule, service and – for <code>websecure</code> – the certificate are correct; <strong>red</strong> means
something is missing (usually network or host rule). That makes the dashboard your
first look at &ldquo;why isn&rsquo;t my app responding?&rdquo;.</p>
<p><figure class="my-6"><img src="/en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_5849a1b2dfad3fb8.webp" srcset="/en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_9cab1a77d2d2e383.webp 480w, /en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_5849a1b2dfad3fb8.webp 768w, /en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_477dc2b839a1268b.webp 1200w, /en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_6912160ac6e2d97f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/traefik-reverse-proxy/traefik-dashboard_hu_5dfd90f74c454165.webp"
    alt="The Traefik dashboard: entrypoints (web/websecure), detected HTTP routers and services – all green" title="The Traefik dashboard at a glance"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The Traefik dashboard at a glance</figcaption></figure></p>
<p>Under <strong>HTTP Routers</strong> you see each router individually – with its <code>Host(...)</code> rule,
the entrypoint, the TLS status (padlock) and the provider <code>docker</code>. This is how you
check at a glance whether your labels were detected correctly:</p>
<p><figure class="my-6"><img src="/en/tutorials/traefik-reverse-proxy/traefik-routers_hu_2424c2a45482f193.webp" srcset="/en/tutorials/traefik-reverse-proxy/traefik-routers_hu_6a794561852d450.webp 480w, /en/tutorials/traefik-reverse-proxy/traefik-routers_hu_2424c2a45482f193.webp 768w, /en/tutorials/traefik-reverse-proxy/traefik-routers_hu_cbd2f006d49919b9.webp 1200w, /en/tutorials/traefik-reverse-proxy/traefik-routers_hu_1ef1bcad6b9c811.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/traefik-reverse-proxy/traefik-routers_hu_37c445c54306ba89.webp"
    alt="The router list in the Traefik dashboard: per app the host rule, the entrypoint (websecure), TLS and the Docker provider" title="HTTP routers with host rules, entrypoint and provider"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">HTTP routers with host rules, entrypoint and provider</figcaption></figure></p>
<h3 id="step-8-security-headers-as-a-reusable-middleware">Step 8: Security headers as a reusable middleware</h3>
<p>A <strong>middleware</strong> hooks in between router and service and modifies the request or
response. A set of security headers belongs on every public app – defined once,
attached everywhere. Define the middleware on any container (common: on Traefik
itself) via labels:</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.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.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.browserXssFilter=true&#34;</span></span></span></code></pre></div>
</div>
<p>What the most important ones do:</p>
<ul>
<li><strong><code>stsSeconds</code> (HSTS)</strong> – the browser will address the domain only over HTTPS from
now on. One year (<code>31536000</code>) is the usual value.</li>
<li><strong><code>frameDeny</code></strong> – forbids embedding in foreign <code>&lt;iframe&gt;</code>s (clickjacking protection).</li>
<li><strong><code>contentTypeNosniff</code></strong> – the browser doesn&rsquo;t guess the content type but takes the
one delivered – rules out a whole class of attacks.</li>
</ul>
<p>Attach it to an app via a label (adjust the router name):</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&#34;</span></span></span></code></pre></div>
</div>
<p>Multiple middlewares are given comma-separated (<code>sec-headers,dashboard-auth</code>) and are
run <strong>in that order</strong>. This is how you gradually build a toolbox (auth, rate limiting,
IP whitelist) that every app can reuse.</p>
<p>If a header set should apply <strong>to all</strong> apps, you don&rsquo;t attach the middleware to each
router individually, but globally to the entrypoint – one line in Traefik&rsquo;s <code>command</code>
block:</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;--entrypoints.websecure.http.middlewares=sec-headers@docker&#34;</span></span></span></code></pre></div>
</div>
<p>The suffix <code>@docker</code> tells Traefik the middleware comes from the Docker provider (where
you defined it via label).</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">Only arm HSTS with <code>stsSeconds</code> once HTTPS runs <strong>reliably</strong> and permanently. The
browser remembers the setting stubbornly – a broken certificate would then be hard to
work around for the full duration.</div>
</div>
<h3 id="step-9-the-recipe-for-every-further-app">Step 9: The recipe for every further app</h3>
<p>From now on, every app is the same pattern – you never have to touch Traefik again. A
new application gets its own folder with a <code>compose.yaml</code>, is on the <code>proxy</code> network,
and carries exactly these labels (adjust router name and domain; for a port ≠ 80 add
the <code>loadbalancer</code> label as well):</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">myapp</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">YOUR_IMAGE:TAG</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.myapp.rule=Host(`app.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.myapp.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.myapp.tls.certresolver=le&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># only needed if the app does NOT listen on port 80:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;traefik.http.services.myapp.loadbalancer.server.port=YOUR_PORT&#34;</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></span><span class="line"><span class="cl"><span class="w">      </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">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></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>
<p><code>docker compose up -d</code>, set the DNS record to the server IP, done – domain and HTTPS
are created automatically. This is exactly how
<a href="/en/tutorials/uptime-kuma-monitoring/">the first real app (Uptime Kuma)</a> hangs behind
the proxy.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>&ldquo;Certificate invalid&rdquo; in the browser, or the Traefik log shows ACME errors.</strong> The
three usual reasons: (1) The DNS record doesn&rsquo;t point to the server yet – check <code>dig +short YOUR_DOMAIN</code>. (2) Port 80 isn&rsquo;t reachable from outside (firewall/netcup
firewall) – the HTTP challenge needs it. (3) You hit the <strong>rate limit</strong> of the
production CA – switch to the staging server (tip in step 3), test, then go back.</p>
<p><strong><code>404 page not found</code> when calling the app domain.</strong> Traefik doesn&rsquo;t know the route.
Check: Does the container have <code>traefik.enable=true</code>? Is it on the <strong><code>proxy</code> network</strong>?
Is the domain in the <code>Host(...)</code> rule exactly right (incl. subdomain)? The dashboard
(step 7) shows under &ldquo;HTTP Routers&rdquo; whether the router was registered.</p>
<p><strong>The browser shows Traefik&rsquo;s self-signed emergency certificate; the log reads
<code>permissions 644 for /acme.json are too open, please use 600</code>.</strong> Traefik is running but
skipped the ACME resolver – hence no real certificate. Run <code>chmod 600 acme.json</code> (step
2) and restart the container.</p>
<p><strong>No app is routed; the Traefik log repeats <code>client version 1.24 is too old. Minimum supported API version is 1.40</code>.</strong> Your Traefik version is too old for your Docker
engine – the Docker provider can no longer query the socket. Current Docker (Engine 29,
API level ≥ 1.40) needs <strong>Traefik ≥ v3.5</strong>; that&rsquo;s why this tutorial uses
<code>traefik:v3.7</code>. Older tags like <code>v3.3</code> no longer work with new Docker – bump the image
tag and run <code>docker compose up -d</code> again.</p>
<p><strong>Basic auth on the dashboard is rejected immediately / the router is missing.</strong> In the
<code>compose.yaml</code>, the <code>$</code> characters of the hash must be <strong>doubled</strong> (<code>$$</code>). Check the
hash once more outside with <code>htpasswd -nbB</code>.</p>
<p><strong><code>Gateway Timeout</code> or Traefik doesn&rsquo;t reach the container.</strong> Usually the app is on the
wrong network, or Traefik doesn&rsquo;t know which one is meant.
<code>providers.docker.network=proxy</code> in Traefik <strong>and</strong> <code>networks: [proxy]</code> on the app must
match.</p>
<p><strong><code>502 Bad Gateway</code>, even though the container is running.</strong> Traefik reaches the
container but hits the wrong port. If the app doesn&rsquo;t listen on 80, it needs the label
<code>traefik.http.services.&lt;name&gt;.loadbalancer.server.port=&lt;real-port&gt;</code>. This exact case
meets you with the first app in the next tutorial (Uptime Kuma on 3001).</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<ul>
<li><strong>You must back up <code>acme.json</code> and all <code>compose.yaml</code> files.</strong> With those, Traefik is
restored within minutes after a crash – the certificates don&rsquo;t need to be reissued
(which also spares the rate limit). We build an encrypted off-site backup of these
files in the <a href="/en/tutorials/restic-backups/">Restic tutorial</a>.</li>
<li><strong>Certificates renew automatically.</strong> Let&rsquo;s Encrypt certificates expire after 90
days; Traefik renews them in good time on its own – no cron job needed. You can check
the expiry date any time by appending <code>-dates</code> instead of <code>-issuer</code> to the <code>openssl</code>
command from step 4 (shows <code>notBefore</code>/<code>notAfter</code>).</li>
<li><strong>Maintain the Traefik version.</strong> The fixed tag (<code>traefik:v3.7</code>) means you apply
updates deliberately. Before jumping to a new minor/major version, read the release
notes – Traefik changed the label syntax between v2 and v3, for example.</li>
<li><strong>Keep an eye on the dashboard.</strong> A quick login shows whether all routers are &ldquo;green&rdquo;
– the fastest check of whether everything holds after a deploy.</li>
</ul>
<p>From now on the path is the same for every app: container onto the <code>proxy</code> network,
four labels on it, set the DNS record – and a publicly reachable service with HTTPS is
ready. As the <strong>first real app</strong>, in the next recipe we hang <strong>Uptime Kuma</strong> behind
Traefik and use it to monitor all subsequent services.</p>
]]></content:encoded></item></channel></rss>