<?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>Privacy – Serverküche</title><link>https://serverkueche.de/en/tags/privacy/</link><description>Privacy – 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>Mon, 17 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://serverkueche.de/en/tags/privacy/index.xml" rel="self" type="application/rss+xml"/><item><title>AdGuard Home: network-wide ad and tracking blocker</title><link>https://serverkueche.de/en/tutorials/adguard-home-ad-blocker/</link><pubDate>Mon, 17 Aug 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/adguard-home-ad-blocker/</guid><description>Set up AdGuard Home with Docker: your own DNS server that blocks ads, trackers and malicious sites for all devices – securely, only via the VPN.</description><content:encoded><![CDATA[<p>Ads and trackers are blocked most effectively not in the browser, but <strong>in DNS</strong> – there, where every device asks which IP belongs to a domain. <strong>AdGuard Home</strong> is a self-hosted DNS server that simply lets requests to known ad, tracking and malicious domains run into the void. One server, all devices – phone, TV, laptop – without a single app on the client.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>AdGuard Home v0.107.78</strong> runs as a DNS server on your VPS. It answers DNS requests, filters out ads/trackers/malware based on block lists, and shows you live in a web interface what was blocked. Concretely:</p>
<ul>
<li>a <strong>DNS filter</strong> with over 150,000 rules (default block list), extensible with any lists,</li>
<li>a <strong>web interface</strong> with statistics and a query log, secured behind your reverse proxy,</li>
<li>the DNS service reachable <strong>exclusively via your WireGuard tunnel</strong> – not open on the internet.</li>
</ul>
<p>The last point is decisive and the reason this tutorial requires WireGuard: an <strong>open DNS resolver</strong> on the internet is abused within hours for <strong>DNS amplification attacks</strong>. So we bind the DNS port to the VPN interface, and only your own devices talk to it via the tunnel.</p>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server with <strong><a href="/en/tutorials/install-docker/">Docker</a></strong> and a running <strong><a href="/en/tutorials/traefik-reverse-proxy/">Traefik reverse proxy</a></strong> (for the web interface under its own domain with TLS).</li>
<li>A set-up <strong><a href="/en/tutorials/wireguard-vpn-setup/">WireGuard VPN</a></strong> with the server interface <code>wg0</code> at <code>10.8.0.1</code>. Your devices later reach the DNS server through it.</li>
<li>Basic understanding of DNS (domain → IP) helps but isn&rsquo;t a must.</li>
</ul>
<p>AdGuard Home is extremely frugal – it gets by with a few tens of MB of RAM. The smallest plan is plenty, especially since it probably runs alongside other services at your place anyway.</p>
<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/adguard-home-ad-blocker/" data-content-piece="VPS 1000 G12">
  <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</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">More than sufficient for AdGuard Home.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12 · {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"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 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">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span><code data-track-voucher="36nc17844976032"
        class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-is-port-53-free">Step 1: Is port 53 free?</h3>
<p>AdGuard Home needs <strong>port 53</strong> – the DNS port. On some systems <code>systemd-resolved</code> already occupies it. Check that:</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">ss -tulpn <span class="p">|</span> grep <span class="s2">&#34;:53 &#34;</span></span></span></code></pre></div>
</div>
<p>On the netcup Debian 13 image used here, the port was <strong>free</strong> (no systemd-resolved active), so the output was empty. If you instead get a line with <code>systemd-resolve</code>, you have to disable its stub listener – otherwise AdGuard won&rsquo;t start (&ldquo;address already in use&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">sudo mkdir -p /etc/systemd/resolved.conf.d
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> -e <span class="s2">&#34;[Resolve]\nDNSStubListener=no&#34;</span> <span class="p">|</span> sudo tee /etc/systemd/resolved.conf.d/adguard.conf
</span></span><span class="line"><span class="cl">sudo systemctl restart systemd-resolved</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>Don&#39;t forget resolv.conf
  </p>
  <div class="prose-kitchen text-sm">If you disable the stub listener, <code>/etc/resolv.conf</code> may still point to <code>127.0.0.53</code> and the server itself can no longer resolve names. In that case, set a working resolver, e.g. <code>nameserver 9.9.9.9</code> (or your provider&rsquo;s), so <code>apt</code> &amp; co. keep working.</div>
</div>
<h3 id="step-2-create-the-docker-compose">Step 2: Create the Docker Compose</h3>
<p>Create the project folder:</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/adguardhome/work /opt/adguardhome/conf <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/adguardhome</span></span></code></pre></div>
</div>
<p>Create the <code>compose.yaml</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">adguardhome</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">adguard/adguardhome:v0.107.78</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">adguardhome</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">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">./work:/opt/adguardhome/work</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./conf:/opt/adguardhome/conf</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;10.8.0.1:53:53/tcp&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;10.8.0.1:53:53/udp&#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">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.adguard.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.adguard.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.adguard.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.adguard.loadbalancer.server.port=3000&#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>
<p>The most important things:</p>
<ul>
<li><strong><code>ports: &quot;10.8.0.1:53:53&quot;</code></strong> – the DNS port is bound <strong>only to the WireGuard address</strong>, not to <code>0.0.0.0</code>. This makes the resolver reachable exclusively via the VPN tunnel and <strong>never open on the internet</strong>. This one line is the difference between &ldquo;secure&rdquo; and &ldquo;abused for attacks&rdquo;.</li>
<li>The <strong>web interface</strong> runs on container port <strong>3000</strong> and is published under <code>YOUR_DOMAIN</code> with TLS via the four Traefik labels. Replace <code>YOUR_DOMAIN</code> with your real domain.</li>
<li>Both <code>volumes</code> (<code>work</code>, <code>conf</code>) make the configuration and statistics persistent.</li>
</ul>
<p>Start the container:</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>Check that it&rsquo;s running:</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>
<p>You should see <code>Up</code> and that port 53 is bound to <code>10.8.0.1</code>.</p>
<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>Never port 53 open to the internet
  </p>
  <div class="prose-kitchen text-sm"><strong>Never</strong> write <code>- &quot;53:53/udp&quot;</code> (that binds to all interfaces) on a public server. An open resolver is found automatically and abused for amplification DDoS against third parties – your provider then rightly suspends you. Always bind to a private interface (<code>10.8.0.1</code>).</div>
</div>
<h3 id="step-3-the-initial-setup">Step 3: The initial setup</h3>
<p>Open your domain in the browser – you land in the <strong>setup wizard</strong>. At the bottom right you can set the language.</p>
<p><figure class="my-6"><img src="/en/tutorials/adguard-home-ad-blocker/adguard-ersteinrichtung_hu_c311aab651123880.webp" srcset="/en/tutorials/adguard-home-ad-blocker/adguard-ersteinrichtung_hu_b9d344ef6275f05.webp 480w, /en/tutorials/adguard-home-ad-blocker/adguard-ersteinrichtung_hu_c311aab651123880.webp 768w, /en/tutorials/adguard-home-ad-blocker/adguard-ersteinrichtung_hu_7e27318e4165fd3f.webp 1200w, /en/tutorials/adguard-home-ad-blocker/adguard-ersteinrichtung_hu_4ac10ab9d2a0eb86.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/adguard-home-ad-blocker/adguard-ersteinrichtung_hu_8ac30040b70ac554.webp"
    alt="The welcome screen of the AdGuard Home setup wizard." title="The setup wizard greets you – set the language at the bottom right."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The setup wizard greets you – set the language at the bottom right.</figcaption></figure></p>
<p>Click through the five steps. Two settings are important:</p>
<ul>
<li><strong>Admin web interface:</strong> as the port you must enter <strong>3000</strong> (All interfaces, port <code>3000</code>) – because that&rsquo;s exactly where Traefik forwards. The suggestion is often port 80; if you don&rsquo;t change it, the interface is no longer reachable via Traefik after the setup.</li>
<li><strong>DNS server:</strong> port <strong>53</strong> (All interfaces). The restriction to the VPN interface is already handled by the port binding from the Compose – inside the container, AdGuard may listen on all interfaces.</li>
</ul>
<p>Then you create a <strong>username and a strong password</strong> for the login. Done – you&rsquo;re redirected to the login.</p>
<h3 id="step-4-the-dashboard">Step 4: The dashboard</h3>
<p>After logging in you see the <strong>overview</strong>: number of requests, how many of them were blocked, the most frequently requested and the most frequently blocked domains.</p>
<p><figure class="my-6"><img src="/en/tutorials/adguard-home-ad-blocker/adguard-dashboard_hu_e6dce879e2886fe9.webp" srcset="/en/tutorials/adguard-home-ad-blocker/adguard-dashboard_hu_3e7b6bff6fbe3d76.webp 480w, /en/tutorials/adguard-home-ad-blocker/adguard-dashboard_hu_e6dce879e2886fe9.webp 768w, /en/tutorials/adguard-home-ad-blocker/adguard-dashboard_hu_50acf7e88d89a3df.webp 1200w, /en/tutorials/adguard-home-ad-blocker/adguard-dashboard_hu_ea241b771fe2457e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/adguard-home-ad-blocker/adguard-dashboard_hu_66db0ed993db9114.webp"
    alt="The AdGuard Home dashboard with request statistics and top domains." title="The dashboard shows live how many requests arrive and how many are blocked."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard shows live how many requests arrive and how many are blocked.</figcaption></figure></p>
<p>At first everything is at zero – it fills up as soon as the first device uses AdGuard as its DNS (step 6).</p>
<h3 id="step-5-manage-block-lists">Step 5: Manage block lists</h3>
<p>Under <strong>Filters → DNS blocklists</strong> you see the active block lists. By default the <strong>AdGuard DNS filter</strong> is active – for us with <strong>159,173 rules</strong>.</p>
<p><figure class="my-6"><img src="/en/tutorials/adguard-home-ad-blocker/adguard-filterlisten_hu_90382eb83dafae56.webp" srcset="/en/tutorials/adguard-home-ad-blocker/adguard-filterlisten_hu_5787b59e5e3e0f7c.webp 480w, /en/tutorials/adguard-home-ad-blocker/adguard-filterlisten_hu_90382eb83dafae56.webp 768w, /en/tutorials/adguard-home-ad-blocker/adguard-filterlisten_hu_770f40222343234.webp 1200w, /en/tutorials/adguard-home-ad-blocker/adguard-filterlisten_hu_bf02ff6c1ff9313f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/adguard-home-ad-blocker/adguard-filterlisten_hu_38ec8a520cd49363.webp"
    alt="The management of DNS block lists in AdGuard Home." title="The default block list is active; you enable or add more lists here."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The default block list is active; you enable or add more lists here.</figcaption></figure></p>
<p>Via <strong>Add blocklist</strong> you can include more curated lists (AdGuard brings a catalog, e.g. AdAway or OISD). Less is often more: one or two good lists block most, without legitimate sites constantly breaking. With <strong>Check for updates</strong> you update the rules manually; AdGuard otherwise keeps them current automatically (see &ldquo;Maintenance&rdquo;).</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>Don&#39;t overdo it
  </p>
  <div class="prose-kitchen text-sm">It&rsquo;s tempting to stack ten aggressive lists. But that leads to <strong>overblocking</strong>: login pages, images or shopping carts suddenly don&rsquo;t work anymore, and troubleshooting is tedious. Start with the default list and add specifically.</div>
</div>
<h3 id="step-6-switch-devices-to-adguard">Step 6: Switch devices to AdGuard</h3>
<p>For your devices to use the filter, they have to use AdGuard as their DNS server. Via the WireGuard tunnel this works most elegantly <strong>centrally in the client configuration</strong>: enter the server&rsquo;s tunnel address as DNS in the <code>[Interface]</code> section of your WireGuard clients:</p>
<div class="sk-code">
  <span class="sk-code-head">INI</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Interface]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ... your existing lines ...</span>
