<?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>Docker – teqqy</title><link>https://www.teqqy.de/en/tags/docker/</link><description>A blog about technology and more</description><language>en</language><managingEditor>teqqy</managingEditor><lastBuildDate>Sun, 26 Jul 2026 07:23:07 +0000</lastBuildDate><generator>Hugo 0.164.0</generator><atom:link href="https://www.teqqy.de/en/tags/docker/index.xml" rel="self" type="application/rss+xml"/><item><title>Teslamate Setup: Docker, Fleet API &amp; Grafana</title><link>https://www.teqqy.de/en/teslamate-setup-docker-fleet-api-grafana/</link><pubDate>Sat, 18 Jul 2026 00:00:00 +0000</pubDate><lastBuildDate>Sat, 18 Jul 2026 00:00:00 +0000</lastBuildDate><guid isPermaLink="true">https://www.teqqy.de/en/teslamate-setup-docker-fleet-api-grafana/</guid><description>Update, December 2025: This article has been revised to reflect the recent changes to the Tesla Fleet API and current Teslamate versions.
Modern vehicles now offer API interfaces that let you pull all kinds of data. Tesla was one of the first manufacturers to expose this comprehensively, and software like Teslamate grew out of that. Teslamate is a self-hosted, open-source data logger that continuously captures your Tesla&amp;rsquo;s data, stores it, and visualizes it with Grafana.</description><content:encoded>&lt;![CDATA[<p><strong>Update, December 2025:</strong> This article has been revised to reflect the recent changes to the Tesla Fleet API and current Teslamate versions.</p><p>Modern vehicles now offer API interfaces that let you pull all kinds of data. Tesla was one of the first manufacturers to expose this comprehensively, and software like Teslamate grew out of that. Teslamate is a self-hosted, open-source data logger that continuously captures your Tesla&rsquo;s data, stores it, and visualizes it with Grafana.</p><h2 id="what-changed-in-2025">What changed in 2025?<a href="#what-changed-in-2025" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>The key updates at a glance:</p><p><strong>Tesla Fleet API:</strong> Tesla replaced the unofficial &ldquo;Owner API&rdquo; with the official Fleet API. Since February 2025, a pay-per-use model applies, with a $10/month free allowance. For private users with 1-2 vehicles, that allowance is usually enough; heavier usage adds roughly $2-5/month.</p><p><strong>PostgreSQL 17:</strong> Teslamate now requires at least PostgreSQL 16.7 or 17.3. The developers recommend PostgreSQL 17.</p><p><strong>Fleet Telemetry:</strong> New is the option for the vehicle to stream data directly. This is more precise and cheaper than classic polling, but more complex to set up. Fleet Telemetry requires your own server endpoint and TLS certificates — the vehicle then streams data via WebSocket whenever values change. That saves on API costs since you no longer have to poll actively, and you get high-frequency data with minimal latency, especially while driving. For most users, though, the tried-and-tested polling approach still works fine and is considerably easier to set up.</p><h2 id="teslamate--install-it-yourself-or-pay-for-a-service">Teslamate – Install It Yourself, or Pay for a Service?<a href="#teslamate--install-it-yourself-or-pay-for-a-service" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>Teslamate isn&rsquo;t a software-as-a-service — you install and run it yourself. Whether that&rsquo;s on your home homelab or on a VPS in the cloud, e.g. with<a href="https://www.netcup.de/?ref=60644" target="_blank" rel="noopener noreferrer">Netcup<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
, both work fine. I host it on my own server on my home network.</p><p>Installation is straightforward with<a href="/wordpress-docker-performance/">Docker</a>
. You need a database (PostgreSQL) and the application container. For visualization, Teslamate relies on Grafana with pre-configured dashboards.</p><h2 id="prerequisites">Prerequisites<a href="#prerequisites" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><strong>Hardware &amp; software:</strong></p><ul><li>A server with at least 2 GB RAM (Raspberry Pi 4/5, NAS, VPS)</li><li>Docker and Docker Compose installed</li><li>A permanently active internet connection</li></ul><p><strong>Tesla account:</strong></p><ul><li>Tesla account with two-factor authentication</li><li>A Tesla Developer account (free at<a href="https://developer.tesla.com" target="_blank" rel="noopener noreferrer">developer.tesla.com<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
)</li></ul><h2 id="setting-up-a-tesla-developer-account">Setting Up a Tesla Developer Account<a href="#setting-up-a-tesla-developer-account" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>Since the switch to the Fleet API, you&rsquo;ll need a developer account:</p><ol><li>Sign in at<a href="https://developer.tesla.com" target="_blank" rel="noopener noreferrer">developer.tesla.com<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
with your Tesla account</li><li>Create a new application (e.g. &ldquo;Teslamate Homelab&rdquo;)</li><li>Provide a short description</li><li>For local use, you can use localhost as the domain</li></ol><p>Review takes a few days; for private use, approval is usually straightforward. The monthly $10 allowance is real credit — you don&rsquo;t need to add a credit card as long as you stay under it.</p><h2 id="creating-tokens">Creating Tokens<a href="#creating-tokens" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>Authentication used to work with your username and password. Today you need a &ldquo;Refresh Token&rdquo; and an &ldquo;Access Token.&rdquo; The easiest way to generate these is with dedicated apps:</p><ul><li><strong>iOS:</strong> &ldquo;Auth for Tesla&rdquo; (Apple App Store)</li><li><strong>Android:</strong> &ldquo;Tesla Tokens&rdquo; or &ldquo;Auth for Tesla&rdquo;</li><li><strong>Windows/Mac:</strong> &ldquo;Tesla Auth&rdquo; or web-based tools</li></ul><p><strong>Steps:</strong></p><ol><li>Install the app and sign in with your Tesla username</li><li>Enter your password</li><li>At the second prompt, enter your 2FA code (not your password again!)</li><li>The app shows a Refresh Token and Access Token — keep both somewhere safe</li></ol><p>The tokens stay valid for several months. Teslamate refreshes the access token automatically.</p><p><img src="/en/teslamate-setup-docker-fleet-api-grafana/images/teslamate-tesla-token_hu_b53628674ab82b23.webp" srcset="/en/teslamate-setup-docker-fleet-api-grafana/images/teslamate-tesla-token_hu_ecfb7a72ba31093e.webp 384w, /en/teslamate-setup-docker-fleet-api-grafana/images/teslamate-tesla-token_hu_b53628674ab82b23.webp 768w" sizes="(max-width: 768px) 100vw, 768px" alt="" loading="eager" fetchpriority="high" decoding="async" width="768" height="595"/><h2 id="installation-with-docker-compose">Installation with Docker Compose<a href="#installation-with-docker-compose" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>The easiest way to install Teslamate is via Docker Compose. If you don&rsquo;t already have Docker Compose installed:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># On Ubuntu/Debian</span></span></span><span style="display:flex;"><span>sudo apt install docker-compose-plugin</span></span><span style="display:flex;"><span>docker compose version</span></span></code></pre></div></div><p>Create a working directory:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir ~/teslamate<span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> cd ~/teslamate</span></span></code></pre></div></div><p>I&rsquo;ll point you to the<a href="https://docs.teslamate.org/docs/installation/docker" target="_blank" rel="noopener noreferrer">official documentation<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
here, since the configuration changes fairly regularly.</p><p><strong>Important adjustments for 2025:</strong></p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">yaml</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="yaml"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#7ee787">version</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">"3"</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#7ee787">services</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">teslamate</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">image</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">teslamate/teslamate:latest</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">always</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">environment</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">ENCRYPTION_KEY=your_secure_key</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_USER=teslamate</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_PASS=secure_password</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_NAME=teslamate</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_HOST=database</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">TZ=Europe/Berlin</span><span style="color:#6e7681"/><span style="color:#8b949e;font-style:italic"># Your timezone!</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">MQTT_DISABLE=true</span><span style="color:#6e7681"/><span style="color:#8b949e;font-style:italic"># If you don't need MQTT</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">ports</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">4000</span>:<span style="color:#a5d6ff">4000</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">cap_drop</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">all</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">database</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">image</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">postgres:17</span><span style="color:#6e7681"/><span style="color:#8b949e;font-style:italic"># At least 16.7 or 17.3!</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">always</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">environment</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">POSTGRES_USER=teslamate</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">POSTGRES_PASSWORD=secure_password</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">POSTGRES_DB=teslamate</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">teslamate-db:/var/lib/postgresql/data</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">grafana</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">image</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">teslamate/grafana:latest</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">restart</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">always</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">environment</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_USER=teslamate</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_PASS=secure_password</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_NAME=teslamate</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">DATABASE_HOST=database</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">ports</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">3000</span>:<span style="color:#a5d6ff">3000</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/>-<span style="color:#a5d6ff">teslamate-grafana-data:/var/lib/grafana</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#7ee787">volumes</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">teslamate-db</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#a5d6ff">teslamate-grafana-data:</span></span></span></code></pre></div></div><p><strong>MQTT for Home Assistant:</strong></p><p>If you want to forward the data to an MQTT server (e.g. for<a href="/tesla-ueberschussladen-mit-home-assistant/">Home Assistant</a>
), replace<code>MQTT_DISABLE=true</code> with<code>MQTT_HOST=mosquitto</code> and add a Mosquitto container.</p><p><strong>Start the containers:</strong></p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose up -d</span></span></code></pre></div></div><p>Teslamate is then reachable at<code>http://{your-server-ip}:4000</code>. On first launch, enter the tokens you generated earlier. After 1-2 minutes (waking the vehicle via the app may help), you should start seeing data.</p><h2 id="analysis-with-grafana">Analysis with Grafana<a href="#analysis-with-grafana" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>A big advantage of the pre-built Docker Compose file: Grafana is included out of the box, reachable by default at<code>http://{your-server-ip}:3000</code>.</p><p>Default login (change immediately!):</p><ul><li>Username:<code>admin</code></li><li>Password:<code>admin</code></li></ul><p><a href="/docker-monitoring-mit-prometheus-und-grafana/">Grafana</a>
already has the PostgreSQL database configured as a data source, and all dashboards are pre-imported. If you&rsquo;re running your own separate Grafana instance, you&rsquo;ll need to import the JSON files manually from the<a href="https://www.github.com/cbirkenbeul" target="_blank" rel="noopener noreferrer">GitHub<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
repository.</p><p><strong>Key dashboards:</strong></p><ul><li><strong>Overview:</strong> an overall summary of all metrics — the perfect starting point</li><li><strong>Drives:</strong> trip analysis with interactive maps, showing route, speed, and consumption</li><li><strong>Charges:</strong> detailed charging history with cost analysis per charge, and comparisons between charging stations</li><li><strong>Battery Health:</strong> long-term battery capacity trends, showing degradation over time and mileage</li><li><strong>Efficiency:</strong> consumption analysis by speed, temperature, and elevation — see what&rsquo;s actually affecting your range</li><li><strong>Vampire Drain:</strong> standby consumption while parked, useful for spotting phantom drain</li><li><strong>Locations:</strong> frequently visited places, with automatic geofencing detection</li><li><strong>Updates:</strong> a history of every software update, with timestamps</li></ul><h2 id="long-term-use-and-insights">Long-term Use and Insights<a href="#long-term-use-and-insights" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>The real value shows up after several months. Here are some concrete examples from my own usage:</p><p><strong>Battery degradation:</strong> my Model Y showed about 4% capacity loss after 50,000 km. 3-5% in the first 50,000 km is typical, and degradation slows noticeably afterward. With Teslamate you can see the exact trend and spot outliers (e.g. from BMS calibration).</p><p><strong>Seasonal differences:</strong> in winter (around 0°C), my consumption sits at roughly 20-22 kWh/100km; in summer it&rsquo;s 15-17 kWh/100km — about 30% more due to heating and a cold battery. That data is genuinely useful for route planning.</p><p><strong>Cost optimization:</strong> home charging (€0.30/kWh) vs. Supercharger (€0.52/kWh) — the difference adds up. At 15,000 km a year, I save roughly €400 annually by charging at home. Even better:<a href="/tesla-ueberschussladen-mit-home-assistant/">charging with solar surplus</a>
brings the cost down to about €0.08/kWh.</p><p><strong>Efficiency:</strong> surprisingly, my optimal speed range turned out to be 90-110 km/h, not 70 km/h as I expected. Below 70 km/h, relative consumption creeps back up slightly due to auxiliary systems.</p><h2 id="troubleshooting">Troubleshooting<a href="#troubleshooting" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><strong>Teslamate shows &ldquo;offline&rdquo; instead of &ldquo;asleep&rdquo;:</strong>
That&rsquo;s expected behavior since Tesla firmware 2024.8+ — Tesla changed the API response, it&rsquo;s not a Teslamate bug.</p><p><strong>Token isn&rsquo;t accepted:</strong>
Use the Refresh Token (the longer of the two strings), not the Access Token.</p><p><strong>No data:</strong>
Check the Docker logs with<code>docker compose logs teslamate</code>. Common causes: the vehicle is in deep sleep, wrong timezone, or network issues.</p><p><strong>High API costs:</strong>
Reduce the polling frequency, switch to Fleet Telemetry, or check for unnecessary wake commands.</p><h2 id="security-and-backup">Security and Backup<a href="#security-and-backup" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><strong>Network:</strong></p><ul><li>Don&rsquo;t expose it directly to the internet</li><li>Use a reverse proxy with HTTPS (Nginx, Caddy, Traefik)</li><li>Enable firewall rules</li></ul><p><strong>Backup:</strong></p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose exec database pg_dump -U teslamate teslamate &gt; backup.sql</span></span></code></pre></div></div><p><strong>Updates:</strong></p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose pull</span></span><span style="display:flex;"><span>docker compose up -d</span></span></code></pre></div></div><h2 id="frequently-asked-questions">Frequently Asked Questions<a href="#frequently-asked-questions" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><strong>Does Teslamate cost money?</strong>
The software itself is free. Tesla API costs apply, but are usually covered by the $10 free allowance. Only heavy usage adds roughly $2-5/month.</p><p><strong>Does it work with the new Fleet API?</strong>
Yes, fully compatible and kept up to date.</p><p><strong>Does it drain the battery?</strong>
No, the additional consumption is negligible.</p><p><strong>Can I track multiple vehicles?</strong>
Yes, each one is logged separately.</p><h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>For me, Teslamate is the best solution for in-depth vehicle data analysis. The upsides:</p><ul><li>Full data ownership through self-hosting</li><li>Detailed analysis with Grafana</li><li>Low cost thanks to the free API allowance</li><li>An active community</li><li>Great fit for Home Assistant integration</li></ul><p>The downsides: it requires technical know-how, your own server, and there&rsquo;s no official support.</p><p>For Tesla owners with a smart-home or homelab streak, Teslamate is an excellent piece of software. After a few months of use, the analytics get genuinely interesting, once trends and long-term patterns start to show. Even while driving, the Grafana dashboards surface plenty of interesting data.</p><h2 id="tips-for-advanced-users">Tips for Advanced Users<a href="#tips-for-advanced-users" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><strong>Reverse proxy setup:</strong>
For secure access on the go, I&rsquo;d recommend a reverse proxy with a Let&rsquo;s Encrypt SSL certificate. Traefik or Caddy fully automate the process.</p><p><strong>Database optimization:</strong>
On very old installations, a manual vacuum of the database can help:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>docker compose exec database vacuumdb -U teslamate -d teslamate -z -v</span></span></code></pre></div></div><p><strong>Custom dashboards:</strong>
Grafana offers endless possibilities. You could, for example, build comparisons with other Tesla drivers, or visualize correlations between weather and consumption.</p><p><strong>Further reading:</strong></p><ul><li><a href="https://docs.teslamate.org" target="_blank" rel="noopener noreferrer">Teslamate Documentation<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></li><li><a href="https://github.com/teslamate-org/teslamate" target="_blank" rel="noopener noreferrer">GitHub Repository<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></li><li><a href="https://github.com/teslamate-org/teslamate/discussions" target="_blank" rel="noopener noreferrer">Community Forum<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></li></ul>
]]></content:encoded><category>selfhosted</category><category>tesla</category><category>docker</category><category>grafana</category><category>homelab</category></item><item><title>GitOps without Komodo: Webhook-based Auto-Deployment for Docker Compose</title><link>https://www.teqqy.de/en/gitops-without-komodo/</link><pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate><lastBuildDate>Wed, 03 Jun 2026 00:00:00 +0000</lastBuildDate><guid isPermaLink="true">https://www.teqqy.de/en/gitops-without-komodo/</guid><description>If you&amp;rsquo;ve read my previous post on my 2025 homelab setup , you know I&amp;rsquo;ve been using Komodo (external link) as my GitOps tool for Docker Compose stacks. The concept is solid: Git as a single source of truth, Renovate for automated version updates, and Komodo deploying changes to the target system on every new commit. It mostly works – but Komodo has been driving me up the wall with one thing: file permissions. It simply doesn&amp;rsquo;t work consistently. Either the permissions on the host are off, or Komodo complains during deployment, or something in between. After the nth time dealing with it, I&amp;rsquo;d had enough and wanted something leaner.</description><content:encoded>&lt;![CDATA[<p>If you&rsquo;ve read my<a href="/selfhosted-setup-2025-mein-neuer-workflow-mit-proxmox-komodo-und-gitops/">previous post on my 2025 homelab setup</a>
, you know I&rsquo;ve been using<a href="https://komo.do/" target="_blank" rel="noopener noreferrer">Komodo<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
as my GitOps tool for Docker Compose stacks. The concept is solid: Git as a single source of truth, Renovate for automated version updates, and Komodo deploying changes to the target system on every new commit. It mostly works – but Komodo has been driving me up the wall with one thing: file permissions. It simply doesn&rsquo;t work consistently. Either the permissions on the host are off, or Komodo complains during deployment, or something in between. After the nth time dealing with it, I&rsquo;d had enough and wanted something leaner.</p><p>The requirement is straightforward: when Renovate pushes a new commit to the repo (after I merge the pull request), the compose file on the server should be updated and the affected containers restarted. No Kubernetes, no additional framework. Just git pull and docker compose up.</p><h2 id="the-basic-concept">The Basic Concept<a href="#the-basic-concept" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>The setup relies on three components working together:</p><ol><li><strong><a href="https://about.gitea.com/" target="_blank" rel="noopener noreferrer">Gitea<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></strong> – my self-hosted Git server where the compose files live</li><li><strong><a href="https://docs.renovatebot.com/" target="_blank" rel="noopener noreferrer">Renovate<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></strong> – checks the image tags in the compose files against the respective container registries every four hours via cronjob, and automatically opens pull requests when newer versions are available. I merge the PR, the commit lands on<code>main</code>.</li><li><strong><a href="https://github.com/adnanh/webhook" target="_blank" rel="noopener noreferrer">webhook<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></strong> – a single Go binary that exposes an HTTP endpoint on the target server. Gitea fires a webhook on every push, which triggers the deploy script.</li></ol><p>The data flow looks like this:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label"/><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang=""><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><pre tabindex="0"><code>Renovate detects new image tag
→ PR in Gitea
→ Merge
→ Gitea fires webhook
→ webhook binary calls deploy.sh
→ git pull + docker compose up -d</code></pre></div><p>No polling, no daemon watching container registries, no framework with its own opinions about file permissions.</p><h2 id="why-not-just-watchtower-or-a-cron-script">Why Not Just Watchtower or a Cron Script?<a href="#why-not-just-watchtower-or-a-cron-script" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>Before reaching for webhook, I briefly considered two other approaches.</p><p><strong><a href="https://containrrr.dev/watchtower/" target="_blank" rel="noopener noreferrer">Watchtower<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a></strong> monitors running containers and pulls new images automatically. The problem: Watchtower reacts to new images in the registry, not to Git commits. When Renovate opens a PR and I merge it, Watchtower has no idea – it just pulls whenever it decides to check. That doesn&rsquo;t fit a setup where Git is supposed to be the single source of truth. I also want to control the merge timing, not Watchtower.</p><p><strong>A cron script with<code>git fetch</code></strong> would be the other option – check for changes every few minutes and deploy if there are any. It works, but has a conceptual drawback: it&rsquo;s polling. I already have Gitea infrastructure that can deliver push events, so I should use it. A webhook reacts within seconds; a cron job with a 5-minute interval adds unnecessary delay.</p><p>webhook is the cleanest approach: event-driven, no extra daemon, a single binary with no dependencies.</p><h2 id="repo-structure">Repo Structure<a href="#repo-structure" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>I have one repository per server:</p><ul><li><code>apps-stack</code> for<code>docker01</code> – running tools like<a href="https://docs.paperless-ngx.com/" target="_blank" rel="noopener noreferrer">Paperless-ngx<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
,<a href="https://immich.app/" target="_blank" rel="noopener noreferrer">Immich<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
,<a href="https://github.com/dani-garcia/vaultwarden" target="_blank" rel="noopener noreferrer">Vaultwarden<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
, and similar</li><li><code>media-stack</code> for<code>media01</code> – all the<code>*arr</code> containers and everything else in the media stack</li></ul><p>Each repo just contains the<code>compose.yaml</code> and any additional config files individual applications need. Nothing special.</p><h3 id="renovate-in-the-repos">Renovate in the Repos<a href="#renovate-in-the-repos" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h3><p>Renovate runs self-hosted and checks the image tags in the compose files every four hours via cronjob. A minimal<code>renovate.json</code> in the repo root is all it takes:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">json</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="json"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{</span></span><span style="display:flex;"><span><span style="color:#7ee787">"$schema"</span>:<span style="color:#a5d6ff">"https://docs.renovatebot.com/renovate-schema.json"</span>,</span></span><span style="display:flex;"><span><span style="color:#7ee787">"extends"</span>: [<span style="color:#a5d6ff">"config:base"</span>],</span></span><span style="display:flex;"><span><span style="color:#7ee787">"docker-compose"</span>: {</span></span><span style="display:flex;"><span><span style="color:#7ee787">"enabled"</span>:<span style="color:#79c0ff">true</span></span></span><span style="display:flex;"><span> }</span></span><span style="display:flex;"><span>}</span></span></code></pre></div></div><p>Renovate automatically detects image tags in the compose file and opens PRs when a newer version is available. For software that uses semantic versioning (<code>major.minor.patch</code>), this can be fine-tuned – for example, automatically merging patch updates while reviewing minor updates manually.</p><p>I exclude Postgres containers from Renovate. Major upgrades between Postgres versions require a manual database dump and restore – not something I want a bot to trigger automatically.</p><h2 id="installing-and-configuring-webhook">Installing and Configuring webhook<a href="#installing-and-configuring-webhook" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><code>webhook</code> is available as a single binary, or as a package:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>apt install webhook</span></span></code></pre></div></div><p>The configuration lives at<code>/etc/webhook/webhook.conf</code>:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">yaml</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="yaml"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span>-<span style="color:#7ee787">id</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">deploy</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">execute-command</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">/opt/scripts/deploy.sh</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">command-working-directory</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">/opt/media-stack</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">trigger-rule</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">match</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">type</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">payload-hmac-sha256</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">secret</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">"{{getenv \"WEBHOOK_SECRET\"}}"</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">parameter</span>:<span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">source</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">header</span><span style="color:#6e7681"/></span></span><span style="display:flex;"><span><span style="color:#6e7681"/><span style="color:#7ee787">name</span>:<span style="color:#6e7681"/><span style="color:#a5d6ff">X-Gitea-Signature</span></span></span></code></pre></div></div><p>A few notes on this:</p><p><strong>Header name:</strong> Gitea sends the signature as<code>X-Gitea-Signature</code> – without a<code>-256</code> suffix and without a<code>sha256=</code> prefix in the value. Sounds trivial, but it cost me a debugging session on the first try.</p><p><strong>Secret as environment variable:</strong> The HMAC secret doesn&rsquo;t belong in the config file in plaintext, especially if the file is versioned in the repo. So it&rsquo;s read from the environment via<code>getenv</code>.</p><p>Generate the secret:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>openssl rand -hex<span style="color:#a5d6ff">32</span></span></span></code></pre></div></div><p>Write it to a secrets file that only root can read:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># /etc/webhook/secrets.env</span></span></span><span style="display:flex;"><span><span style="color:#79c0ff">WEBHOOK_SECRET</span><span style="color:#ff7b72;font-weight:bold">=</span>your-generated-string</span></span></code></pre></div></div><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chmod<span style="color:#a5d6ff">600</span> /etc/webhook/secrets.env</span></span></code></pre></div></div><h3 id="systemd-unit">Systemd Unit<a href="#systemd-unit" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h3><div class="code-block"><div class="code-block-header"><span class="code-lang-label">ini</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="ini"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#8b949e;font-style:italic"># /etc/systemd/system/webhook.service</span></span></span><span style="display:flex;"><span><span style="color:#ff7b72">[Unit]</span></span></span><span style="display:flex;"><span>Description<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">Webhook Receiver</span></span></span><span style="display:flex;"><span>After<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">network.target</span></span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span><span style="color:#ff7b72">[Service]</span></span></span><span style="display:flex;"><span>User<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">teqqy # Replace with your own username</span></span></span><span style="display:flex;"><span>EnvironmentFile<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">/etc/webhook/secrets.env</span></span></span><span style="display:flex;"><span>ExecStart<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">/usr/bin/webhook -hooks /etc/webhook/webhook.conf -port 9000</span></span></span><span style="display:flex;"><span>Restart<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">on-failure</span></span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span><span style="color:#ff7b72">[Install]</span></span></span><span style="display:flex;"><span>WantedBy<span style="color:#ff7b72;font-weight:bold">=</span><span style="color:#a5d6ff">multi-user.target</span></span></span></code></pre></div></div><p><code>User=teqqy</code> ensures the webhook daemon and the deploy script don&rsquo;t run as root. That&rsquo;s important to me – processes that only need to run git pull and docker compose up have no business running as root. Replace the username with your own; the user needs access to the repo directory and must be a member of the<code>docker</code> group.</p><p><code>EnvironmentFile</code> loads<code>secrets.env</code> and makes<code>WEBHOOK_SECRET</code> available as an environment variable before the process starts – so the secret flows cleanly into the<code>getenv</code> call in the config.<code>Restart=on-failure</code> ensures the daemon automatically restarts after an unexpected crash without manual intervention.</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>systemctl enable --now webhook</span></span></code></pre></div></div><h2 id="setting-up-the-gitea-webhook">Setting Up the Gitea Webhook<a href="#setting-up-the-gitea-webhook" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>In Gitea under<code>Repository → Settings → Webhooks → Add Webhook → Gitea</code>:</p><ul><li><strong>Target URL:</strong><code>http://media01.example.com:9000/hooks/deploy</code></li><li><strong>Secret:</strong> the same string you generated above</li><li><strong>Trigger:</strong> Only<code>Push</code> events are needed</li></ul><p>On every push, Gitea automatically computes an HMAC-SHA256 signature over the request body and sends it as the<code>X-Gitea-Signature</code> header. The webhook binary verifies the signature before executing the script – a simple Authorization header would be significantly less secure since it doesn&rsquo;t protect the payload against tampering.</p><p>Port 9000 shouldn&rsquo;t be exposed directly to the internet. In my setup, Gitea and the webhook daemon are on the same internal network and the port isn&rsquo;t reachable from outside. Anyone who needs to expose the endpoint for an external Gitea instance or GitHub should at minimum put it behind a reverse proxy with IP restrictions.</p><h2 id="the-deploy-script">The Deploy Script<a href="#the-deploy-script" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#8b949e;font-weight:bold;font-style:italic">#!/bin/bash</span></span></span><span style="display:flex;"><span>set -euo pipefail</span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span><span style="color:#79c0ff">REPO</span><span style="color:#ff7b72;font-weight:bold">=</span>/opt/media-stack</span></span><span style="display:flex;"><span><span style="color:#79c0ff">LOG</span><span style="color:#ff7b72;font-weight:bold">=</span>/opt/deploy-logs/deploy-<span style="color:#ff7b72">$(</span>date +%Y%m%d-%H%M%S<span style="color:#ff7b72">)</span>.log</span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span>exec &gt;&gt;<span style="color:#a5d6ff">"</span><span style="color:#79c0ff">$LOG</span><span style="color:#a5d6ff">"</span> 2&gt;&amp;<span style="color:#a5d6ff">1</span></span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span>echo<span style="color:#a5d6ff">"=== Deploy</span><span style="color:#ff7b72">$(</span>date<span style="color:#ff7b72">)</span><span style="color:#a5d6ff"> ==="</span></span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span>cd<span style="color:#a5d6ff">"</span><span style="color:#79c0ff">$REPO</span><span style="color:#a5d6ff">"</span></span></span><span style="display:flex;"><span>git pull origin main</span></span><span style="display:flex;"><span>docker compose up -d --remove-orphans</span></span><span style="display:flex;"><span>docker compose ps</span></span><span style="display:flex;"><span/></span><span style="display:flex;"><span>echo<span style="color:#a5d6ff">"=== Done ==="</span></span></span></code></pre></div></div><p><code>set -euo pipefail</code> ensures the script immediately aborts on any error and returns a non-zero exit code. webhook logs that as well, so<code>journalctl -u webhook</code> is the first place to look when something goes wrong.</p><p>The log directory needs to be owned by the executing user:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>mkdir -p /opt/deploy-logs</span></span><span style="display:flex;"><span>chown teqqy:teqqy /opt/deploy-logs</span></span></code></pre></div></div><h2 id="pitfalls-i-hit-along-the-way">Pitfalls I Hit Along the Way<a href="#pitfalls-i-hit-along-the-way" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p><strong><code>.git</code> directory ownership:</strong> If the repo was ever cloned or pulled as a different user, Git refuses access with a warning about &ldquo;dubious ownership&rdquo;. Fix:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chown -R teqqy:teqqy /opt/media-stack/.git</span></span></code></pre></div></div><p><strong><code>safe.directory</code>:</strong> In some setups this helps additionally:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>git config --global --add safe.directory /opt/media-stack</span></span></code></pre></div></div><p><strong><code>/var/log</code> permissions:</strong> The executing user doesn&rsquo;t have write access there. Put the log directory somewhere the user owns – I use<code>/opt/deploy-logs</code>.</p><p><strong><code>*arr</code> containers and UID/GID:</strong> My<code>*arr</code> containers run as<code>99:100</code> (Unraid-compatible for NFS reasons). On the very first deployment, Docker creates bind mount directories with<code>1000:1000</code> if they don&rsquo;t exist yet. One-time fix before the first start:</p><div class="code-block"><div class="code-block-header"><span class="code-lang-label">bash</span><button class="code-copy-btn" type="button" aria-label="Copy code" data-umami-event="Code Copy" data-umami-event-lang="bash"><svg class="icon-copy" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg><svg class="icon-check" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></button></div><div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:2;-o-tab-size:2;tab-size:2;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>chown -R 99:100 /mnt/data/arr/</span></span></code></pre></div></div><p>After that it&rsquo;s a non-issue since the directories already exist.</p><h2 id="monitoring">Monitoring<a href="#monitoring" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>I use<a href="https://uptime.kuma.pet/" target="_blank" rel="noopener noreferrer">Uptime Kuma<svg class="link-external-icon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg><span class="visually-hidden"> (external link)</span></a>
to check whether containers are still running cleanly after a deployment. That&rsquo;s enough for my use case: if a container stops responding after a failed update, Uptime Kuma alerts me. The deploy log and<code>journalctl -u webhook</code> then provide the details on what went wrong.</p><p>A dead man&rsquo;s switch (e.g. via Healthchecks.io) would be the next sensible step if you also want to be notified when a deploy simply<em>doesn&rsquo;t happen</em> – but for a media stack that&rsquo;s not critical enough for me to add the overhead.</p><h2 id="conclusion">Conclusion<a href="#conclusion" class="heading-anchor" aria-label="Link to this section"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></a></h2><p>The setup has been running for a little while now and does exactly what it&rsquo;s supposed to – without me having to debug anything in Komodo. The webhook binary is stable, the deployment flow is deterministic, and the total overhead is a handful of config files and a shell script.</p><p>For anyone with similar requirements who doesn&rsquo;t want to run Kubernetes: this is, in my opinion, the most pragmatic approach. No magic, no frameworks, no overengineering. Just git pull and docker compose up.</p><p>If you have questions or feedback, reach me through the links in the menu bar.</p>
]]></content:encoded><category>selfhosted</category><category>gitops</category><category>docker</category><category>gitea</category><category>renovate</category><category>homelab</category></item></channel></rss>