<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>TheMace</title>
    <link>https://themace.de/</link>
    <description>Persönliche Website und Blog von Mace – Code, Ideen und Geschichten.</description>
    <language>de</language>
    <atom:link href="https://themace.de/feed.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>TrueNAS als iSCSI-Storage für Proxmox: das offizielle Plugin einrichten</title>
      <link>https://themace.de/blog/truenas-proxmox-plugin/</link>
      <guid>https://themace.de/blog/truenas-proxmox-plugin/</guid>
      <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
      <description>Dataset, iSCSI-Target, API-Schlüssel und eine Zeile in der storage.cfg. So bindest du TrueNAS SCALE mit dem offiziellen Plugin als Shared Storage in Proxmox VE ein.</description>
      <content:encoded><![CDATA[<p>Proxmox und TrueNAS gehören im Homelab fast schon zusammen. Bisher hieß das aber: ZVOLs von Hand anlegen, iSCSI-Extents klicken, LUNs zuordnen und hoffen, dass man nichts vergisst. Mit dem offiziellen <a href="https://github.com/truenas/truenas-proxmox-plugin">TrueNAS-Proxmox-Plugin</a> erledigt Proxmox das selbst über die TrueNAS-API.</p>
<p>Dieser Beitrag zeigt die Einrichtung Schritt für Schritt: erst die Vorbereitung auf TrueNAS, dann Installation und Konfiguration auf Proxmox.</p>
<h2 id="die-ausgangslage"><a class="anchor" href="#die-ausgangslage" aria-hidden="true" tabindex="-1">#</a>Die Ausgangslage</h2>
<table>
<thead>
<tr>
<th>Komponente</th>
<th>Details</th>
</tr>
</thead>
<tbody><tr>
<td>NAS</td>
<td>TrueNAS SCALE 25.10 „Goldeye“</td>
</tr>
<tr>
<td>Hypervisor</td>
<td>Proxmox VE</td>
</tr>
<tr>
<td>Protokoll</td>
<td>iSCSI über die TrueNAS-API</td>
</tr>
<tr>
<td>Ziel</td>
<td>Shared Storage für VM-Disks und Container</td>
</tr>
</tbody></table>
<h2 id="teil-1-truenas-vorbereiten"><a class="anchor" href="#teil-1-truenas-vorbereiten" aria-hidden="true" tabindex="-1">#</a>Teil 1: TrueNAS vorbereiten</h2>
<h3 id="dataset-anlegen"><a class="anchor" href="#dataset-anlegen" aria-hidden="true" tabindex="-1">#</a>Dataset anlegen</h3>
<p>Unter <strong>Datasets → Add Dataset</strong> ein neues Dataset erstellen:</p>
<ul>
<li><strong>Name:</strong> <code>Proxmox-Backup</code></li>
<li><strong>Übergeordnetes Dataset:</strong> der Speicherpool, z. B. <code>pve</code></li>
<li><strong>Dataset-Voreinstellung:</strong> <code>Generic</code></li>
</ul>
<p>Unter diesem Dataset legt das Plugin später alle ZVOLs für die VMs ab.</p>
<h3 id="iscsi-dienst-aktivieren"><a class="anchor" href="#iscsi-dienst-aktivieren" aria-hidden="true" tabindex="-1">#</a>iSCSI-Dienst aktivieren</h3>
<p><strong>System → Services</strong>, in der Zeile <strong>iSCSI</strong> auf den Play-Button klicken und <strong>Start Automatically</strong> anhaken.</p>
<h3 id="base-name-pruefen"><a class="anchor" href="#base-name-pruefen" aria-hidden="true" tabindex="-1">#</a>Base Name prüfen</h3>
<p>In derselben Zeile über das Stift-Symbol die <strong>iSCSI Global Configuration</strong> öffnen. Wichtig ist hier der <strong>Base Name</strong>: Er muss mit <code>iqn</code> beginnen. Der Standardwert <code>iqn.2005-10.org.freenas.ctl</code> passt.</p>
<aside class="callout callout-warnung" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2 20h20L12 3Z"/><path d="M12 10v4M12 17h.01"/></svg><span>Base Name muss mit „iqn“ beginnen</span></div><div class="callout-body"><p>Steht dort etwas wie <code>TrueNAS</code>, entstehen ungültige IQNs und kein Initiator verbindet sich. Genau das ist das Symptom aus <a href="https://github.com/truenas/truenas-proxmox-plugin/issues/117">Issue #117</a>.</p>
</div></aside>
<p>Den Wert notieren. Er landet später zusammen mit dem Target-Namen in der <code>storage.cfg</code>, im Format <code>&lt;Base Name&gt;:&lt;Target Name&gt;</code>.</p>
<h3 id="target-extent-und-portal-per-wizard"><a class="anchor" href="#target-extent-und-portal-per-wizard" aria-hidden="true" tabindex="-1">#</a>Target, Extent und Portal per Wizard</h3>
<p>Unter <strong>Shares → Block (iSCSI) Shares Targets</strong> auf <strong>Wizard</strong> klicken. Der Assistent hat drei Schritte.</p>
<p><strong>1. Target</strong></p>
<p>Das Dropdown auf <strong>Create New</strong> lassen und einen Target-Namen vergeben, z. B. <code>pve</code>. Weiter.</p>
<p><strong>2. Extent</strong></p>
<ul>
<li><strong>Name:</strong> beliebig</li>
<li><strong>Extent Type:</strong> <code>Device</code></li>
<li><strong>Device:</strong> das ZVOL, das TrueNAS freigeben soll</li>
<li><strong>Sharing Platform:</strong> <code>Modern OS</code> (4K-Blockgröße, TPC an, kein Xen-Kompatibilitätsmodus, SSD-Speed)</li>
<li><strong>Read-Only:</strong> aus lassen</li>
</ul>
<aside class="callout callout-hinweis" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg><span>Hinweis</span></div><div class="callout-body"><p>Die Standardeinstellung ist auf VMware abgestimmt und setzt Blockgrößen-Optimierungen, die für Proxmox nicht passen. Deshalb <code>Modern OS</code> wählen.</p>
</div></aside>
<p><strong>3. Protocol Options</strong></p>
<ul>
<li><strong>Portal:</strong> über <strong>Add</strong> ein Portal auf <code>0.0.0.0:3260</code> anlegen oder ein bestehendes wählen.</li>
<li><strong>Initiators:</strong> leer lassen bzw. <strong>Allow all initiators</strong> anhaken, damit alle Proxmox-Knoten zugreifen dürfen. Wer den Zugriff einschränken will, trägt hier die IQNs der Proxmox-Knoten ein.</li>
</ul>
<p>Speichern, fertig.</p>
<aside class="callout callout-wichtig" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v12H8l-4 4V4Z"/><path d="M12 7v4M12 13.5h.01"/></svg><span>Portal und Initiators nicht vergessen</span></div><div class="callout-body"><p>Fehlt eines von beiden, liefert <code>iscsiadm --mode discovery</code> auf Proxmox nichts zurück. Das Plugin legt dann zwar ZVOLs auf TrueNAS an, kommt aber nicht weiter. Das ist das häufigste Problem bei der Ersteinrichtung.</p>
</div></aside>
<p>Wer jedes Feld einzeln steuern möchte, findet den manuellen Weg über die Registerkarten Targets, Extents, Initiators, Portals und Authorized Access weiterhin unter <code>/ui/sharing/iscsi/targets</code>. Für den Normalfall reicht der Wizard.</p>
<h3 id="api-schluessel-erstellen"><a class="anchor" href="#api-schluessel-erstellen" aria-hidden="true" tabindex="-1">#</a>API-Schlüssel erstellen</h3>
<p>Oben rechts im Benutzermenü (<strong>root</strong>) → <strong>My API Keys</strong> → <strong>Add</strong>. Den Schlüssel sofort kopieren, TrueNAS zeigt ihn nur ein einziges Mal an. Er kommt später als <code>tn_api_key</code> in die <code>storage.cfg</code>.</p>
<aside class="callout callout-tipp" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.8V16h5v-.3c0-.7.4-1.4 1-1.8A6 6 0 0 0 12 3Z"/></svg><span>Nicht unbedingt root</span></div><div class="callout-body"><p>Statt <code>root</code> kann man auch einen eigenen API-Benutzer mit minimalen Rechten anlegen. Welche Berechtigungen das Plugin braucht, steht in der Plugin-Dokumentation im Abschnitt zu den API-Berechtigungen.</p>
</div></aside>
<h3 id="optional-chap"><a class="anchor" href="#optional-chap" aria-hidden="true" tabindex="-1">#</a>Optional: CHAP</h3>
<p>Wer CHAP nutzen möchte:</p>
<ol>
<li>Im Wizard (Schritt 3) ein Portal wählen, dessen <strong>Discovery Auth Method</strong> auf <code>CHAP</code> steht.</li>
<li>Im manuellen Ablauf unter <strong>Authorized Access</strong> einen Eintrag mit Benutzer und einem 12–16 Zeichen langen Passwort anlegen.</li>
<li>In Proxmox <code>tn_chap_user</code> und <code>tn_chap_password</code> in der <code>storage.cfg</code> ergänzen.</li>
</ol>
<h2 id="teil-2-proxmox-einrichten"><a class="anchor" href="#teil-2-proxmox-einrichten" aria-hidden="true" tabindex="-1">#</a>Teil 2: Proxmox einrichten</h2>
<h3 id="plugin-installieren"><a class="anchor" href="#plugin-installieren" aria-hidden="true" tabindex="-1">#</a>Plugin installieren</h3>
<p>Das Plugin kommt über das offizielle APT-Repository. Das Installskript richtet es ein:</p>
<pre><code class="hljs language-bash">bash &lt;(curl -sSL https://raw.githubusercontent.com/truenas/truenas-proxmox-plugin/main/install.sh) --non-interactive --apt-install
</code></pre><aside class="callout callout-tipp" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.8V16h5v-.3c0-.7.4-1.4 1-1.8A6 6 0 0 0 12 3Z"/></svg><span>Tipp</span></div><div class="callout-body"><p>Wer Skripte nicht ungesehen per <code>curl | bash</code> ausführen möchte, lädt <code>install.sh</code> vorher herunter und schaut kurz rein.</p>
</div></aside>
<p>Bei einem Cluster muss das Plugin auf jedem Knoten installiert werden.</p>
<h3 id="storage-konfigurieren"><a class="anchor" href="#storage-konfigurieren" aria-hidden="true" tabindex="-1">#</a>Storage konfigurieren</h3>
<p>Die Konfiguration liegt in <code>/etc/pve/storage.cfg</code> und gilt dank Cluster-Dateisystem automatisch für alle Knoten:</p>
<pre><code class="hljs language-bash">nano /etc/pve/storage.cfg
</code></pre><p>Dort einen neuen Eintrag mit den Werten der eigenen TrueNAS-Umgebung ergänzen:</p>
<pre><code class="hljs language-ini">truenasplugin: truenas-storage
    tn_api_host 10.26.0.15
    tn_api_key &lt;DEIN-API-SCHLÜSSEL&gt;
    tn_api_insecure 1
    tn_target_iqn iqn.2005-10.org.freenas.ctl:pve
    tn_dataset pve/Proxmox-Backup
    tn_discovery_portal 10.26.0.15:3260
    content images,rootdir
    shared 1
</code></pre><p>Was die Zeilen bedeuten:</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Bedeutung</th>
</tr>
</thead>
<tbody><tr>
<td><code>tn_api_host</code></td>
<td>IP oder Hostname von TrueNAS</td>
</tr>
<tr>
<td><code>tn_api_key</code></td>
<td>der API-Schlüssel von oben</td>
</tr>
<tr>
<td><code>tn_api_insecure</code></td>
<td><code>1</code> akzeptiert selbstsignierte Zertifikate</td>
</tr>
<tr>
<td><code>tn_target_iqn</code></td>
<td>Base Name + <code>:</code> + Target-Name</td>
</tr>
<tr>
<td><code>tn_dataset</code></td>
<td>Pfad des Datasets, unter dem die ZVOLs landen</td>
</tr>
<tr>
<td><code>tn_discovery_portal</code></td>
<td>IP und Port des iSCSI-Portals</td>
</tr>
<tr>
<td><code>content</code></td>
<td><code>images</code> für VM-Disks, <code>rootdir</code> für Container</td>
</tr>
<tr>
<td><code>shared</code></td>
<td><code>1</code>, damit alle Knoten den Storage nutzen (wichtig für Live-Migration)</td>
</tr>
</tbody></table>
<h2 id="was-das-plugin-ab-jetzt-erledigt"><a class="anchor" href="#was-das-plugin-ab-jetzt-erledigt" aria-hidden="true" tabindex="-1">#</a>Was das Plugin ab jetzt erledigt</h2>
<p>Ab hier übernimmt das Plugin. Sobald eine VM ihre erste Disk auf dem neuen Storage bekommt:</p>
<ul>
<li>legt es ein ZVOL unter dem Dataset an, z. B. <code>pve/Proxmox-Backup/vm-100-disk-0</code>,</li>
<li>erstellt dafür einen iSCSI-Extent,</li>
<li>ordnet den Extent dem Target unter einer eigenen LUN zu,</li>
<li>und kümmert sich auf jedem Proxmox-Knoten um Auf- und Abbau der iSCSI-Sessions.</li>
</ul>
<p>Kein Klicken in der TrueNAS-Oberfläche mehr, keine vergessenen LUNs. Der Storage taucht in Proxmox einfach als <code>truenas-storage</code> auf und verhält sich wie jeder andere.</p>
<h2 id="fazit"><a class="anchor" href="#fazit" aria-hidden="true" tabindex="-1">#</a>Fazit</h2>
<p>Die meiste Arbeit steckt in der einmaligen iSCSI-Vorbereitung auf TrueNAS. Wer auf einen gültigen Base Name, ein Portal und erlaubte Initiators achtet, umgeht die typischen Stolpersteine. Danach ist das Plugin angenehm unsichtbar.</p>
<p>Weitere Details, Optionen und Troubleshooting stehen im <a href="https://github.com/truenas/truenas-proxmox-plugin">GitHub-Repository des Plugins</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Lokaler KI-Server mit 2× Tesla T4: von OpenClaw zu Open WebUI</title>
      <link>https://themace.de/blog/lokaler-ki-server-tesla-t4/</link>
      <guid>https://themace.de/blog/lokaler-ki-server-tesla-t4/</guid>
      <pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate>
      <description>Zwei Tesla T4, eine Debian-VM und ein Plan. Wie aus einem autonomen Agenten, der Unsinn erzählt, ein lokales ChatGPT wurde, das funktioniert.</description>
      <content:encoded><![CDATA[<p>Ich hatte zwei NVIDIA Tesla T4 übrig, eine frische VM und einen Plan: ein eigener KI-Server im Homelab. Lokale Sprachmodelle, ein autonomer Agent mit Discord-Anbindung, eine Weboberfläche für mehrere Leute. Alles ohne Cloud.</p>
<p>Dieser Beitrag erzählt, wie es wirklich gelaufen ist. Mit allen Irrwegen, denn die waren lehrreicher als das Ergebnis.</p>
<h2 id="die-ausgangslage"><a class="anchor" href="#die-ausgangslage" aria-hidden="true" tabindex="-1">#</a>Die Ausgangslage</h2>
<table>
<thead>
<tr>
<th>Komponente</th>
<th>Details</th>
</tr>
</thead>
<tbody><tr>
<td>Plattform</td>
<td>VM „AI-01“, Debian 13 „trixie“, Kernel 6.12</td>
</tr>
<tr>
<td>CPU</td>
<td>32 vCPUs (Host: Intel Xeon E5-2695, 2,1 GHz)</td>
</tr>
<tr>
<td>RAM</td>
<td>ca. 100 GB (der Host hätte noch rund 400 GB mehr)</td>
</tr>
<tr>
<td>Festplatte</td>
<td>ca. 985 GB</td>
</tr>
<tr>
<td>GPUs</td>
<td><strong>2× NVIDIA Tesla T4</strong> mit je 16 GB VRAM, per PCI-Passthrough</td>
</tr>
<tr>
<td>Secure Boot</td>
<td>aus, die NVIDIA-Module müssen also nicht signiert werden</td>
</tr>
</tbody></table>
<h2 id="gpus-zum-leben-erwecken"><a class="anchor" href="#gpus-zum-leben-erwecken" aria-hidden="true" tabindex="-1">#</a>GPUs zum Leben erwecken</h2>
<p><code>lspci</code> zeigte beide T4, aber <code>nvidia-smi</code> gab es nicht. Also erstmal die Treiber von Nvidia selbst herunterladen.</p>
<p>In diesem fall müssen wir noch das <code>non-free</code> im Debian Repo akivieren:
<code>/etc/apt/sources.list.d/debian.sources</code> (deb822-Format):</p>
<pre><code class="hljs language-ini">Components: main contrib non-free non-free-firmware
</code></pre><p>Danach den Treiber installieren:</p>
<pre><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> apt update
<span class="hljs-built_in">sudo</span> apt install linux-headers-amd64 nvidia-driver nvidia-smi firmware-misc-nonfree
</code></pre><p>Debian liefert Treiber <strong>550.163.01</strong> (CUDA bis 12.4). Das Kernelmodul wird per DKMS gebaut, <code>nouveau</code> automatisch gesperrt und das initramfs neu erzeugt. Danach ist ein Neustart fällig.</p>
<p>Nach dem Reboot meldete <code>nvidia-smi</code> beide Karten: 49 °C, 10–12 W im Leerlauf, je 15 GB frei.</p>
<aside class="callout callout-tipp" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.8V16h5v-.3c0-.7.4-1.4 1-1.8A6 6 0 0 0 12 3Z"/></svg><span>Secure Boot aus + non-free an</span></div><div class="callout-body"><p>Damit ist der NVIDIA-Treiber unter Debian eine Sache von fünf Minuten. Mit Secure Boot müsstest du die DKMS-Module zusätzlich signieren.</p>
</div></aside>
<h2 id="ollama-der-einfache-teil"><a class="anchor" href="#ollama-der-einfache-teil" aria-hidden="true" tabindex="-1">#</a>Ollama: der einfache Teil</h2>
<pre><code class="hljs language-bash">curl -fsSL https://ollama.com/install.sh | sh
</code></pre><p>Das war&#39;s. Ollama läuft als systemd-Dienst unter eigenem Benutzer, lauscht nur auf <code>127.0.0.1:11434</code> und erkennt beide T4 per CUDA. Ein separates CUDA-Toolkit braucht es nicht, Ollama bringt seine Bibliotheken selbst mit.</p>
<h2 id="openclaw-ein-agent-mit-persoenlichkeit"><a class="anchor" href="#openclaw-ein-agent-mit-persoenlichkeit" aria-hidden="true" tabindex="-1">#</a>OpenClaw: ein Agent mit Persönlichkeit</h2>
<p><a href="https://github.com/openclaw/openclaw">OpenClaw</a> ist ein Open-Source-Agent, der über Discord, Telegram oder WhatsApp erreichbar ist. Er kann Shell-Befehle ausführen, Dateien bearbeiten, im Web suchen und Automationen planen. Dazu gehören ein Gateway-Dienst und eine Weboberfläche.</p>
<h3 id="erst-mal-sicherheit"><a class="anchor" href="#erst-mal-sicherheit" aria-hidden="true" tabindex="-1">#</a>Erst mal Sicherheit</h3>
<p>Mein Benutzer hat passwortloses <code>sudo</code>. Ein Agent, der autonom Shell-Befehle ausführt, hätte damit indirekt Root-Rechte gehabt. Keine gute Idee. Also bekam OpenClaw einen <strong>eigenen Benutzer <code>openclaw</code> ohne sudo und ohne Zusatzgruppen</strong>. Damit sein systemd-User-Dienst auch ohne Login läuft, wird Lingering aktiviert:</p>
<pre><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> useradd -m -s /bin/bash openclaw
<span class="hljs-built_in">sudo</span> loginctl enable-linger openclaw
</code></pre><aside class="callout callout-achtung" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3h8l5 5v8l-5 5H8l-5-5V8l5-5Z"/><path d="M12 8v5M12 16h.01"/></svg><span>Achtung</span></div><div class="callout-body"><p>Autonome Agenten nie unter einem Benutzer mit sudo laufen lassen. Was der Agent darf, darf im Zweifel auch jeder, der ihm etwas unterschiebt.</p>
</div></aside>
<h3 id="installation-und-stolpersteine"><a class="anchor" href="#installation-und-stolpersteine" aria-hidden="true" tabindex="-1">#</a>Installation und Stolpersteine</h3>
<p>Debian trixie liefert ein zu altes Node.js, also kam Node 24 von NodeSource. Das Installskript von OpenClaw habe ich vorher heruntergeladen, dann als <code>openclaw</code> ausgeführt. Weil <code>/usr</code> für diesen Benutzer nicht beschreibbar ist, landete alles in <code>~/.npm-global</code>.</p>
<p>Beim Onboarding gab es ein paar Fallen (meine eigene Inkompetenz):</p>
<ul>
<li>Erst mal eine Shell öffnen und dann mich als <code>openclaw</code> anmelden: <code>sudo -iu openclaw</code>.</li>
<li>Für einen systemd-User-Dienst reicht auch das oft nicht, weil keine echte Login-Session mit D-Bus existiert. Abhilfe schafft <code>sudo machinectl shell openclaw@</code> (Paket <code>systemd-container</code>).</li>
<li>Ich habe <code>sudo machinectl …</code> natürlich erst mal <em>als</em> <code>openclaw</code> probiert. Der hat bewusst kein sudo. Also <code>exit</code> und noch mal mit meinem User:<code>mace</code>.</li>
<li>„Gateway not detected yet“: Der Gateway braucht rund 30 Sekunden zum Starten, das Onboarding gibt vorher auf. Er lief trotzdem.</li>
</ul>
<p>Als Modelle kamen <code>qwen3:30b</code> (18 GB) und <code>gemma4:e4b</code> (6,6 GB) auf die Platte. Was ich da noch nicht wusste: <code>qwen3:30b</code> ist bei Ollama die <strong>Thinking-Variante</strong>. Das wird später wichtig.</p>
<h3 id="jarvis-bekommt-einen-charakter"><a class="anchor" href="#jarvis-bekommt-einen-charakter" aria-hidden="true" tabindex="-1">#</a>Jarvis bekommt einen Charakter</h3>
<p>OpenClaw liest bei jedem Sitzungsstart Dateien aus seinem Workspace: <code>AGENTS.md</code>, <code>SOUL.md</code>, <code>IDENTITY.md</code> und <code>USER.md</code>. Mein Agent heißt Jarvis, ist ein lockerer Typ, spricht Deutsch und duzt. In <code>SOUL.md</code> steht: direkt, ehrlich, trockener Humor, „erst die Lösung, dann der Spruch“. Die Sicherheitsgrenzen bleiben: private Daten schützen und vor riskanten Aktionen fragen.</p>
<h2 id="die-weboberflaeche-und-der-basic-auth-albtraum"><a class="anchor" href="#die-weboberflaeche-und-der-basic-auth-albtraum" aria-hidden="true" tabindex="-1">#</a>Die Weboberfläche und der Basic-Auth-Albtraum</h2>
<p>Die Weboberfläche sollte im LAN erreichbar sein, später auch über NetBird, und natürlich mit Passwort.</p>
<p>Der Aufbau: Der OpenClaw-Gateway bleibt auf <code>127.0.0.1:18789</code> und ist nie direkt im Netz. Davor sitzt <strong>Caddy</strong> als Reverse Proxy mit selbst signiertem Zertifikat (<code>tls internal</code>). HTTP leitet automatisch auf HTTPS um. In OpenClaw kommen die neuen Adressen in <code>gateway.controlUi.allowedOrigins</code>, dazu <code>gateway.trustedProxies: [&quot;127.0.0.1&quot;]</code>.</p>
<p>Dann sollte noch Basic Auth davor. Klingt trivial. Die Passwortabfrage kam aber immer wieder, egal was ich eingab. Die Fehlersuche lief über das Access-Log von Caddy:</p>
<ol>
<li><strong>WebSockets und <code>/healthz</code> kamen ohne Zugangsdaten an.</strong> Chromium schickt bei WebSocket-Verbindungen kein Basic Auth mit. Also eine Ausnahme dafür.</li>
<li><strong>Anfragen <em>mit</em> <code>Authorization</code>-Header wurden trotzdem abgelehnt.</strong> Die OpenClaw-Oberfläche schickt bei API-Aufrufen <code>Authorization: Bearer &lt;Token&gt;</code>. Das ist derselbe Header wie bei Basic Auth, und ein Browser kann nur einen davon senden. Also noch eine Ausnahme für Bearer-Anfragen. Der Gateway prüft das Token ja selbst.</li>
<li><strong>Immer noch Abfragen.</strong> An der Stelle habe ich entschieden: Der Passwortschutz fliegt raus. Caddy macht nur noch HTTPS, geschützt wird über das Gateway-Token und die Gerätefreigabe in OpenClaw.</li>
</ol>
<aside class="callout callout-warnung" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2 20h20L12 3Z"/><path d="M12 10v4M12 17h.01"/></svg><span>Basic Auth vor einer modernen Web-App</span></div><div class="callout-body"><p>WebSockets, eigene Bearer-Tokens und Service Worker vertragen sich nicht mit Basic Auth. Sauberer wäre ein Login-Portal mit Cookie, etwa Authelia. Das braucht aber einen richtigen Domainnamen.</p>
</div></aside>
<h2 id="warum-erzaehlt-der-so-viel-muell"><a class="anchor" href="#warum-erzaehlt-der-so-viel-muell" aria-hidden="true" tabindex="-1">#</a>„Warum erzählt der so viel Müll?“</h2>
<p>Jetzt kam der eigentliche Test. Ich fragte Jarvis: <em>„Was macht die Thomas-Krenn.AG?“</em></p>
<p>Richtig wäre: ein Serverhersteller aus Freyung im Bayerischen Wald, bekannt für individuell konfigurierte Server, einen Online-Konfigurator und das Thomas-Krenn-Wiki.</p>
<p>Jarvis wusste es anders. Erst sei das wohl ein Tippfehler und Thomas Krenn ein IT-Sicherheitsexperte und Linux-Blogger. Auf „du liegst falsch“ war es plötzlich ein Serverhersteller, die „Thomas Krenn GmbH“. Auf „ist eine AG“ stimmte er sofort zu und erklärte mir den Unterschied zwischen AG und GmbH. Später kamen Rechenzentren in München, Frankfurt und Wien dazu, eine Gründung 1985 in Ludwigshafen und Pentests für Banken.</p>
<p>Klassisches Halluzinieren, garniert mit unkritischem Zustimmen. Also auf Fehlersuche.</p>
<h3 id="ursache-1-das-kontextfenster-war-zu-klein"><a class="anchor" href="#ursache-1-das-kontextfenster-war-zu-klein" aria-hidden="true" tabindex="-1">#</a>Ursache 1: Das Kontextfenster war zu klein</h3>
<p>Im Gateway-Log stand:</p>
<pre><code class="hljs">route=compact_only estimatedPromptTokens=26176 promptBudgetBeforeReserve=24576
</code></pre><p>OpenClaw hatte das Modell auf <strong>32 768 Tokens</strong> begrenzt, obwohl Qwen3 über 260 000 kann. Allein Systemprompt und Tools brauchten rund 26 000. Jede Anfrage wurde also zusammengestrichen, bevor das Modell überhaupt meine Frage sah.</p>
<p>Der Fix: <code>contextTokens</code> für das Modell auf 65.536 hochsetzen und Ollama per systemd-Override etwas tunen:</p>
<pre><code class="hljs language-ini"><span class="hljs-comment"># /etc/systemd/system/ollama.service.d/override.conf</span>
<span class="hljs-section">[Service]</span>
<span class="hljs-attr">Environment</span>=<span class="hljs-string">&quot;OLLAMA_FLASH_ATTENTION=1&quot;</span>
<span class="hljs-attr">Environment</span>=<span class="hljs-string">&quot;OLLAMA_KEEP_ALIVE=30m&quot;</span>
</code></pre><p>Ergebnis: 26 GB VRAM über beide GPUs verteilt, 100 % GPU, rund 75 Tokens pro Sekunde.</p>
<h3 id="ursache-2-ein-riesiger-systemprompt"><a class="anchor" href="#ursache-2-ein-riesiger-systemprompt" aria-hidden="true" tabindex="-1">#</a>Ursache 2: Ein riesiger Systemprompt</h3>
<p>Mit <code>/context detail</code> zeigt OpenClaw, woraus der Prompt besteht:</p>
<table>
<thead>
<tr>
<th>Anteil</th>
<th align="right">Tokens</th>
</tr>
</thead>
<tbody><tr>
<td>Tool-Schemas (54 Tools!)</td>
<td align="right">~19 300</td>
</tr>
<tr>
<td>OpenClaw-Systemprompt</td>
<td align="right">~4 400</td>
</tr>
<tr>
<td>Workspace-Dateien</td>
<td align="right">~3 250</td>
</tr>
<tr>
<td>Skills-Liste (20 Skills, nur Einträge)</td>
<td align="right">~1 500</td>
</tr>
<tr>
<td><strong>Summe</strong></td>
<td align="right"><strong>~28 300</strong></td>
</tr>
</tbody></table>
<p>Dabei fiel etwas Absurdes auf: <code>IDENTITY.md</code> war von 215 auf 4 415 Zeichen gewachsen. Ich hatte über die Weboberfläche ein Avatarbild gesetzt, und die UI hatte das Bild <strong>als Base64 direkt in die Datei geschrieben</strong>. Das waren rund 1 100 Tokens Bilddaten in jedem einzelnen Prompt. Das Bild liegt jetzt als Datei daneben, in <code>IDENTITY.md</code> steht nur noch der Pfad.</p>
<p>Danach habe ich per <code>tools.deny</code> alles entfernt, was auf einem Server ohne Bildschirm nichts bringt: Desktop-, Browser-, Canvas- und Sprach-Tools, dazu die Sub-Agenten (Ollama verarbeitet ohnehin nur eine Anfrage gleichzeitig). Ergebnis: <strong>~18 700 statt ~28 300 Tokens</strong>, gut ein Drittel weniger.</p>
<aside class="callout callout-hinweis" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg><span>Tools werden nicht nachgeladen</span></div><div class="callout-body"><p>Ich war zuerst davon ausgegangen, dass OpenClaw Tools erst bei Bedarf nachlädt. Laut <code>/context</code> stimmt das nicht, alle Schemas gehen bei jeder Anfrage mit. Lazy geladen werden nur die Skills.</p>
</div></aside>
<h3 id="mehr-ram-bringt-keinen-groesseren-kontext"><a class="anchor" href="#mehr-ram-bringt-keinen-groesseren-kontext" aria-hidden="true" tabindex="-1">#</a>Mehr RAM bringt keinen größeren Kontext</h3>
<p>Mein erster Gedanke: Der Host hat noch 400 GB RAM frei, die gebe ich der VM einfach. Leider hilft das kaum. Passt der Kontext nicht mehr in den VRAM, lagert Ollama Teile auf die CPU aus. Dann bricht die Geschwindigkeit ein, von 75 auf geschätzt 10–25 Tokens pro Sekunde, und das Einlesen langer Prompts wird noch viel zäher.</p>
<p>Der bessere Weg ist, den <strong>KV-Cache zu quantisieren</strong>. Mit <code>OLLAMA_KV_CACHE_TYPE=q8_0</code> (setzt Flash Attention voraus) braucht der Cache nur noch rund die Hälfte:</p>
<table>
<thead>
<tr>
<th>Kontext</th>
<th align="right">KV-Cache f16</th>
<th align="right">KV-Cache q8</th>
<th>Passt auf die GPUs?</th>
</tr>
</thead>
<tbody><tr>
<td>64k</td>
<td align="right">~6 GB</td>
<td align="right">~3 GB</td>
<td>ja</td>
</tr>
<tr>
<td>128k</td>
<td align="right">~12 GB</td>
<td align="right">~6,4 GB</td>
<td><strong>ja (gewählt)</strong></td>
</tr>
<tr>
<td>256k</td>
<td align="right">~24 GB</td>
<td align="right">~12 GB</td>
<td>nein  (wird Sportlich)</td>
</tr>
</tbody></table>
<p>Mit <strong>128k Kontext</strong> läuft alles weiter komplett auf den GPUs: 26 GB VRAM, rund 63 Tokens pro Sekunde.</p>
<h3 id="ursache-3-das-thinking-modell-verdenkt-den-tool-aufruf"><a class="anchor" href="#ursache-3-das-thinking-modell-verdenkt-den-tool-aufruf" aria-hidden="true" tabindex="-1">#</a>Ursache 3: Das Thinking-Modell „verdenkt“ den Tool-Aufruf</h3>
<p>Die Antworten waren trotzdem Unsinn. Also ging es in die SQLite-Datenbank von OpenClaw, wo die Verläufe der Sitzungen liegen. Im Denkprozess des Modells stand sinngemäß: <em>„Ich sollte jetzt <code>web_search</code> mit ‚Thomas-Krenn.AG‘ aufrufen.“</em> Danach beendete es den Zug, <strong>ohne den Aufruf tatsächlich zu senden</strong> (<code>non_deliverable_terminal_turn</code>). OpenClaw erzwang dann eine Antwort ohne Tools, und das Modell fing an zu fantasieren.</p>
<p>Die Lösung war der Wechsel auf <code>qwen3:30b-a3b-instruct-2507-q4_K_M</code>: gleiche Größe, kein Thinking. Interessant: In einem direkten Test gegen die Ollama-API mit einem einzigen <code>web_search</code>-Tool lieferten beide Varianten 3 von 3 korrekte Aufrufe. Das Problem trat nur im großen OpenClaw-Kontext auf.</p>
<h3 id="ursache-4-falsche-tools-falscher-kanal"><a class="anchor" href="#ursache-4-falsche-tools-falscher-kanal" aria-hidden="true" tabindex="-1">#</a>Ursache 4: Falsche Tools, falscher Kanal</h3>
<p>Das Instruct-Modell antwortete erst mal ganz ohne Suche. Also kam eine Regel in <code>SOUL.md</code>: <strong>Fakten: erst suchen, dann reden.</strong> Bei Firmen, Personen, Produkten, Preisen und allem Aktuellen immer zuerst <code>web_search</code>, Quelle nennen, bei Korrekturen erst nachprüfen.</p>
<p>Danach wurde es kurios:</p>
<ul>
<li>Jarvis rief <code>tool_search</code> mit „Thomas-Krenn AG company overview“ auf. Ergebnis: eine leere Liste. <code>tool_search</code> durchsucht aber nicht das Web, sondern <strong>OpenClaws eigenen Werkzeugkatalog</strong>. Das echte <code>web_search</code> war hinter der Tool-Suche versteckt.</li>
<li>Mit abgeschalteter Tool-Suche griff er zum Tool <code>intent</code>, das Daueraufträge anlegt. Das scheiterte mit „authenticated sender identity is unavailable“.</li>
<li>Fragte ich ihn nach seinen Tools, nannte er nur Nachrichten- und Sitzungs-Tools. <code>web_search</code>, <code>read</code> und <code>exec</code> fehlten komplett.</li>
<li>Am Ende stellte er Rückfragen, brauchte über drei Minuten und <strong>schickte die Antwort auf eine Frage aus der Weboberfläche nach Discord</strong>.</li>
</ul>
<p>Und dann gab es noch einen peinlichen Zwischenfall. Ich hatte Jarvis zum Testen über die Kommandozeile (<code>openclaw agent</code>) angesprochen. Dabei gab es keinen festen Antwortkanal, also nutzte Jarvis sein <code>message</code>-Tool und <strong>postete die Antworten schon wieder in einen zufälligen Discord-Channel</strong>. Danach war mit CLI-Tests Schluss.</p>
<h2 id="die-einsicht-falsches-werkzeug-fuer-lokale-modelle"><a class="anchor" href="#die-einsicht-falsches-werkzeug-fuer-lokale-modelle" aria-hidden="true" tabindex="-1">#</a>Die Einsicht: falsches Werkzeug für lokale Modelle</h2>
<p>OpenClaw ist für starke Cloud-Modelle wie Claude oder GPT gebaut: ein riesiger Systemprompt, Dutzende Tools, Kanäle, Automationen, Tool-Suche, Regeln für Absender und Besitzer. Ein lokales 30B-Modell ist mit dieser Komplexität überfordert. Und die Tool-Logik war selbst für mich kaum zu durchschauen. Meine Vermutung, nicht bestätigt: Die Sitzung aus der Weboberfläche wurde nicht als Besitzer erkannt, weil nur meine Discord-ID hinterlegt war.</p>
<p>Am Ende standen drei Optionen auf dem Tisch:</p>
<ul>
<li><strong>A:</strong> Open WebUI als lokales „ChatGPT“ mit Websuche und echten Benutzerkonten</li>
<li><strong>B:</strong> OpenClaw mit einem Cloud-Modell. Das kostet Geld, und die Daten verlassen das Haus.</li>
<li><strong>C:</strong> Weiter debuggen, mit ungewissem Ausgang</li>
</ul>
<p>Ich habe mich für <strong>A</strong> entschieden und OpenClaw komplett vom Server entfernt: Dienst gestoppt, Lingering aus, Benutzer samt Home gelöscht, Logs weg, Node.js, das NodeSource-Repo und <code>systemd-container</code> deinstalliert. Eine abschließende Suche fand keine Reste.</p>
<aside class="callout callout-wichtig" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v12H8l-4 4V4Z"/><path d="M12 7v4M12 13.5h.01"/></svg><span>Wichtig</span></div><div class="callout-body"><p>Wer einen Discord-Bot wieder abbaut, sollte auch die Bot-Anwendung im Discord Developer Portal löschen oder zumindest das Token zurücksetzen.</p>
</div></aside>
<h2 id="open-webui-juhuuuuu"><a class="anchor" href="#open-webui-juhuuuuu" aria-hidden="true" tabindex="-1">#</a>Open WebUI: „Juhuuuuu“</h2>
<p>Open WebUI läuft als Docker-Container. Docker kommt direkt aus Debian (<code>docker.io</code>):</p>
<pre><code class="hljs language-bash"><span class="hljs-built_in">sudo</span> apt install docker.io

<span class="hljs-built_in">sudo</span> docker run -d --name open-webui \
  --restart unless-stopped \
  --network host \
  -v open-webui:/app/backend/data \
  -e PORT=8080 \
  -e HOST=127.0.0.1 \
  -e OLLAMA_BASE_URL=http://127.0.0.1:11434 \
  -e WEBUI_SECRET_KEY=&lt;ein-langer-zufälliger-wert&gt; \
  -e WEBUI_NAME=AI-01 \
  -e DEFAULT_USER_ROLE=pending \
  -e ENABLE_WEB_SEARCH=<span class="hljs-literal">true</span> \
  -e WEB_SEARCH_ENGINE=duckduckgo \
  -e WEB_SEARCH_RESULT_COUNT=5 \
  -e ANONYMIZED_TELEMETRY=<span class="hljs-literal">false</span> \
  ghcr.io/open-webui/open-webui:main
</code></pre><p>Die wichtigsten Punkte:</p>
<ul>
<li>Der Container lauscht nur auf <code>127.0.0.1:8080</code> und ist von außen nicht erreichbar. Caddy zeigt jetzt dorthin, mit HTTPS und einem Upload-Limit von 200 MB.</li>
<li>Neue Benutzer können sich registrieren, landen aber im Status <strong>„pending“</strong>, bis ich sie freischalte.</li>
<li>Die Websuche läuft über DuckDuckGo, ganz ohne API-Key.</li>
</ul>
<aside class="callout callout-achtung" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3h8l5 5v8l-5 5H8l-5-5V8l5-5Z"/><path d="M12 8v5M12 16h.01"/></svg><span>Sofort registrieren</span></div><div class="callout-body"><p>Das erste Konto in Open WebUI wird automatisch Admin. Also direkt nach dem Start selbst anmelden, bevor es jemand anderes tut.</p>
</div></aside>
<p>Dann kam dieselbe Frage, mit demselben Modell auf derselben Hardware: <em>„Was macht die Thomas-Krenn.AG?“</em></p>
<p>Das Modell rief selbstständig die Websuche auf und antwortete im Kern richtig: Sitz in Freyung, individuell konfigurierte Server, Tests vor der Auslieferung, „Servers Made in Germany“. Meine Reaktion im Chat war ein schlichtes <strong>„Juhuuuuu“</strong>.</p>
<p>Das ist für mich die wichtigste Erkenntnis aus dem ganzen Projekt: <strong>Das Modell war nie das Problem.</strong> Mit einem schlanken Frontend, das sich auf Chat und Websuche konzentriert, funktioniert es. Mit einem überladenen Agenten-Framework nicht.</p>
<h2 id="nebenschauplaetze"><a class="anchor" href="#nebenschauplaetze" aria-hidden="true" tabindex="-1">#</a>Nebenschauplätze</h2>
<h3 id="programmieren-mit-dem-eigenen-modell"><a class="anchor" href="#programmieren-mit-dem-eigenen-modell" aria-hidden="true" tabindex="-1">#</a>Programmieren mit dem eigenen Modell</h3>
<p>Cursor erlaubt zwar eine eigene OpenAI-kompatible Adresse, schickt die Anfragen aber über die eigenen Server. Eine Adresse im LAN ist von dort nicht erreichbar, man müsste den KI-Server öffentlich ins Internet stellen. Das will ich nicht.</p>
<p>Besser passt <strong>VS Code mit der Erweiterung <a href="https://www.continue.dev">Continue</a></strong> (oder Cline). Die spricht direkt vom PC aus die OpenAI-kompatible API von Open WebUI unter <code>https://&lt;server&gt;/api</code> an, mit einem persönlichen API-Key. Die Keys muss der Admin in Open WebUI erst freigeben. Wegen des selbst signierten Zertifikats importiert man die Root-CA von Caddy auf dem PC oder schaltet die Zertifikatsprüfung in Continue ab.</p>
<p>Für Code ist <code>qwen3-coder:30b</code> die bessere Wahl. Es ist genauso groß und passt ebenfalls auf die beiden T4.</p>
<h2 id="der-endstand"><a class="anchor" href="#der-endstand" aria-hidden="true" tabindex="-1">#</a>Der Endstand</h2>
<table>
<thead>
<tr>
<th>Dienst</th>
<th>Details</th>
</tr>
</thead>
<tbody><tr>
<td>NVIDIA</td>
<td>Treiber 550.163.01, CUDA 12.4, DKMS</td>
</tr>
<tr>
<td>Ollama</td>
<td>systemd, nur lokal, Flash Attention, KV-Cache q8_0, Keep-Alive 30 min</td>
</tr>
<tr>
<td>Hauptmodell</td>
<td><code>qwen3:30b-a3b-instruct-2507-q4_K_M</code> mit 128k Kontext</td>
</tr>
<tr>
<td>Open WebUI</td>
<td>Docker, nur lokal, Konten mit Freischaltung, Websuche über DuckDuckGo</td>
</tr>
<tr>
<td>Caddy</td>
<td>HTTPS mit interner CA, Reverse Proxy auf Open WebUI</td>
</tr>
<tr>
<td>OpenClaw</td>
<td>vollständig entfernt</td>
</tr>
</tbody></table>
<p>Auf der Liste steht noch: NetBird auf der VM einrichten, die Registrierung in Open WebUI eventuell ganz abschalten, <code>qwen3-coder</code> mit Continue ausprobieren und die ungenutzten Modelle löschen. Das sind rund 25 GB.</p>
<h2 id="was-ich-gelernt-habe"><a class="anchor" href="#was-ich-gelernt-habe" aria-hidden="true" tabindex="-1">#</a>Was ich gelernt habe</h2>
<ol>
<li><strong>Agenten nie mit sudo:</strong> Ein autonomer Agent bekommt einen eigenen Benutzer ohne Rechte.</li>
<li><strong>Basic Auth und moderne Web-Apps</strong> vertragen sich nicht.</li>
<li><strong>Kontextgröße prüfen:</strong> Frameworks setzen oft vorsichtige Standardwerte. Hier waren es 32k, und das Modell sah nur einen Bruchteil seiner Anweisungen.</li>
<li><strong>Mehr RAM heißt nicht mehr Kontext.</strong> Ein quantisierter KV-Cache (q8_0) verdoppelt den Kontext im selben VRAM, fast ohne Verlust.</li>
<li><strong>Base64-Bilder in Prompt-Dateien</strong> fressen still und leise Tokens.</li>
<li><strong>Thinking-Modelle</strong> können Tool-Aufrufe „verdenken“. Für Tools lieber die Instruct-Variante nehmen.</li>
<li><strong>Lokale Modelle und überladene Frameworks</strong> ergeben Halluzinationen. Weniger Tools und eine klare Oberfläche bringen bessere Ergebnisse.</li>
<li><strong>Logs lesen statt raten:</strong> Die echten Ursachen standen im Gateway-Log und in der Sitzungsdatenbank, nicht in der Doku.</li>
<li><strong>Manchmal ist das Werkzeug falsch, nicht die Konfiguration.</strong> Open WebUI lief auf Anhieb.</li>
</ol>
<p>Unterm Strich habe ich jetzt ein eigenes, lokales ChatGPT für mich und ein paar Leute, das weiß, wann es nachschauen muss. Und Jarvis? Der ist erst mal in Rente. Vielleicht kommt er wieder, wenn lokale Modelle groß genug für ihn sind.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Wie schwer kann es sein, eine Versionsnummer in den Footer zu schreiben?</title>
      <link>https://themace.de/blog/versionsnummer-im-footer/</link>
      <guid>https://themace.de/blog/versionsnummer-im-footer/</guid>
      <pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate>
      <description>Ein Mini-Feature, drei Commits und eine Lektion über stille Fallbacks.</description>
      <content:encoded><![CDATA[<p>Manche Features sind so klein, dass man sie gar nicht plant. Unten im Footer sollte neben dem Copyright einfach die aktuelle Version der Seite stehen. Eine Zeile Template, ein bisschen Git, fertig. Dachte ich.</p>
<h2 id="versuch-1-git-describe"><a class="anchor" href="#versuch-1-git-describe" aria-hidden="true" tabindex="-1">#</a>Versuch 1: <code>git describe</code></h2>
<p>Die Idee war simpel. Ich tagge Releases sowieso mit <code>v1.0.x</code>, also kann der Build die Version direkt aus Git holen:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">siteVersion</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-title function_">execFileSync</span>(<span class="hljs-string">&#x27;git&#x27;</span>, [<span class="hljs-string">&#x27;describe&#x27;</span>, <span class="hljs-string">&#x27;--tags&#x27;</span>, <span class="hljs-string">&#x27;--always&#x27;</span>], { <span class="hljs-attr">cwd</span>: <span class="hljs-variable constant_">ROOT</span> })
      .<span class="hljs-title function_">toString</span>().<span class="hljs-title function_">trim</span>();
  } <span class="hljs-keyword">catch</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">`v<span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.parse(fs.readFileSync(<span class="hljs-string">&#x27;package.json&#x27;</span>, <span class="hljs-string">&#x27;utf8&#x27;</span>)).version}</span>`</span>;
  }
}
</code></pre><p><code>git describe --tags</code> ist dafür praktisch: Steht der Build genau auf einem Tag, kommt <code>v1.0.4</code> raus. Gibt es danach noch Commits, wird daraus etwas wie <code>v1.0.4-2-gabc1234</code>, also „zwei Commits nach v1.0.4, aktueller Stand abc1234“. Für den Notfall gibt es noch einen Fallback auf die Version aus der <code>package.json</code>.</p>
<p>Lokal sah das super aus. Commit, Push, fertig.</p>
<h2 id="live-v1-0-0"><a class="anchor" href="#live-v1-0-0" aria-hidden="true" tabindex="-1">#</a>Live: v1.0.0</h2>
<p>Auf der echten Seite stand im Footer: <strong>v1.0.0</strong>. Das war die Version, die seit dem allerersten Commit unverändert in der <code>package.json</code> stand.</p>
<p>Der Grund: Meine Seite wird nicht lokal gebaut, sondern auf dem Server. Ein <code>git push</code> nach GitLab löst einen Webhook aus, Plesk holt sich den Stand und baut die Seite. Nur legt Plesk die Dateien in <code>httpdocs/</code> ab, <strong>ohne <code>.git</code>-Verzeichnis</strong>. <code>git describe</code> hatte dort also gar kein Repository, ist fehlgeschlagen und der Fallback hat leise übernommen.</p>
<aside class="callout callout-hinweis" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg><span>Hinweis</span></div><div class="callout-body"><p>Der Fallback hat genau das getan, wofür er da war.</p>
</div></aside>
<h2 id="versuch-2-dann-eben-package-json"><a class="anchor" href="#versuch-2-dann-eben-package-json" aria-hidden="true" tabindex="-1">#</a>Versuch 2: Dann eben <code>package.json</code></h2>
<p>Einige Minuten später der nächste Commit: Weg mit Git, die Version kommt ab jetzt nur noch aus der <code>package.json</code>. Neue Releases mache ich mit <code>npm version</code>:</p>
<pre><code class="hljs language-bash">npm version patch                   <span class="hljs-comment"># 1.0.4 → 1.0.5, mit Commit und Tag</span>
git push --follow-tags origin main
</code></pre><p><code>npm version</code> erhöht die Nummer, committet und setzt den passenden Git-Tag in einem Schritt. Das funktioniert und ist robust. Aber es fühlte sich wie ein Rückschritt an. Zwischenstände wie <code>v1.0.4-2-gabc1234</code> sind weg, und vergesse ich das Release, zeigt der Footer wochenlang eine veraltete Nummer.</p>
<h2 id="versuch-3-das-repo-suchen"><a class="anchor" href="#versuch-3-das-repo-suchen" aria-hidden="true" tabindex="-1">#</a>Versuch 3: Das Repo suchen</h2>
<p>Also nochmal genauer hingeschaut. Plesk hat das Repository ja irgendwo, sonst könnte es nicht pullen. Gefunden! Es liegt als <strong>Bare-Repo</strong> unter <code>~/git/</code>, also nur die Git-Daten ohne ausgecheckte Dateien. Und <code>git describe</code> braucht genau das, keine Arbeitskopie.</p>
<p>Mit <code>--git-dir</code> kann man Git sagen, wo das Repository liegt:</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> gitDir <span class="hljs-keyword">of</span> [<span class="hljs-literal">undefined</span>, ...<span class="hljs-title function_">pleskRepos</span>()]) {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> v = <span class="hljs-title function_">describe</span>(gitDir);
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">version</span>: v, <span class="hljs-attr">source</span>: gitDir ? <span class="hljs-string">`git (<span class="hljs-subst">${gitDir}</span>)`</span> : <span class="hljs-string">&#x27;git&#x27;</span> };
  } <span class="hljs-keyword">catch</span> { <span class="hljs-comment">/* nächste Quelle */</span> }
}
<span class="hljs-keyword">return</span> { <span class="hljs-attr">version</span>: pkg, <span class="hljs-attr">source</span>: <span class="hljs-string">&#x27;package.json&#x27;</span> };
</code></pre><p>Zuerst das Projektverzeichnis selbst (lokal), dann alle <code>*.git</code>-Ordner unter <code>~/git/</code>, wobei Repos mit „themace“ im Namen nach vorne sortiert werden. Erst wenn nichts klappt, kommt die <code>package.json</code>.</p>
<h3 id="bonus-stolperstein-dubious-ownership"><a class="anchor" href="#bonus-stolperstein-dubious-ownership" aria-hidden="true" tabindex="-1">#</a>Bonus-Stolperstein: „dubious ownership“</h3>
<p>Seit einer Weile weigert sich Git, in Repositories zu arbeiten, die einem anderen Benutzer gehören. Das ist ein Sicherheitsfeature gegen manipulierte Repos auf geteilten Systemen. Auf dem Server gehört das Bare-Repo aber nicht unbedingt dem Benutzer, der den Build ausführt. Die Lösung für diesen einen Aufruf:</p>
<pre><code class="hljs language-bash">git -c safe.directory=* --git-dir ~/git/themace-de.git describe --tags --always
</code></pre><aside class="callout callout-warnung" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2 20h20L12 3Z"/><path d="M12 10v4M12 17h.01"/></svg><span>Warnung</span></div><div class="callout-body"><p><code>safe.directory=*</code> schaltet die Prüfung komplett ab. Ich setze das bewusst nur per <code>-c</code> für genau diesen einen Befehl und nicht global in der Git-Konfiguration.</p>
</div></aside>
<h2 id="die-eigentliche-lektion-fallbacks-muessen-laut-sein"><a class="anchor" href="#die-eigentliche-lektion-fallbacks-muessen-laut-sein" aria-hidden="true" tabindex="-1">#</a>Die eigentliche Lektion: Fallbacks müssen laut sein</h2>
<p>Das Spannendste an der Geschichte ist nicht Git und nicht Plesk. Mein erster Versuch war eigentlich richtig. Er hatte nur einen Fallback, der <strong>still</strong> war. Die Seite lief, nichts ist abgestürzt, und trotzdem war das Ergebnis falsch.</p>
<p>Deshalb schreibt der Build jetzt ins Deploy-Log, woher die Version kommt:</p>
<pre><code class="hljs language-text">→ Version v1.0.4 (aus git (/var/www/vhosts/.../git/themace-de.git))
</code></pre><p>Steht da <code>(aus package.json)</code>, weiß ich sofort, dass etwas nicht stimmt. Eine Zeile Log hätte mir zwei Commits erspart.</p>
<h2 id="fazit"><a class="anchor" href="#fazit" aria-hidden="true" tabindex="-1">#</a>Fazit</h2>
<p>Drei Commits für eine Versionsnummer im Footer. Was ich mitnehme:</p>
<ul>
<li><strong>Lokal ist nicht gleich Server.</strong> Was auf meinem Rechner ein Git-Repo ist, ist auf dem Server nur ein Ordner mit Dateien.</li>
<li><strong>Bare-Repos reichen für Git-Abfragen.</strong> <code>--git-dir</code> ist dein Freund.</li>
<li><strong>Ein Fallback ohne Log ist ein versteckter Bug.</strong> Wenn der Plan B greift, will ich das wissen.</li>
</ul>
<p>Und ja: Die Versionsnummer steht jetzt unten im Footer. Schau ruhig mal nach. 👇</p>
]]></content:encoded>
    </item>
    <item>
      <title>Eine Website ohne Cookie-Banner</title>
      <link>https://themace.de/blog/website-ohne-cookie-banner/</link>
      <guid>https://themace.de/blog/website-ohne-cookie-banner/</guid>
      <pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate>
      <description>Kein Tracking, keine Google Fonts, keine Drittanbieter. Was ich dafür auf TheMace.de getan habe und warum das einfacher ist, als man denkt.</description>
      <content:encoded><![CDATA[<p>Wenn du diese Seite öffnest, springt dir kein Banner ins Gesicht. Kein „Wir respektieren deine Privatsphäre“ mit drei Knöpfen, von denen nur einer leuchtet. Das ist kein Versehen, sondern Absicht. Hier erzähle ich, was dafür nötig war.</p>
<aside class="callout callout-hinweis" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/></svg><span>Hinweis</span></div><div class="callout-body"><p>Ich bin kein Anwalt. Das hier ist meine Sicht als jemand, der eine kleine private Seite betreibt, und keine Rechtsberatung.</p>
</div></aside>
<h2 id="warum-gibt-es-ueberhaupt-cookie-banner"><a class="anchor" href="#warum-gibt-es-ueberhaupt-cookie-banner" aria-hidden="true" tabindex="-1">#</a>Warum gibt es überhaupt Cookie-Banner?</h2>
<p>Kurz gesagt: Eine Website darf ohne deine Einwilligung nichts auf deinem Gerät speichern oder auslesen, <strong>außer es ist für die Funktion, die du willst, unbedingt nötig</strong>. In Deutschland steht das in § 25 TDDDG. Sobald Daten an Dritte gehen, kommt dazu noch die DSGVO.</p>
<p>Das heißt im Umkehrschluss: Ein Banner braucht nur, wer Dinge tut, die eine Einwilligung brauchen. Tracking, Werbung, eingebettete Inhalte von fremden Servern. Wer das alles weglässt, braucht auch kein Banner.</p>
<p>Klingt banal. Die Arbeit steckt im Detail, denn viele Seiten laden Drittanbieter, ohne dass es dem Betreiber bewusst ist.</p>
<h2 id="1-kein-tracking-keine-analyse"><a class="anchor" href="#1-kein-tracking-keine-analyse" aria-hidden="true" tabindex="-1">#</a>1. Kein Tracking, keine Analyse</h2>
<p>Der einfachste Schritt: Ich habe kein Google Analytics, kein Matomo, kein Plausible eingebaut. Ich weiß ehrlich gesagt nicht, wie viele Leute hier mitlesen. Und das ist okay.</p>
<p>Die Server-Logs gibt es trotzdem, weil ein Webserver ohne sie nicht vernünftig betrieben werden kann. Sie werden aber nicht ausgewertet und nach spätestens sieben Tagen gelöscht.</p>
<p>Es kann natürlich sein, dass ich mir irgendwann denke: „Ja komm, ich will schon wissen, wie viele hier mitlesen.“ Dann baue ich vielleicht Matomo oder etwas Ähnliches ein. Und dann bräuchte es wahrscheinlich auch wieder ein Banner.</p>
<h2 id="2-schriften-selbst-hosten"><a class="anchor" href="#2-schriften-selbst-hosten" aria-hidden="true" tabindex="-1">#</a>2. Schriften selbst hosten</h2>
<p>Das ist der Klassiker. Google Fonts per <code>&lt;link&gt;</code> einzubinden ist bequem, aber dabei baut der Browser eine Verbindung zu Google auf und überträgt deine IP-Adresse. Genau das hat 2022 eine ganze Abmahnwelle ausgelöst.</p>
<p>Auf TheMace.de liegen alle Schriften auf dem eigenen Server. Ein kleines Skript im Build (<code>lib/fonts.js</code>) holt sich Archivo, Inter, JetBrains Mono und Fraunces aus den <a href="https://fontsource.org">Fontsource</a>-Paketen von npm, kopiert die Dateien nach <code>dist/fonts/</code> und erzeugt eine passende <code>fonts.css</code>.</p>
<p>Dabei gleich ein paar Optimierungen:</p>
<ul>
<li><strong>Nur Latin und Latin-Extended.</strong> Kyrillisch, Griechisch und Vietnamesisch brauche ich hier nicht, also werden sie gar nicht erst ausgeliefert.</li>
<li><strong>Variable Fonts.</strong> Eine Datei pro Schrift statt einer pro Schriftschnitt.</li>
<li><strong>Preload</strong> für die beiden Schriften, die sofort sichtbar sind, damit nichts flackert.</li>
</ul>
<aside class="callout callout-tipp" role="note"><div class="callout-title"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.4 1 1.1 1 1.8V16h5v-.3c0-.7.4-1.4 1-1.8A6 6 0 0 0 12 3Z"/></svg><span>Tipp</span></div><div class="callout-body"><p>Wer kein eigenes Build-Skript hat: Bei <a href="https://fontsource.org">Fontsource</a> oder dem <a href="https://gwfh.mranftl.com/fonts">google-webfonts-helper</a> kann man Schriften als Dateien herunterladen und selbst hosten.</p>
</div></aside>
<h2 id="3-keine-eingebetteten-inhalte"><a class="anchor" href="#3-keine-eingebetteten-inhalte" aria-hidden="true" tabindex="-1">#</a>3. Keine eingebetteten Inhalte</h2>
<p>Kein YouTube-Video, keine Twitter-Timeline, kein Discord-Widget, keine Karten von Google Maps. Jedes dieser Embeds lädt Code von fremden Servern und braucht damit eine Einwilligung.</p>
<p>Stattdessen gibt es ganz normale Links. Auf GitHub, Discord und Co. landest du nur, wenn du selbst klickst. Erst ab dann gelten deren Regeln.</p>
<h2 id="4-alles-vom-eigenen-server"><a class="anchor" href="#4-alles-vom-eigenen-server" aria-hidden="true" tabindex="-1">#</a>4. Alles vom eigenen Server</h2>
<p>Auch sonst kommt nichts von außen: Kein CDN für JavaScript, keine Icon-Fonts, keine externen Bilder. CSS wird im fertigen Build sogar direkt ins HTML eingebettet. Das ist gut für den Datenschutz und nebenbei auch schneller.</p>
<p>Prüfen kannst du das selbst: Öffne die Entwicklertools (F12), wechsle zum Tab <strong>Netzwerk</strong> und lade die Seite neu. Jede Anfrage sollte an <code>themace.de</code> gehen.</p>
<p><figure><picture><source type="image/webp" srcset="/img/_opt/netzwerk-tab-480.webp 480w, /img/_opt/netzwerk-tab-960.webp 960w, /img/_opt/netzwerk-tab-1440.webp 1440w, /img/_opt/netzwerk-tab-1920.webp 1920w" sizes="(max-width: 860px) 100vw, 720px"><img src="/img/_opt/netzwerk-tab-1920.webp" width="2000" height="961" alt="Netzwerk-Tab der Entwicklertools beim Aufruf von themace.de: sieben Anfragen, alle vom eigenen Server" loading="lazy" decoding="async"></picture><figcaption>Sieben Anfragen, alle an themace.de: die Seite selbst, vier Schriften, ein Skript und das Favicon.</figcaption></figure></p>
<h2 id="5-und-was-ist-mit-dem-dark-mode"><a class="anchor" href="#5-und-was-ist-mit-dem-dark-mode" aria-hidden="true" tabindex="-1">#</a>5. Und was ist mit dem Dark Mode?</h2>
<p>Eine Ausnahme gibt es: Wenn du oben zwischen hell und dunkel wechselst, merkt sich die Seite das im <code>localStorage</code> deines Browsers.</p>
<pre><code class="hljs language-js"><span class="hljs-keyword">try</span> { <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-string">&#x27;theme&#x27;</span>, next); } <span class="hljs-keyword">catch</span> (e) {}
</code></pre><p>Das ist erlaubt, weil es genau die Funktion ist, die du gerade angeklickt hast. Ohne Speichern würde die Seite deine Wahl beim nächsten Aufruf vergessen. Der Wert bleibt auf deinem Gerät und wird nie an den Server geschickt. Gespeichert wird übrigens nur, wenn du selbst umschaltest.</p>
<h2 id="bonus-e-mail-adressen-verstecken"><a class="anchor" href="#bonus-e-mail-adressen-verstecken" aria-hidden="true" tabindex="-1">#</a>Bonus: E-Mail-Adressen verstecken</h2>
<p>Mit Cookies hat das nichts zu tun, aber es gehört zum selben Gedanken: Ich möchte dich nicht ausspionieren, und ich möchte selbst nicht zugespammt werden. Deshalb stehen E-Mail-Adressen nicht im Klartext im HTML. Ein Schritt im Build kodiert sie, und erst im Browser werden sie wieder zu einem klickbaren Link. Für Menschen ändert sich nichts, Bots, die nur den Quelltext durchsuchen, finden nichts.</p>
<h2 id="fazit"><a class="anchor" href="#fazit" aria-hidden="true" tabindex="-1">#</a>Fazit</h2>
<p>Eine Seite ohne Cookie-Banner ist kein großes Projekt. Es ist eher eine Reihe von Dingen, die man <strong>nicht</strong> macht:</p>
<ul>
<li>kein Tracking</li>
<li>keine Schriften, Skripte oder Bilder von fremden Servern</li>
<li>keine Embeds</li>
</ul>
<p>Was übrig bleibt, ist eine schnelle Seite, eine kurze <a href="/datenschutz/">Datenschutzerklärung</a> und Besucher, die einfach lesen können, ohne vorher etwas wegzuklicken.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Hallo Welt – TheMace.de ist online</title>
      <link>https://themace.de/blog/hallo-welt/</link>
      <guid>https://themace.de/blog/hallo-welt/</guid>
      <pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate>
      <description>Der erste Beitrag auf der neuen Seite. Warum es sie gibt und was hier passieren wird.</description>
      <content:encoded><![CDATA[<p>Moin! Das hier ist der allererste Beitrag auf <strong>TheMace.de</strong>. Schön, dass du da bist.</p>
<h2 id="warum-eine-eigene-seite"><a class="anchor" href="#warum-eine-eigene-seite" aria-hidden="true" tabindex="-1">#</a>Warum eine eigene Seite?</h2>
<p>Social-Media-Plattformen kommen und gehen. Eine eigene Domain bleibt. Hier gehört alles mir: das Design, die Inhalte und die Entscheidung, <em>kein</em> Tracking einzubauen.</p>
<blockquote>
<p>Schreib Dinge auf. Dein zukünftiges Ich wird es dir danken.</p>
</blockquote>
<h2 id="was-dich-hier-erwartet"><a class="anchor" href="#was-dich-hier-erwartet" aria-hidden="true" tabindex="-1">#</a>Was dich hier erwartet</h2>
<ul>
<li>Notizen zu Projekten, an denen ich gerade arbeite</li>
<li>Anleitungen zu Linux, Self-Hosting und Webentwicklung</li>
<li>Gedanken zu Design und Werkzeugen</li>
<li>Ab und zu etwas völlig anderes</li>
</ul>
<h2 id="wie-geht-es-weiter"><a class="anchor" href="#wie-geht-es-weiter" aria-hidden="true" tabindex="-1">#</a>Wie geht es weiter?</h2>
<p>Ich versuche, regelmäßig zu schreiben. Wenn du nichts verpassen willst, abonniere einfach den <a href="/feed.xml">RSS-Feed</a>.</p>
<p>Bis bald!</p>
]]></content:encoded>
    </item>
  </channel>
</rss>