</span></span><span class="line"><span class="cl"><span class="na">DNS</span> <span class="o">=</span> <span class="s">10.8.0.1</span></span></span></code></pre></div>
</div>
<p>As soon as the tunnel is up, the device sends all DNS requests to AdGuard – whether on Wi-Fi on the go or on the mobile network. No intervention at the router needed, and the protection works on the go too.</p>
<h3 id="step-7-does-it-really-work">Step 7: Does it really work?</h3>
<p>Check the resolver directly on the server via the VPN address. A normal domain must resolve:</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">dig @10.8.0.1 +short example.com</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">172.66.147.243</span></span></code></pre></div>
</div>
<p>A known tracking domain must be <strong>blocked</strong> – AdGuard then answers with <code>0.0.0.0</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">dig @10.8.0.1 +short doubleclick.net</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">0.0.0.0</span></span></code></pre></div>
</div>
<p>As a counter-check that the resolver does <strong>not</strong> listen openly, you query it via the loopback address – that must fail:</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">dig @127.0.0.1 +time<span class="o">=</span><span class="m">2</span> +tries<span class="o">=</span><span class="m">1</span> example.com</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">;; communications error to 127.0.0.1#53: connection refused</span></span></code></pre></div>
</div>
<p>Exactly as it should be: reachable via <code>10.8.0.1</code> (the tunnel), rejected on all other addresses. A look at the <strong>query log</strong> in the web interface shows the same requests live – blocked entries are marked red and name the list that took effect:</p>
<p><figure class="my-6"><img src="/en/tutorials/adguard-home-ad-blocker/adguard-abfrageprotokoll_hu_eb5c1b3656056f41.webp" srcset="/en/tutorials/adguard-home-ad-blocker/adguard-abfrageprotokoll_hu_83b4bfc3848bbc92.webp 480w, /en/tutorials/adguard-home-ad-blocker/adguard-abfrageprotokoll_hu_eb5c1b3656056f41.webp 768w, /en/tutorials/adguard-home-ad-blocker/adguard-abfrageprotokoll_hu_3a2604c938b303fa.webp 1200w, /en/tutorials/adguard-home-ad-blocker/adguard-abfrageprotokoll_hu_3be87d28e48aa39e.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/adguard-home-ad-blocker/adguard-abfrageprotokoll_hu_b89c4e8e72981ac2.webp"
    alt="The query log of AdGuard Home with allowed and blocked requests." title="The query log: blocked domains (red) with the list that blocked them."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The query log: blocked domains (red) with the list that blocked them.</figcaption></figure></p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The container won&rsquo;t start, the log says &ldquo;listen udp :53: bind: address already in use&rdquo;.</strong>
<code>systemd-resolved</code> (or another DNS service) occupies port 53. disable the stub listener as in step
1, then <code>docker compose up -d</code> again.</p>
<p><strong><code>docker compose up</code> fails with &ldquo;cannot assign requested address&rdquo; for <code>10.8.0.1</code>.</strong> the WireGuard
interface <code>wg0</code> with <code>10.8.0.1</code> doesn&rsquo;t exist (yet) – Docker can&rsquo;t bind the port to a non-existent
address. first set up the <a href="/en/tutorials/wireguard-vpn-setup/">WireGuard VPN</a> and bring <code>wg0</code> up
(<code>ip -br addr show wg0</code> must show <code>10.8.0.1</code>), then start the container.</p>
<p><strong>After the setup, the web interface is no longer reachable.</strong> in the wizard, the admin port was set
to 80 (the suggestion) instead of <strong>3000</strong> – but Traefik forwards to 3000. in
<code>./conf/AdGuardHome.yaml</code> under <code>http:</code> correct the <code>address</code> to <code>0.0.0.0:3000</code> and <code>docker compose restart</code>.</p>
<p><strong>DNS doesn&rsquo;t filter, even though the device is connected.</strong> the browser uses <strong>DNS-over-HTTPS
(DoH)</strong> and thus bypasses your server completely – Firefox and Chrome have this active by default in
part. disable &ldquo;Secure DNS&rdquo; / &ldquo;DNS over HTTPS&rdquo; in the browser settings. You can check with <code>dig @10.8.0.1 …</code> (always takes effect).</p>
<p><strong>A website is suddenly broken (empty pages, missing images, no login).</strong> overblocking – a block
list blocks a domain the site really needs. find the blocked domain in the query log, <strong>allow</strong> it
via right-click/menu (exception) or disable the overly aggressive list.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Block-list updates.</strong> AdGuard updates the lists automatically (default: every 24 hours). You don&rsquo;t have to do anything here; a manual <strong>Check for updates</strong> is possible any time if needed.</p>
<p><strong>Backups.</strong> The entire configuration is in <strong><code>/opt/adguardhome/conf/AdGuardHome.yaml</code></strong> – admin login, DNS settings, lists, exceptions. You back up this file (plus the <code>work</code> folder with the statistics) regularly, e.g. via <a href="/en/tutorials/restic-backups/">Restic</a>. This way a server migration is done in minutes: bring up the container, restore <code>conf</code>, done.</p>
<p><strong>Updates.</strong> You fetch new versions like with any Compose stack. Pin the tag deliberately (not <code>latest</code>) so updates happen in a controlled way:</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">cd</span> /opt/adguardhome
</span></span><span class="line"><span class="cl">docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p><strong>Privacy.</strong> The query log stores which device requested which domain – that&rsquo;s gold for troubleshooting, but also sensitive. Under <strong>Settings → General settings</strong> you can shorten the <strong>retention period</strong> of the log or disable logging entirely. Especially when other people share your DNS, a short retention is fair.</p>
]]></content:encoded></item><item><title>Self-hosting Matomo cookieless: analytics without a consent banner</title><link>https://serverkueche.de/en/tutorials/self-host-matomo/</link><pubDate>Tue, 04 Aug 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-matomo/</guid><description>Set up Matomo 5.12 with Docker &amp; Traefik: privacy-friendly web analytics with IP anonymization, entirely without Google and without a cookie banner.</description><content:encoded><![CDATA[<p>Google Analytics knows every one of your visitors better than you do yourself – and sends the data to the US. With Matomo you bring web analytics back onto your own server: GDPR-compliant, IP-anonymized and, in many cases, entirely without a cookie banner.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>Matomo 5.12</strong> runs in Docker behind your Traefik reverse proxy, reachable under your own (sub)domain with a valid HTTPS certificate. As the database, <strong>MariaDB 11.4</strong> comes along – both in one <code>compose.yaml</code>. You get a full-featured analytics dashboard that evaluates visitor numbers, origin, devices and popular pages – the data lives exclusively on your server.</p>
<p>The real gain over Google Analytics is privacy: we take control of IP anonymization and configure Matomo so it works without cookies. That&rsquo;s the technical basis for being able, in many cases, to do without an annoying consent banner. Matomo is the full package – if all you want to know is which pages get how many hits, the much leaner <a href="/en/tutorials/self-host-hitkeep-analytics/">HitKeep</a> will do as well.</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>Note
  </p>
  <div class="prose-kitchen text-sm">This tutorial is no substitute for legal advice. Whether you specifically need a cookie banner depends on your use. But the configuration shown here (no cookies, IP anonymization, an opt-out option) is exactly the one Matomo and many data protection authorities name as the basis for consent-free operation.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A <a href="/en/tutorials/harden-ssh/">hardened server</a> with Debian 13 and a sudo user</li>
<li><a href="/en/tutorials/install-docker/">Docker &amp; Docker Compose installed</a></li>
<li>A <a href="/en/tutorials/traefik-reverse-proxy/">running Traefik reverse proxy</a> with the external network <code>proxy</code> and the certificate resolver <code>le</code> – Matomo hooks in there</li>
<li>A (sub)domain whose A/AAAA record <a href="/en/tutorials/connect-domain-to-server/">points to your server</a>, e.g. <code>stats.YOUR_DOMAIN</code></li>
<li>A <a href="/en/tutorials/restic-backups/">working backup strategy with Restic</a> – analytics data accumulates over years, you don&rsquo;t want to lose it</li>
</ul>
<p>Matomo is frugal: for a small to medium website, the VPS 1000 is plenty. Only with very high traffic or when many other services run alongside is it worth looking at more RAM – our <a href="/en/server-calculator/">server calculator</a> helps with the assessment.</p>
<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/self-host-matomo/" data-content-piece="VPS 1000 G12">
  <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</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">Enough for Matomo alongside a few other small services.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12 · {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"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 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">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span><code data-track-voucher="36nc17844976032"
        class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-create-the-project-folder-and-check-the-network">Step 1: Create the project folder and check the network</h3>
<p>We create a dedicated folder for Matomo under <code>/opt</code> – this keeps every service cleanly separated:</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/matomo <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/matomo</span></span></code></pre></div>
</div>
<p>Matomo has to go into the same Docker network as Traefik so the proxy can reach the container. Check that the <code>proxy</code> network from the <a href="/en/tutorials/traefik-reverse-proxy/">Traefik tutorial</a> exists:</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>
<p>You should see a line with <code>proxy</code>. If not, set up Traefik first – without the proxy, Matomo won&rsquo;t get a certificate.</p>
<h3 id="step-2-move-passwords-into-a-env">Step 2: Move passwords into a <code>.env</code></h3>
<p>The database passwords do <strong>not</strong> belong in the <code>compose.yaml</code>, but in a separate <code>.env</code>. We have <code>openssl</code> generate them randomly:</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="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;DB_PASSWORD=</span><span class="k">$(</span>openssl rand -hex 16<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;DB_ROOT_PASSWORD=</span><span class="k">$(</span>openssl rand -hex 16<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span> &gt; .env
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> .env</span></span></code></pre></div>
</div>
<p><code>chmod 600</code> ensures only <code>root</code> can read the file. Take a look inside once so you know the values:</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">cat .env</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">DB_PASSWORD=b51024e3f9a7c8d1e2f3a4b5c6d7e8f9
</span></span><span class="line"><span class="cl">DB_ROOT_PASSWORD=1a2b3c4d5e6f70819a0b1c2d3e4f5061</span></span></code></pre></div>
</div>
<h3 id="step-3-write-the-composeyaml">Step 3: Write the <code>compose.yaml</code></h3>
<p>Create the file <code>/opt/matomo/compose.yaml</code> with the following content. In the Traefik label, replace <strong><code>stats.YOUR_DOMAIN</code> with your real (sub)domain</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="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.4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">matomo-db</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 class="l">max-allowed-packet=64MB</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">matomo</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">matomo</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">${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MARIADB_ROOT_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_ROOT_PASSWORD}</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:/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">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="w">  </span><span class="nt">app</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">matomo:5.12-apache</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">matomo</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">MATOMO_DATABASE_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">MATOMO_DATABASE_ADAPTER</span><span class="p">:</span><span class="w"> </span><span class="l">mysql</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MATOMO_DATABASE_USERNAME</span><span class="p">:</span><span class="w"> </span><span class="l">matomo</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MATOMO_DATABASE_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${DB_PASSWORD}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">MATOMO_DATABASE_DBNAME</span><span class="p">:</span><span class="w"> </span><span class="l">matomo</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">app:/var/www/html</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 class="p">[</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">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">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">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.matomo.rule=Host(`stats.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.matomo.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.matomo.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.matomo.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 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">external</span><span class="p">:</span><span class="w"> </span><span class="kc">false</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</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">app:</span></span></span></code></pre></div>
</div>
<p>The most important things about this file:</p>
<ul>
<li><strong>Two networks, deliberately separated.</strong> Only the <code>app</code> container is on the <code>proxy</code> network and thus reachable from outside. The database is on the internal <code>intern</code> network and is <strong>not</strong> addressable via Traefik – exactly right.</li>
<li><strong>The <code>MATOMO_DATABASE_*</code> variables</strong> pre-fill the web installer in the next step automatically. So you don&rsquo;t have to type the DB data again by hand.</li>
<li><strong><code>max-allowed-packet=64MB</code></strong> is recommended by Matomo for the database, otherwise the import of large reports can abort.</li>
<li><strong>No port to the outside.</strong> Neither Matomo nor the DB publishes a host port. The only way in leads via Traefik and thus via HTTPS.</li>
</ul>
<h3 id="step-4-start-the-stack">Step 4: Start the stack</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>On the first start, Docker downloads the images (about 310 MB of downloads, a good 1.3 GB unpacked) and creates the volumes. Then check the status:</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                COMMAND                  SERVICE   CREATED          STATUS          PORTS
</span></span><span class="line"><span class="cl">matomo      matomo:5.12-apache   &#34;/entrypoint.sh apac…&#34;   app       15 seconds ago   Up 13 seconds   80/tcp
</span></span><span class="line"><span class="cl">matomo-db   mariadb:11.4         &#34;docker-entrypoint.s…&#34;   db        15 seconds ago   Up 14 seconds   3306/tcp</span></span></code></pre></div>
</div>
<p>Both containers should be <code>Up</code>. Traefik detects the new container automatically via the labels and fetches a Let&rsquo;s Encrypt certificate for your domain in the background. That usually takes only a few seconds. Check from your own machine whether Matomo responds:</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://stats.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 200</span></span></code></pre></div>
</div>
<p>An <code>HTTP/2 200</code> means: the certificate is in place, Traefik routes, Matomo runs. If a certificate error comes instead, give Traefik a few more seconds – see &ldquo;When things go wrong&rdquo;.</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>Finish the installer immediately
  </p>
  <div class="prose-kitchen text-sm">As soon as the stack runs, your Matomo URL is publicly reachable – and the web installer still open. Whoever comes first creates the admin account. So click through the installer (steps 5 and 6) <strong>right afterwards</strong>, instead of leaving the instance unfinished for days. If you can&rsquo;t do that immediately, protect the domain temporarily – e.g. via <a href="/en/tutorials/traefik-reverse-proxy/">Traefik basic auth</a> or an IP allowlist that only lets your own address through.</div>
</div>
<h3 id="step-5-click-through-the-web-installer">Step 5: Click through the web installer</h3>
<p>Open <code>https://stats.YOUR_DOMAIN</code> in the browser. Matomo greets you with an eight-step setup wizard.</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-matomo/matomo-installer_hu_53df1f3592e88e3.webp" srcset="/en/tutorials/self-host-matomo/matomo-installer_hu_dd01f90c0d952834.webp 480w, /en/tutorials/self-host-matomo/matomo-installer_hu_53df1f3592e88e3.webp 768w, /en/tutorials/self-host-matomo/matomo-installer_hu_9c2b274fe7e15c56.webp 1200w, /en/tutorials/self-host-matomo/matomo-installer_hu_286ad5dc793f6adc.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-matomo/matomo-installer_hu_5198089e803ba170.webp"
    alt="Matomo installation wizard in the browser, welcome page with the step list from &ldquo;1. Welcome&rdquo; to &ldquo;8. Congratulations&rdquo;" title="The web installer of Matomo 5.12 – eight steps to the finished dashboard"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The web installer of Matomo 5.12 – eight steps to the finished dashboard</figcaption></figure></p>
<p>Click through the first steps:</p>
<ol>
<li><strong>Welcome</strong> → <em>Next</em>.</li>
<li><strong>System check</strong>: Matomo checks the PHP version, extensions and write permissions. Thanks to the official image, the list stays free of errors. You&rsquo;ll still see one yellow warning: <em>Forced SSL Connection</em>. It&rsquo;s normal here and no reason to stop – Matomo&rsquo;s own <code>force_ssl</code> option simply isn&rsquo;t set yet, while delivery is HTTPS-only anyway. We clear it in step 7. → <em>Next</em>.</li>
<li><strong>Set up the database</strong>: the fields are already filled in (the <code>MATOMO_DATABASE_*</code> variables from the <code>compose.yaml</code> do that). Briefly verify that the server is <code>db</code>, and click <em>Next</em>.</li>
<li><strong>Create tables</strong>: Matomo creates its database schema. → <em>Next</em>.</li>
</ol>
<p>Now you create your <strong>main administrator</strong>. Choose a username, a strong password and a real email address (security and update notices come through it).</p>
<p>Then you set up the <strong>first website</strong> you want to track. Besides name and URL, the <strong>website timezone</strong> is a required field: leave it on the placeholder and the form sends you back with <em>Website timezone is not valid</em>. It decides when a new day starts for your reports.</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-matomo/matomo-website_hu_b217dc7f93b2121f.webp" srcset="/en/tutorials/self-host-matomo/matomo-website_hu_e0fb0136b7af3d27.webp 480w, /en/tutorials/self-host-matomo/matomo-website_hu_b217dc7f93b2121f.webp 768w, /en/tutorials/self-host-matomo/matomo-website_hu_ea73a371a859144f.webp 1200w, /en/tutorials/self-host-matomo/matomo-website_hu_98760e0298847f1f.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-matomo/matomo-website_hu_83c3f773e0a79fd1.webp"
    alt="The &ldquo;Set up a Website&rdquo; form in the Matomo installer with the fields for name, address and timezone of the website" title="Step 6: create the first website to track – the timezone is required"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Step 6: create the first website to track – the timezone is required</figcaption></figure></p>
<h3 id="step-6-add-the-tracking-code">Step 6: Add the tracking code</h3>
<p>In the second-to-last step, Matomo shows you the <strong>JavaScript tracking code</strong>. That&rsquo;s the small snippet that belongs on every page of your website – ideally right before <code>&lt;/head&gt;</code>:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-matomo/matomo-tracking_hu_6626c761541fa35b.webp" srcset="/en/tutorials/self-host-matomo/matomo-tracking_hu_44f958d3f70d37fb.webp 480w, /en/tutorials/self-host-matomo/matomo-tracking_hu_6626c761541fa35b.webp 768w, /en/tutorials/self-host-matomo/matomo-tracking_hu_7d2f24dfbf211ffd.webp 1200w, /en/tutorials/self-host-matomo/matomo-tracking_hu_348cf6d3236b91d8.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-matomo/matomo-tracking_hu_ae26394fbac73d0e.webp"
    alt="Matomo installer shows the finished JavaScript tracking code to copy" title="The tracking code – this snippet goes into the head area of your website"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The tracking code – this snippet goes into the head area of your website</figcaption></figure></p>
<div class="sk-code">
  <span class="sk-code-head">HTML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="c">&lt;!-- Matomo --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="kd">var</span> <span class="nx">_paq</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">_paq</span> <span class="o">=</span> <span class="nb">window</span><span class="p">.</span><span class="nx">_paq</span> <span class="o">||</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">  <span class="nx">_paq</span><span class="p">.</span><span class="nx">push</span><span class="p">([</span><span class="s1">&#39;disableCookies&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">_paq</span><span class="p">.</span><span class="nx">push</span><span class="p">([</span><span class="s1">&#39;trackPageView&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">_paq</span><span class="p">.</span><span class="nx">push</span><span class="p">([</span><span class="s1">&#39;enableLinkTracking&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">u</span><span class="o">=</span><span class="s2">&#34;//stats.YOUR_DOMAIN/&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">_paq</span><span class="p">.</span><span class="nx">push</span><span class="p">([</span><span class="s1">&#39;setTrackerUrl&#39;</span><span class="p">,</span> <span class="nx">u</span><span class="o">+</span><span class="s1">&#39;matomo.php&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">_paq</span><span class="p">.</span><span class="nx">push</span><span class="p">([</span><span class="s1">&#39;setSiteId&#39;</span><span class="p">,</span> <span class="s1">&#39;1&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nx">d</span><span class="o">=</span><span class="nb">document</span><span class="p">,</span> <span class="nx">g</span><span class="o">=</span><span class="nx">d</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;script&#39;</span><span class="p">),</span> <span class="nx">s</span><span class="o">=</span><span class="nx">d</span><span class="p">.</span><span class="nx">getElementsByTagName</span><span class="p">(</span><span class="s1">&#39;script&#39;</span><span class="p">)[</span><span class="mi">0</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="nx">g</span><span class="p">.</span><span class="kr">async</span><span class="o">=</span><span class="kc">true</span><span class="p">;</span> <span class="nx">g</span><span class="p">.</span><span class="nx">src</span><span class="o">=</span><span class="nx">u</span><span class="o">+</span><span class="s1">&#39;matomo.js&#39;</span><span class="p">;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">parentNode</span><span class="p">.</span><span class="nx">insertBefore</span><span class="p">(</span><span class="nx">g</span><span class="p">,</span><span class="nx">s</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">})();</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="c">&lt;!-- End Matomo Code --&gt;</span></span></span></code></pre></div>
</div>
<p>We deliberately added the line <code>_paq.push(['disableCookies']);</code> (not included in the original snippet): with it, Matomo does entirely without cookies – an important building block for operation without a consent banner. If you run your website with a CMS like WordPress, there are ready-made plugins (&ldquo;Matomo for WordPress&rdquo;, &ldquo;Connect Matomo&rdquo;) that embed the snippet automatically. After the last step (&ldquo;Finish&rdquo;), you log in with the account you just created and land in the dashboard.</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-matomo/matomo-dashboard_hu_186f68b605b0e79e.webp" srcset="/en/tutorials/self-host-matomo/matomo-dashboard_hu_61bb4e60427f80e0.webp 480w, /en/tutorials/self-host-matomo/matomo-dashboard_hu_186f68b605b0e79e.webp 768w, /en/tutorials/self-host-matomo/matomo-dashboard_hu_8b5e93b8c4c64a5.webp 1200w, /en/tutorials/self-host-matomo/matomo-dashboard_hu_a91969f1d4a5d77c.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-matomo/matomo-dashboard_hu_8044f97a992cc8d.webp"
    alt="Matomo dashboard after installation with the visitor overview and the administration menu" title="The finished Matomo dashboard – all data lives on your server"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The finished Matomo dashboard – all data lives on your server</figcaption></figure></p>
<p>The dashboard bundles the most important reports: <em>Visits in real-time</em>, <em>Visits Over Time</em>, the <em>Visits Overview</em>, a <em>Visitor Map</em> and, under <em>Channel Types</em>, the origin of visitors (search engine, direct, referral). Via the menu on the left you go deeper – <strong>Visitors</strong> (devices, browsers, location), <strong>Behaviour</strong> (pages, entries and exits) and <strong>Acquisition</strong> (which channels bring visitors). Exactly the questions Google Analytics answers – only on your server.</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>Secure the admin account
  </p>
  <div class="prose-kitchen text-sm">Your Matomo account sees the complete visit behavior of your website – a worthwhile target. Assign a long, unique password and enable <strong>two-factor authentication</strong> under Administration → <em>Personal → Security</em> (the plugin is already on board). This way, even with a leaked password, no one gets at your data.</div>
</div>
<h3 id="step-7-check-the-privacy-settings-the-important-part">Step 7: Check the privacy settings (the important part)</h3>
<p>Matomo 5.12 starts out more privacy-friendly than many expect: IP anonymization is <strong>on by default</strong>. Still, walk through the page once – first, to have seen it with your own eyes, second, because there is a switch on it that really secures your cookieless tracking. Open <strong>Administration</strong> (gear) at the top right → <strong>Privacy → Anonymize data</strong> in the menu on the left.</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-matomo/matomo-datenschutz_hu_7af55f22679b3999.webp" srcset="/en/tutorials/self-host-matomo/matomo-datenschutz_hu_4f047bc4667cb237.webp 480w, /en/tutorials/self-host-matomo/matomo-datenschutz_hu_7af55f22679b3999.webp 768w, /en/tutorials/self-host-matomo/matomo-datenschutz_hu_c11282b3cd4b3695.webp 1200w, /en/tutorials/self-host-matomo/matomo-datenschutz_hu_b2aa088f929e7bf3.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-matomo/matomo-datenschutz_hu_4910d6c342e09ec2.webp"
    alt="Matomo&rsquo;s &ldquo;Anonymize data&rdquo; page with IP anonymization enabled and the selection of masked bytes" title="Privacy → Anonymize data: IP anonymization is already set to two bytes"
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Privacy → Anonymize data: IP anonymization is already set to two bytes</figcaption></figure></p>
<p>Three knobs are decisive:</p>
<ul>
<li><strong>Anonymize Visitors&rsquo; IP addresses</strong>: already set to <em>Yes</em>, and <strong>2 byte(s)</strong> are masked by default – <code>203.0.113.45</code> therefore becomes <code>203.0.xxx.xxx</code> (Matomo itself marks this level as <em>Recommended</em>). Just check that it stayed that way; with it the individual visitor is no longer identifiable.</li>
<li><strong>Force tracking without cookies</strong>: turn it on. Matomo then extends <code>matomo.js</code> with the matching code server-side and ignores tracking cookies on receipt as well. That still works if the original snippet without <code>disableCookies</code> ends up somewhere – a safety net for the very thing your consent-banner-free operation hangs on.</li>
<li><strong>&ldquo;Do Not Track&rdquo;</strong>: lives under <em>Privacy → Users opt-out</em>. Matomo now classifies the support as <strong>deprecated</strong> itself – because browsers send the signal unasked or have dropped support entirely, &ldquo;we no longer recommend enabling this option&rdquo;. So don&rsquo;t rely on it. The dependable route is on the same page: the <strong>opt-out building block</strong> (iframe or your own form) that you embed in your privacy policy.</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>Tip
  </p>
  <div class="prose-kitchen text-sm">Under <em>Privacy → Anonymize data → Regularly delete old raw data from the database</em> you can instruct Matomo to delete raw data automatically after e.g. 180 days. That reduces the data volume <strong>and</strong> the privacy risk – the aggregated reports are preserved.</div>
</div>
<p>That leaves the switch the system check from step 5 complained about: <code>force_ssl</code>. It makes Matomo itself reject unencrypted access instead of relying on Traefik alone. There is no setting for it in the interface; it lives in Matomo&rsquo;s <code>config/config.ini.php</code> inside the volume:</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">cd</span> /opt/matomo
</span></span><span class="line"><span class="cl">docker compose <span class="nb">exec</span> -u www-data app sed -i <span class="s1">&#39;/^\[General\]/a force_ssl = 1&#39;</span> config/config.ini.php</span></span></code></pre></div>
</div>
<p><code>sed</code> appends the line <code>force_ssl = 1</code> right below the <code>[General]</code> section; <code>-u www-data</code> makes sure the file keeps its previous owner so Matomo can still write to it. Reload the dashboard afterwards – under <em>Administration → Diagnostic → System Check</em> the <em>Forced SSL Connection</em> warning is gone.</p>
<h3 id="step-8-archive-reports-via-cron">Step 8: Archive reports via cron</h3>
<p>By default, Matomo computes its reports &ldquo;on the fly&rdquo; as soon as someone opens the dashboard. That gets slow with more traffic and is officially not recommended. Better: a cron job computes the reports regularly in the background.</p>
<p>For that, on the host, enter a cron job that runs the archiving command in the container every 15 minutes:</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">crontab -e</span></span></code></pre></div>
</div>
<p>Add this line (adjust the domain):</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">*/15 * * * * cd /opt/matomo &amp;&amp; docker compose exec -T -u www-data app ./console core:archive --url=https://stats.YOUR_DOMAIN &gt;&gt; /opt/matomo/archive.log 2&gt;&amp;1</span></span></code></pre></div>
</div>
<p>The log deliberately lands in <code>/opt/matomo/archive.log</code> and not under <code>/var/log</code>: if your cron runs as a sudo user (not as <code>root</code>), a write to <code>/var/log</code> fails silently. In <code>/opt/matomo</code> you have write permissions, and you find the log right by the project.</p>
<p>Then switch off the old mode: Administration → <em>General settings</em> → <em>Archiving settings</em>, set <strong>&ldquo;Archive reports when viewed from the browser&rdquo;</strong> to <strong>No</strong> and save. This keeps the dashboard fast even with a lot of traffic – and the system check stops reporting that browser and cron archiving run side by side.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The browser shows a certificate error or <code>404 page not found</code>.</strong> Traefik hasn&rsquo;t fetched the Let&rsquo;s
Encrypt certificate yet, or the DNS record doesn&rsquo;t point to the server. check with <code>dig stats.YOUR_DOMAIN</code> that the IP is correct, and look at the Traefik logs. You find the exact
container name from your Traefik setup with <code>docker ps | grep traefik</code>, then <code>docker logs &lt;container-name&gt;</code> (for us e.g. <code>docker logs traefik-traefik-1</code>). The HTTP challenge fails if port 80
isn&rsquo;t reachable from outside – check your <a href="/en/tutorials/firewall-ufw-setup/">firewall</a> and the
<a href="/en/tutorials/netcup-firewall-setup/">netcup firewall</a>.</p>
<p><strong>The installer reports <code>SQLSTATE... Connection refused</code> or hangs at the database.</strong> MariaDB wasn&rsquo;t
ready on the first start. give the DB a moment and reload the page. Check with <code>docker compose logs db</code> whether it says <code>ready for connections</code>. If an access error appears instead, the <code>DB_PASSWORD</code>
in the <code>.env</code> and the already-created DB no longer match – then a clean restart with <code>docker compose down -v</code> (careful: deletes the data) and <code>docker compose up -d</code> helps.</p>
<p><strong>Warning &ldquo;It looks like the <code>trusted_hosts</code> setting is not correct&rdquo;.</strong> Matomo checks, for security
reasons, under which hostname it&rsquo;s called. the warning appears when you change the domain. Confirm
the correct hostname via the button in the message – Matomo then enters it in
<code>config/config.ini.php</code>.</p>
<p><strong>No visits appear in the dashboard.</strong> the tracking code is missing, incorrectly embedded, or you&rsquo;re
visiting your own site (Matomo ignores you if your IP is excluded). open your website in a private
window and check in the network tab whether a request to <code>matomo.php</code> goes out. In Matomo,
<em>Administration → Diagnostic → Tracking failures</em> helps.</p>
<p><strong>The dashboard loads very slowly.</strong> the on-the-fly archiving computes on every call. set up the
archiving cron from step 8 and switch report generation to cron.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Updates</strong> are uncritical with Matomo but due regularly – a new version appears about monthly. Because we pinned the image tag to <code>5.12</code>, you pull security patches within the 5.12 series like this:</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">cd</span> /opt/matomo <span class="o">&amp;&amp;</span> docker compose pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>On the first call after an update, Matomo checks whether a <strong>database upgrade</strong> is needed and performs it after a click.</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>Before a major upgrade: backup and release notes
  </p>
  <div class="prose-kitchen text-sm">For a jump to a new major version (e.g. 5.12 → 6.x), you raise the tag in the <code>compose.yaml</code>. A major upgrade often entails more extensive database migrations that can&rsquo;t easily be rolled back. So read the release notes first and <strong>definitely pull a backup</strong> (see below) before you start the new image.</div>
</div>
<p><strong>Backups</strong> comprise two things: the database (all visit data) and the <code>app</code> volume (your configuration in <code>config/config.ini.php</code>). Create a database dump regularly …</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 <span class="nb">exec</span> -T db sh -c <span class="s1">&#39;exec mariadb-dump -u matomo -p&#34;$MARIADB_PASSWORD&#34; matomo&#39;</span> &gt; /opt/matomo/dump.sql</span></span></code></pre></div>
</div>
<p>… and include this dump together with the <code>/opt/matomo</code> folder in your <a href="/en/tutorials/restic-backups/">Restic off-site backups</a>. A netcup snapshot of the whole server is a good complement, but <a href="/en/tutorials/netcup-snapshots-scp/">no substitute for a real off-site backup</a> – if both are at the same provider, both are gone in a total failure.</p>
<p><strong>Ongoing operation:</strong> keep an eye on disk space – with a lot of traffic the database grows. The automatic deletion of old raw data (step 7) and the archiving cron (step 8) keep Matomo lean and fast permanently. So you notice when Matomo is ever unreachable, add the instance to your <a href="/en/tutorials/uptime-kuma-monitoring/">Uptime Kuma monitoring</a> – a simple HTTP check on your stats domain is enough.</p>
<p>And the most important operational note to finish: Matomo lives off its privacy promise. Whoever has set the service up once should not soften the anonymization and deletion settings from step 7 again – they are the reason you can do without a consent banner in the first place. After every larger update, briefly check that they&rsquo;re still in place.</p>
]]></content:encoded></item><item><title>HitKeep: self-host privacy-friendly web analytics</title><link>https://serverkueche.de/en/tutorials/self-host-hitkeep-analytics/</link><pubDate>Fri, 31 Jul 2026 00:00:00 +0000</pubDate><author>feedback@serverkueche.de (Serverküche)</author><guid>https://serverkueche.de/en/tutorials/self-host-hitkeep-analytics/</guid><description>Set up HitKeep with Docker and Traefik: cookieless, GDPR-friendly website statistics on your own server – with tracking code and backups.</description><content:encoded><![CDATA[<p>Everyone knows Google Analytics – and that&rsquo;s exactly the problem: it sends your visitors&rsquo; data
to Google, requires a cookie banner and makes you accountable to explain it. HitKeep turns that
around: cookieless statistics on <strong>your</strong> server, under <strong>your</strong> domain, without a single byte
ever going to third parties.</p>
<h2 id="what-are-we-building">What are we building?</h2>
<p>By the end, <strong>HitKeep 2.12.0</strong> runs as a single container behind your Traefik, reachable at
<code>https://YOUR_DOMAIN</code>. You get a dashboard with page views, visitors, time on page, referrers
and devices – fed by a tiny JavaScript snippet you embed in your website. HitKeep works
<strong>cookieless</strong> (no consent banner needed) and stores everything locally in an embedded DuckDB
database. The image is a lean distroless image (about 71 MB) that gets by entirely without an
external database – ideal for a small VPS.</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>Note
  </p>
  <div class="prose-kitchen text-sm">HitKeep is &ldquo;cookieless&rdquo; because it recognizes visitors via a daily-changing hash instead of a
set cookie. That&rsquo;s significantly more privacy-friendly than classic tracking, but is <strong>no</strong>
substitute for legal advice. Whether you can do entirely without consent depends on your
specific use – when in doubt, clarify it with a data protection officer.</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<ul>
<li>A server with <strong>Debian 13</strong> and running Docker (tested on a netcup VPS).</li>
<li>A <strong><a href="/en/tutorials/traefik-reverse-proxy/">reverse proxy with Traefik</a></strong> that fetches TLS
certificates via Let&rsquo;s Encrypt. HitKeep brings no own HTTPS server – Traefik handles the
encryption. This tutorial assumes the <code>proxy</code> network and the resolver <code>le</code> described there.</li>
<li>A <strong>(sub)domain</strong> that points to your server (A/AAAA record). In the example we use
<code>YOUR_DOMAIN</code>.</li>
<li>The website you want to measure – HitKeep measures every page into which you embed the
snippet.</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/self-host-hitkeep-analytics/" data-content-piece="VPS 1000 G12">
  <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</p>
      <p class="mt-1 text-sm text-slate-600 dark:text-slate-300">4 vCores · 8 GB RAM · 256 GB NVMe</p>
      <p class="mt-1 text-sm font-semibold text-paprika-700 dark:text-paprika-400">from €10.36/month</p>
      <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">A single container with an embedded DuckDB – the smallest vServer is easily enough for that.</p>
    </div>
    <a href="https://www.netcup.com/en/server/vps/vps-1000-g12-iv-12m?ref=44083" rel="sponsored noopener" target="_blank"
   data-track-event="Affiliate|netcup: Affiliate-Box|VPS 1000 G12 · {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"><p class="not-prose my-3 flex flex-wrap items-center gap-x-2 gap-y-1 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">
  <span>💶 <strong>5 € voucher</strong> for new netcup customers:</span><code data-track-voucher="36nc17844976032"
        class="rounded bg-white px-2 py-0.5 font-mono text-sm font-semibold text-herb-800 dark:bg-slate-800 dark:text-herb-400">36nc17844976032</code>
  <span class="text-xs text-slate-500 dark:text-slate-400">(new customers only, no domains)</span>
</p></div>
</div>

<h2 id="step-by-step">Step by step</h2>
<h3 id="step-1-generate-a-jwt-secret">Step 1: Generate a JWT secret</h3>
<p>HitKeep signs the login sessions with a secret key. Generate a random 32-byte value – <strong>don&rsquo;t</strong>
make one up, generate 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">openssl rand -hex <span class="m">32</span></span></span></code></pre></div>
</div>
<p>You get a 64-character hex string. Copy it – it goes into the configuration shortly. If this
secret changes later, all open logins become invalid; so keep it stable and secret.</p>
<h3 id="step-2-create-the-compose-file">Step 2: Create the Compose file</h3>
<p>Create a folder for the stack and change into 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">mkdir -p /opt/hitkeep <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/hitkeep</span></span></code></pre></div>
</div>
<p>Create the file <code>compose.yaml</code>. Replace <code>YOUR_DOMAIN</code> with your real domain and
<code>YOUR_JWT_SECRET</code> with the value from step 1:</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">hitkeep</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">pascalebeier/hitkeep:2.12.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">hitkeep</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">HITKEEP_PUBLIC_URL</span><span class="p">:</span><span class="w"> </span><span class="l">https://YOUR_DOMAIN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HITKEEP_JWT_SECRET</span><span class="p">:</span><span class="w"> </span><span class="l">YOUR_JWT_SECRET</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HITKEEP_TRUSTED_PROXIES</span><span class="p">:</span><span class="w"> </span><span class="m">172.16.0.0</span><span class="l">/12</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HITKEEP_DB_PATH</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/hitkeep/data/hitkeep.db</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HITKEEP_DATA_PATH</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/hitkeep/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HITKEEP_ARCHIVE_PATH</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/hitkeep/archive</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HITKEEP_BACKUP_PATH</span><span class="p">:</span><span class="w"> </span><span class="l">/var/lib/hitkeep/backups</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">hitkeep_data:/var/lib/hitkeep/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">hitkeep_archive:/var/lib/hitkeep/archive</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">hitkeep_backups:/var/lib/hitkeep/backups</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.hitkeep.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.hitkeep.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.hitkeep.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.hitkeep.loadbalancer.server.port=8080&#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 class="p">[</span><span class="l">proxy]</span><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">hitkeep_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">hitkeep_archive</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">hitkeep_backups</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></span></code></pre></div>
</div>
<p>The most important points in detail:</p>
<ul>
<li><strong><code>HITKEEP_PUBLIC_URL</code></strong> is the public address under which HitKeep is reachable. The interface
later builds the tracking snippet and the links from it. It must match <strong>exactly</strong> the URL
under which you call HitKeep – otherwise you end up in a login loop.</li>
<li><strong><code>HITKEEP_TRUSTED_PROXIES</code></strong> is the crux behind a reverse proxy: without this value, HitKeep
sees only Traefik&rsquo;s internal IP as the sender of every call – then <strong>all</strong> visitors land as a
single one. <code>172.16.0.0/12</code> covers the Docker networks, so HitKeep trusts Traefik&rsquo;s
<code>X-Forwarded-For</code> header and evaluates the real visitor IP.</li>
<li><strong><code>loadbalancer.server.port=8080</code></strong> tells Traefik that HitKeep listens internally on port
8080. The container itself publishes no ports to the outside – access runs exclusively via
Traefik.</li>
<li>The four <strong><code>_PATH</code> variables</strong> store the database, data, archive and backups under
<code>/var/lib/hitkeep/</code>; the matching <strong>volumes</strong> ensure they survive an update or a restart.</li>
</ul>
<h3 id="step-3-start-the-stack-and-wait-for-tls">Step 3: Start the stack and wait for TLS</h3>
<p>Start the container:</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>Check the status after a few seconds:</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>
<p>You should see the container as <code>healthy</code> – HitKeep brings its own healthcheck:</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">NAME      IMAGE                         SERVICE   STATUS                   PORTS
</span></span><span class="line"><span class="cl">hitkeep   pascalebeier/hitkeep:2.12.0   hitkeep   Up 18 minutes (healthy)  7946/tcp, 8080/tcp</span></span></code></pre></div>
</div>
<p>Traefik now fetches the Let&rsquo;s Encrypt certificate for your domain in the background. Check from
your own machine that the tracking script is served:</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/hk.js</span></span></code></pre></div>
</div>
<p>Expected output (shortened) – status 200 and a <code>text/javascript</code> type, aggressively cached:</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">HTTP/2 200
</span></span><span class="line"><span class="cl">content-type: text/javascript; charset=utf-8
</span></span><span class="line"><span class="cl">cache-control: public, max-age=31536000, immutable</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>Warning
  </p>
  <div class="prose-kitchen text-sm">If you get a <code>404</code> from Traefik or a certificate warning here, wait a minute (Let&rsquo;s Encrypt
needs a moment) and check that the A/AAAA record of your domain really points to the server.
As long as the certificate isn&rsquo;t in place, the snippet won&rsquo;t load in the browser either.</div>
</div>
<h3 id="step-4-create-the-admin-account">Step 4: Create the admin account</h3>
<p>Open <code>https://YOUR_DOMAIN</code> in the browser. On the very first start, HitKeep greets you with the
initial setup. Create your administrator account here – name, email address and a password. Take
a long passphrase or a random password generated by a password manager; this account sees all
statistics and must not hang on a weak password:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-hitkeep-analytics/hitkeep-admin-einrichten_hu_b02649cf40b3be10.webp" srcset="/en/tutorials/self-host-hitkeep-analytics/hitkeep-admin-einrichten_hu_3135860f4f0c0e67.webp 480w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-admin-einrichten_hu_b02649cf40b3be10.webp 768w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-admin-einrichten_hu_d9a8892db681c810.webp 1200w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-admin-einrichten_hu_a483d9f5bb43a510.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-hitkeep-analytics/hitkeep-admin-einrichten_hu_4192495b2f148095.webp"
    alt="HitKeep initial setup: form to create the administrator account with first and last name, email address and password." title="On first launch you create your admin account."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">On first launch you create your admin account.</figcaption></figure></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">This initial setup is only open on the very first call. Still: set up the admin account
<strong>immediately</strong> after the start and don&rsquo;t leave a freshly started HitKeep wizard unsecured on
the net.</div>
</div>
<h3 id="step-5-create-a-website-and-get-the-tracking-code">Step 5: Create a website and get the tracking code</h3>
<p>After logging in, click the plus next to <strong>Sites</strong> at the top left and create your website – as
the domain, enter the domain of the site you want to measure (e.g. <code>YOUR_WEBSITE</code>).</p>
<p>Then open the site settings and switch to the <strong>Tracking</strong> tab. There you find the <strong>live
tracking verifier</strong> (which waits for the first hit) and the configuration of the tracking code
including the tracker host:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-hitkeep-analytics/hitkeep-tracking-code_hu_dc6a283eb5b165ae.webp" srcset="/en/tutorials/self-host-hitkeep-analytics/hitkeep-tracking-code_hu_bc41135cf48424d3.webp 480w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-tracking-code_hu_dc6a283eb5b165ae.webp 768w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-tracking-code_hu_c3d82fe0876941d.webp 1200w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-tracking-code_hu_aa79f4d88d44e8cc.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-hitkeep-analytics/hitkeep-tracking-code_hu_87dfd87be9a39e96.webp"
    alt="HitKeep tracking settings: live verifier waits for the first hit, below it the configuration of the tracking code with tracker host and options like DNT and Web Vitals." title="The tracking tab delivers the snippet and checks live whether hits arrive."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The tracking tab delivers the snippet and checks live whether hits arrive.</figcaption></figure></p>
<p>The code to embed consists of a single line. HitKeep needs <strong>no site ID</strong> in the snippet – the
assignment happens automatically via the domain of the page on which the script runs:</p>
<div class="sk-code">
  <span class="sk-code-head">HTML</span>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">async</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;https://YOUR_DOMAIN/hk.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div>
</div>
<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>Note
  </p>
  <div class="prose-kitchen text-sm">The difference between the <strong>tracker host</strong> and the <strong>measured domain</strong> is important: <code>hk.js</code> is
loaded from your HitKeep domain (<code>YOUR_DOMAIN</code>), but the hit is assigned to the domain of the
visited page (<code>YOUR_WEBSITE</code>). Both may be different – the site created in HitKeep only has to
match the hostname of the visited page.</div>
</div>
<p>Automatic event tracking (outbound clicks, downloads, form submissions) is active by default.
Optionally, on the same tab, you can enable &ldquo;Web Vitals&rdquo; to also measure load times (LCP, INP,
CLS, FCP, TTFB), or enable &ldquo;Collect DNT&rdquo; if you also want to count visitors with &ldquo;Do Not Track&rdquo;
– from a privacy perspective the default (respect DNT) is the cleaner one.</p>
<h3 id="step-6-embed-the-tracking-code-in-the-website">Step 6: Embed the tracking code in the website</h3>
<p>Add the snippet line from step 5 into the <code>&lt;head&gt;</code> of your website – for a static page directly
into the HTML template, for a CMS into the header area or a &ldquo;Custom HTML&rdquo; field. Thanks to the
<code>async</code> attribute, the script doesn&rsquo;t block the page build.</p>
<p>Then open a page of your website in the browser. The <strong>live tracking verifier</strong> from step 5
should jump from &ldquo;Waiting&rdquo; to a first hit within a few seconds – that&rsquo;s the confirmation that
the chain website → <code>hk.js</code> → HitKeep is in place.</p>
<h3 id="step-7-check-the-dashboard">Step 7: Check the dashboard</h3>
<p>As soon as hits trickle in, the dashboard fills up. Under <strong>Dashboard</strong> you see your website&rsquo;s
key figures – live visitors, page views, unique sessions, bounce rate, time on page and the
traffic trend:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-hitkeep-analytics/hitkeep-dashboard_hu_20fed96fa1b9aa0e.webp" srcset="/en/tutorials/self-host-hitkeep-analytics/hitkeep-dashboard_hu_662414d46c29959.webp 480w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-dashboard_hu_20fed96fa1b9aa0e.webp 768w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-dashboard_hu_4d9ecf744e854a2f.webp 1200w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-dashboard_hu_d35548a457f25bc.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-hitkeep-analytics/hitkeep-dashboard_hu_92a92986f140919f.webp"
    alt="HitKeep dashboard with metric tiles (live visitors, page views, unique sessions, bounce rate) and a traffic trend chart for the current day." title="The dashboard bundles the most important key figures and the traffic trend."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">The dashboard bundles the most important key figures and the traffic trend.</figcaption></figure></p>
<p>Further down, <strong>Latest Hits</strong> lists the individual calls with path, time, referrer and device –
here you see at a glance which search engines and referrals your visitors come from:</p>
<p><figure class="my-6"><img src="/en/tutorials/self-host-hitkeep-analytics/hitkeep-latest-hits_hu_3e42239dd25f7ebe.webp" srcset="/en/tutorials/self-host-hitkeep-analytics/hitkeep-latest-hits_hu_4876f6c928f4581e.webp 480w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-latest-hits_hu_3e42239dd25f7ebe.webp 768w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-latest-hits_hu_c8a10421fd2e6fc4.webp 1200w, /en/tutorials/self-host-hitkeep-analytics/hitkeep-latest-hits_hu_b3e324401e709011.webp 1920w" sizes="(min-width: 768px) 768px, 100vw"
    width="768" height="432"
    data-full="/en/tutorials/self-host-hitkeep-analytics/hitkeep-latest-hits_hu_99f80a189f8ddb68.webp"
    alt="HitKeep table &ldquo;Latest Hits&rdquo; with individual page views, referrers like search engines and the device type per call." title="Latest Hits shows every single call with referrer and device."
    loading="lazy" decoding="async" class="rounded-lg"><figcaption class="mt-2 text-sm text-center text-slate-500 italic">Latest Hits shows every single call with referrer and device.</figcaption></figure></p>
<p>With that your self-hosted statistics are in place: every call to your website lands directly in
your own database, without a detour via third parties.</p>
<h3 id="step-8-more-than-just-page-views">Step 8: More than just page views</h3>
<p>For the start, page views and referrers are enough – but HitKeep can do considerably more, and
you find the building blocks in the left navigation. A few that are worth it for most sites:</p>
<ul>
<li><strong>Goals:</strong> define an event as a goal – e.g. the submission of a contact form or a click on
&ldquo;Buy&rdquo;. This way you measure not only <em>how many</em> come, but <em>how many do what you want</em>.</li>
<li><strong>Funnels:</strong> chain several steps (home page → product page → cart) and see at which point
visitors drop off.</li>
<li><strong>Events:</strong> besides the automatically captured events (outbound clicks, downloads, forms), you
can send your own events from your frontend.</li>
<li><strong>Web Vitals:</strong> if you enable them on the tracking tab, you see real load times of your
visitors (LCP, INP, CLS) instead of synthetic lab values.</li>
<li><strong>UTM:</strong> campaign parameters (<code>utm_source</code>, <code>utm_medium</code>, <code>utm_campaign</code>) are evaluated – handy
to tell newsletter from social reach.</li>
<li><strong>Email Reports:</strong> have a summary sent to you regularly by email instead of having to look
into the dashboard yourself.</li>
</ul>
<p>A single HitKeep instance also manages <strong>any number of websites</strong>: via the plus next to
<strong>Sites</strong> you create more, each with its own snippet and its own dashboard. So you don&rsquo;t need a
second container if you want to measure several projects.</p>
<h2 id="when-things-go-wrong">When things go wrong</h2>
<div class="troubleshoot not-prose">
<p><strong>The live verifier stays on &ldquo;Waiting&rdquo; / no hits in the dashboard.</strong>
Check in the browser (dev tools → Network) whether <code>hk.js</code> is loaded at all and the send request
afterwards comes back with status 2xx. Most common causes: the snippet isn&rsquo;t in the HTML, the
site domain created in HitKeep doesn&rsquo;t match the hostname of the visited page, or an ad/tracking
blocker filters the call. Since you host under your own domain (first-party), most blockers
don&rsquo;t apply – but some lists know the path <code>hk.js</code>.</p>
<p><strong>All visitors seemingly come from a single IP.</strong>
Then <code>HITKEEP_TRUSTED_PROXIES</code> is missing or not taking effect: HitKeep sees only Traefik&rsquo;s
internal IP. Make sure the variable is set (<code>172.16.0.0/12</code> covers the Docker networks) and
restart the stack (<code>docker compose up -d</code>). After that, visitor numbers and origin again
evaluate the real client IP from the <code>X-Forwarded-For</code> header.</p>
<p><strong>Traefik returns 404 or 502.</strong>
A <code>404</code> usually means the router rule isn&rsquo;t matching – check that <code>Host(...)</code> contains your real
domain and the container is on the <code>proxy</code> network. A <code>502</code> indicates the wrong port: HitKeep
listens internally on <strong>8080</strong>, so <code>loadbalancer.server.port=8080</code> must be set.</p>
<p><strong>After login you land on the login page again (login loop).</strong>
That&rsquo;s almost always a mismatch in <code>HITKEEP_PUBLIC_URL</code>: the value must match exactly the address
through which you call HitKeep (including <code>https://</code>, without a trailing slash). Correct the
variable and restart the container.</p>
<p><strong>The container won&rsquo;t start or isn&rsquo;t <code>healthy</code>.</strong>
Look at the logs: <code>docker compose logs -f hitkeep</code>. A missing or empty <code>HITKEEP_JWT_SECRET</code> is a
typical start blocker – generate one as in step 1 and enter it.</p>

</div>

<h2 id="maintenance--backups">Maintenance &amp; backups</h2>
<p><strong>Updates:</strong> HitKeep moves along briskly in the 2.x series – check the
<a href="https://github.com/pascalebeier/hitkeep/releases">releases</a> about monthly. For an update, set
the new tag in the <code>compose.yaml</code> (replace <code>2.12.0</code> with the new version) and pull 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 compose pull <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div>
</div>
<p>Because the data lives in volumes, your statistics are preserved. Deliberately pin the version
to a fixed tag instead of <code>latest</code>, so a restart doesn&rsquo;t slip you an unplanned new major version.</p>
<p><strong>Backups:</strong> Your entire statistics live in an embedded DuckDB file (<code>hitkeep.db</code> plus the
write-ahead log <code>hitkeep.db.wal</code>) under <code>/var/lib/hitkeep/data</code>. So don&rsquo;t back up a single file,
but the <strong>complete <code>hitkeep_data</code> volume</strong> regularly – cleanest with
<a href="/en/tutorials/restic-backups/">Restic</a>. Because these files are written during operation, you
back them up most consistently by briefly stopping the container (<code>docker compose stop</code>), backing
up the volume and starting again – or by including the backup HitKeep stores under
<code>HITKEEP_BACKUP_PATH</code>. A backup you&rsquo;ve never restored is just a glimmer of hope: test the
restoration once on a test system.</p>
<p><strong>Cleanup:</strong> The database grows with the traffic. Keep an eye on the size of the volumes
(<code>docker system df -v</code>) and plan for enough storage with a lot of traffic.</p>
]]></content:encoded></item></channel></rss>