<?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>Gitea Actions on Kabelbruch</title><link>https://kabelbruch.de/tags/gitea-actions/</link><description>Recent content in Gitea Actions on Kabelbruch</description><image><title>Kabelbruch</title><url>https://kabelbruch.de/og-image.png</url><link>https://kabelbruch.de/og-image.png</link></image><generator>Hugo</generator><language>de-DE</language><lastBuildDate>Fri, 30 Oct 2026 23:33:00 +0100</lastBuildDate><atom:link href="https://kabelbruch.de/tags/gitea-actions/index.xml" rel="self" type="application/rss+xml"/><item><title>Wie Kabelbruch entstanden ist – Teil 3: Die Pipeline</title><link>https://kabelbruch.de/posts/kabelbruch-teil-3-pipeline/</link><pubDate>Fri, 30 Oct 2026 23:33:00 +0100</pubDate><guid>https://kabelbruch.de/posts/kabelbruch-teil-3-pipeline/</guid><description>Vom git push zur fertigen Seite: Gitea im Homelab, ein isolierter Runner, ein SSH-Schlüssel, der nur rsync darf – und warum mein Artikel nicht erschien.</description><content:encoded><![CDATA[<p>In <a href="/posts/kabelbruch-teil-1-idee-und-server/">Teil 1</a> habe ich den Server aufgesetzt, in <a href="/posts/kabelbruch-teil-2-caddy-dns-header/">Teil 2</a> liefert er per HTTPS eine Seite aus. Jetzt fehlt noch das Wichtigste für einen Blog: ein bequemer Weg, neue Artikel online zu bringen. Mein Ziel war, dass ich einen Artikel auf dem Laptop schreibe, ihn per <code>git push</code> in meinen eigenen Git-Server schiebe – und der Rest passiert von selbst.</p>
<p>Das klappt inzwischen. Vom Push bis zur fertigen Seite vergehen etwa zehn Sekunden. Wie das aufgebaut ist und warum ich dabei ziemlich paranoid war, zeigt dieser Teil.</p>
<h2 id="der-plan">Der Plan</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Laptop ──git push──▶ Gitea (Homelab) ──▶ Runner (isoliertes LAB-Netz)
</span></span><span class="line"><span class="cl">                                            │  hugo --minify
</span></span><span class="line"><span class="cl">                                            ▼
</span></span><span class="line"><span class="cl">                             rsync über SSH (nur ausgehend)
</span></span><span class="line"><span class="cl">                                            ▼
</span></span><span class="line"><span class="cl">                         VPS: Caddy ──▶ https://example.com
</span></span></code></pre></div><p>Die Bausteine:</p>
<ul>
<li><strong><a href="https://about.gitea.com/" target="_blank" rel="noopener noreferrer">Gitea</a></strong> ist mein selbst gehosteter Git-Server, ein bisschen wie GitHub für zu Hause. Er läuft bei mir schon länger als Docker-Container im Homelab. Gitea bringt mit <strong>Gitea Actions</strong> eine eigene CI mit (Continuous Integration, also automatische Abläufe bei jedem Push), deren Syntax fast identisch zu GitHub Actions ist.</li>
<li>Ein <strong>Runner</strong> führt diese Abläufe aus. Er bekommt bei mir eine eigene kleine VM.</li>
<li><strong><a href="https://gohugo.io/" target="_blank" rel="noopener noreferrer">Hugo</a></strong> baut aus den Markdown-Dateien die fertige Seite.</li>
<li><strong>rsync</strong> über SSH schiebt die Seite zum VPS.</li>
</ul>
<p>Das Schöne daran: Mein Heimnetz öffnet nach außen gar nichts. Der Runner baut zu Hause und verbindet sich <strong>von innen nach außen</strong> zum VPS. Es gibt keine Portfreigabe.</p>
<h2 id="voraussetzungen">Voraussetzungen</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Version / Stand</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Gitea</td>
					<td>28.x, als Docker-Container, Actions aktiv</td>
			</tr>
			<tr>
					<td>Proxmox VE</td>
					<td>9.x (für die Runner-VM)</td>
			</tr>
			<tr>
					<td>Gitea Runner</td>
					<td>Image <code>gitea/runner</code> (der frühere <code>act_runner</code>)</td>
			</tr>
			<tr>
					<td>Hugo</td>
					<td>0.169.0 extended</td>
			</tr>
			<tr>
					<td>Theme</td>
					<td>PaperMod (als Git-Submodule)</td>
			</tr>
			<tr>
					<td>VPS</td>
					<td>Debian 13 mit Caddy aus Teil 2</td>
			</tr>
	</tbody>
</table>
<p>Beispielwerte in diesem Artikel: <code>git.example.com</code> ist mein Gitea, <code>10.0.20.10</code> der Docker-Host, auf dem er läuft, <code>10.0.99.0/24</code> das LAB-Netz, <code>10.0.99.20</code> der Runner und <code>203.0.113.10</code> der VPS.</p>
<h2 id="schritt-1-das-hugo-projekt">Schritt 1: Das Hugo-Projekt</h2>
<p>Hugo installiere ich lokal direkt aus den offiziellen Releases, weil die Pakete der Distributionen oft hinterherhinken:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/.local/bin
</span></span><span class="line"><span class="cl"><span class="nv">URL</span><span class="o">=</span><span class="k">$(</span>curl -s https://api.github.com/repos/gohugoio/hugo/releases/latest <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> grep -o <span class="s1">&#39;https://[^&#34;]*hugo_extended_[0-9.]*_linux-amd64\.tar\.gz&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">curl -L <span class="s2">&#34;</span><span class="nv">$URL</span><span class="s2">&#34;</span> <span class="p">|</span> tar -xz -C ~/.local/bin hugo
</span></span><span class="line"><span class="cl">hugo version
</span></span></code></pre></div><p>Die Versionsnummer merke ich mir, denn genau diese Version bekommt später auch die Pipeline. Dann sehen die Seite lokal und live garantiert gleich aus.</p>
<p>In Gitea lege ich ein leeres, privates Repository an, klone es und erzeuge darin die Seite:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone ssh://git@git.example.com:2222/thomas/kabelbruch.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> kabelbruch
</span></span><span class="line"><span class="cl">hugo new site . --force --format toml
</span></span><span class="line"><span class="cl">git submodule add --depth<span class="o">=</span><span class="m">1</span> https://github.com/adityatelange/hugo-PaperMod.git themes/PaperMod
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cat &gt; .gitignore <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">public/
</span></span></span><span class="line"><span class="cl"><span class="s">resources/_gen/
</span></span></span><span class="line"><span class="cl"><span class="s">.hugo_build.lock
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span></code></pre></div><p>Als Theme nutze ich <a href="https://github.com/adityatelange/hugo-PaperMod" target="_blank" rel="noopener noreferrer">PaperMod</a>: schlicht, schnell, mit Hell-Dunkel-Modus, und es braucht weder Node.js noch externe Schriftarten. Mit <code>hugo server -D</code> sehe ich die Seite lokal unter <code>http://localhost:1313</code> – inklusive Entwürfen.</p>
<h2 id="schritt-2-ein-deploy-schlüssel-der-fast-nichts-darf">Schritt 2: Ein Deploy-Schlüssel, der fast nichts darf</h2>
<p>Die Pipeline braucht einen SSH-Schlüssel, um Dateien auf den VPS zu schieben. Dieser Schlüssel liegt später als Secret in Gitea und muss <strong>ohne Passphrase</strong> funktionieren, sonst kann ihn die Pipeline nicht nutzen. Genau deshalb soll er auf dem Server so wenig dürfen wie möglich.</p>
<h3 id="ein-eigener-benutzer-auf-dem-vps">Ein eigener Benutzer auf dem VPS</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt install -y rsync
</span></span><span class="line"><span class="cl">sudo adduser --system --group --shell /bin/sh --home /home/deploy deploy
</span></span><span class="line"><span class="cl">sudo chown -R deploy:deploy /var/www/kabelbruch
</span></span></code></pre></div><h3 id="der-schlüssel">Der Schlüssel</h3>
<p>Auf dem Laptop, ohne Passphrase (<code>-N &quot;&quot;</code>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh-keygen -t ed25519 -f ~/.ssh/kabelbruch_deploy -N <span class="s2">&#34;&#34;</span> -C <span class="s2">&#34;gitea-deploy-kabelbruch&#34;</span>
</span></span><span class="line"><span class="cl">cat ~/.ssh/kabelbruch_deploy.pub
</span></span></code></pre></div><h3 id="der-trick-rrsync">Der Trick: <code>rrsync</code></h3>
<p>Auf dem Server trage ich den öffentlichen Schlüssel mit zwei Einschränkungen ein:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /home/deploy/.ssh
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;restrict,command=&#34;/usr/bin/rrsync /var/www/kabelbruch&#34; ssh-ed25519 AAAA… gitea-deploy-kabelbruch&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> sudo tee /home/deploy/.ssh/authorized_keys
</span></span><span class="line"><span class="cl">sudo chown -R deploy:deploy /home/deploy/.ssh
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">700</span> /home/deploy/.ssh
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">600</span> /home/deploy/.ssh/authorized_keys
</span></span></code></pre></div><ul>
<li><strong><code>restrict</code></strong> verbietet alles, was SSH sonst noch kann: Terminal, Port-Forwarding, Agent-Forwarding.</li>
<li><strong><code>command=&quot;/usr/bin/rrsync /var/www/kabelbruch&quot;</code></strong> sorgt dafür, dass mit diesem Schlüssel <strong>ausschließlich</strong> <code>rsync</code> läuft, und zwar nur innerhalb dieses einen Verzeichnisses. <code>rrsync</code> („restricted rsync“) ist bei Debian im <code>rsync</code>-Paket enthalten. Es ist ein Python-Skript, <code>python3</code> muss also installiert sein.</li>
</ul>
<p>Selbst wenn jemand den Schlüssel stiehlt, kann er damit nur meine Website überschreiben. Ärgerlich, aber kein Weg auf den Server.</p>
<h3 id="der-gegentest">Der Gegentest</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ssh -i ~/.ssh/kabelbruch_deploy deploy@203.0.113.10
</span></span><span class="line"><span class="cl">PTY allocation request failed on channel 0
</span></span><span class="line"><span class="cl">/usr/bin/rrsync error: Not invoked via sshd
</span></span><span class="line"><span class="cl">Connection to 203.0.113.10 closed.
</span></span></code></pre></div><p>Genau so soll es aussehen: kein Terminal, keine Shell. Und der echte Upload:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rsync -av --delete -e <span class="s2">&#34;ssh -i ~/.ssh/kabelbruch_deploy&#34;</span> /tmp/kb-test/ deploy@203.0.113.10:
</span></span></code></pre></div><p>Wichtig ist der Doppelpunkt am Ende <strong>ohne Pfad</strong>. Das Zielverzeichnis legt <code>rrsync</code> auf dem Server fest.</p>
<h2 id="schritt-3-der-runner--warum-er-in-ein-eigenes-netz-kommt">Schritt 3: Der Runner – warum er in ein eigenes Netz kommt</h2>
<p>Ein Runner führt Code aus dem Repository aus, und dafür bekommt er in der Regel Zugriff auf das Docker-Socket seines Hosts. Das heißt: <strong>Wer den Runner kontrolliert, ist auf dessen Host praktisch root.</strong> Mein Docker-Host im Homelab hat Dienste wie meinen Passwortmanager und meine Fotos. Da gehört so etwas nicht hin.</p>
<p>Deshalb bekommt der Runner eine eigene kleine VM in meinem <strong>LAB-Netz</strong>. Das ist ein VLAN für Testkram, das auf der Firewall vom Rest des Heimnetzes getrennt ist und nur DNS und Internet darf.</p>
<h3 id="die-vm-aus-dem-cloud-image">Die VM aus dem Cloud-Image</h3>
<p>Statt Debian per Hand zu installieren, nehme ich das offizielle <strong>Debian-Cloud-Image</strong> und lasse <strong>cloud-init</strong> Benutzer und SSH-Schlüssel beim ersten Start einrichten. Auf dem Proxmox-Host:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p /var/lib/vz/import <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /var/lib/vz/import
</span></span><span class="line"><span class="cl">wget https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;ssh-ed25519 AAAA… thomas@laptop&#39;</span> &gt; /root/runner.pub
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qm create <span class="m">108</span> --name ci-runner --ostype l26 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --cpu host --cores <span class="m">2</span> --memory <span class="m">4096</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --scsihw virtio-scsi-single <span class="se">\
</span></span></span><span class="line"><span class="cl">  --scsi0 ZFS-Pool:0,import-from<span class="o">=</span>/var/lib/vz/import/debian-13-genericcloud-amd64.qcow2,discard<span class="o">=</span>on,iothread<span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --ide2 ZFS-Pool:cloudinit <span class="se">\
</span></span></span><span class="line"><span class="cl">  --boot <span class="nv">order</span><span class="o">=</span>scsi0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --net0 virtio,bridge<span class="o">=</span>vmbr0,tag<span class="o">=</span>99,firewall<span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --serial0 socket --vga serial0 <span class="se">\
</span></span></span><span class="line"><span class="cl">  --agent <span class="nv">enabled</span><span class="o">=</span><span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --ciuser thomas --sshkeys /root/runner.pub <span class="se">\
</span></span></span><span class="line"><span class="cl">  --ipconfig0 <span class="nv">ip</span><span class="o">=</span>dhcp --ciupgrade <span class="m">1</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  --onboot <span class="m">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">qm disk resize <span class="m">108</span> scsi0 32G
</span></span><span class="line"><span class="cl">qm config <span class="m">108</span> <span class="p">|</span> grep net0
</span></span></code></pre></div><p><code>ZFS-Pool</code> ist der Name meines Speichers und <code>tag=99</code> steht hier für die VLAN-ID des LAB-Netzes – beides an die eigene Umgebung anpassen. Aus der letzten Zeile notiere ich mir die <strong>MAC-Adresse</strong>, denn die VM soll per DHCP-Reservierung immer dieselbe IP bekommen.</p>
<p><strong>Noch nicht starten</strong> – erst kommt die Firewall.</p>
<h3 id="gescheitert-teil-1-qcow21">Gescheitert, Teil 1: <code>.qcow2.1</code></h3>
<p><code>wget</code> meldete beim Download: <code>Saving to: 'debian-13-genericcloud-amd64.qcow2.1'</code>. Eine Datei mit demselben Namen lag schon im Verzeichnis, von einem früheren Versuch. Hätte ich das übersehen, hätte <code>qm create</code> das alte Image importiert. Also erst aufräumen:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mv -f debian-13-genericcloud-amd64.qcow2.1 debian-13-genericcloud-amd64.qcow2
</span></span></code></pre></div><h3 id="eine-einzige-ausnahme-in-der-firewall">Eine einzige Ausnahme in der Firewall</h3>
<p>Der Runner muss genau zwei Dinge können: ins Internet (Container-Images, GitHub, SSH zum VPS) und <strong>mein Gitea erreichen</strong>. Das Internet ist im LAB-Netz ohnehin erlaubt. Für Gitea braucht es eine einzige, möglichst schmale Ausnahme: <strong>nur diese eine IP, nur zum Gitea-Host, nur HTTPS.</strong></p>
<p>Auf meiner FortiGate sieht das so aus (Objektnamen an die eigene Konfiguration anpassen):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">config firewall address
</span></span><span class="line"><span class="cl">    edit &#34;H_CI_RUNNER&#34;
</span></span><span class="line"><span class="cl">        set subnet 10.0.99.20 255.255.255.255
</span></span><span class="line"><span class="cl">    next
</span></span><span class="line"><span class="cl">end
</span></span><span class="line"><span class="cl">config firewall policy
</span></span><span class="line"><span class="cl">    edit 0
</span></span><span class="line"><span class="cl">        set name &#34;RUNNER-to-GITEA&#34;
</span></span><span class="line"><span class="cl">        set srcintf &#34;VL99_LAB&#34;
</span></span><span class="line"><span class="cl">        set dstintf &#34;VL20_SERVER&#34;
</span></span><span class="line"><span class="cl">        set srcaddr &#34;H_CI_RUNNER&#34;
</span></span><span class="line"><span class="cl">        set dstaddr &#34;H_DOCKER_HOST&#34;
</span></span><span class="line"><span class="cl">        set action accept
</span></span><span class="line"><span class="cl">        set schedule &#34;always&#34;
</span></span><span class="line"><span class="cl">        set service &#34;HTTPS&#34;
</span></span><span class="line"><span class="cl">        set utm-status enable
</span></span><span class="line"><span class="cl">        set ips-sensor &#34;default&#34;
</span></span><span class="line"><span class="cl">        set logtraffic all
</span></span><span class="line"><span class="cl">    next
</span></span><span class="line"><span class="cl">end
</span></span></code></pre></div><p>Die Warnung „The no-inspection profile doesn&rsquo;t perform SSL inspection“ beim Speichern ist hier gewollt. Der IPS-Sensor schaut trotzdem mit.</p>
<p>Den Code holt der Runner übrigens per <strong>HTTPS</strong> mit einem Token, das Gitea für jeden Lauf automatisch erzeugt. Einen SSH-Zugang zu Gitea braucht er nicht.</p>
<p>Eine ehrliche Einschränkung: Gitea läuft bei mir hinter einem Reverse Proxy, der auf derselben IP noch andere Dienste ausliefert. Rein vom Netz her könnte der Runner also auch deren Login-Seiten erreichen – Zugangsdaten hat er natürlich keine. Wer es ganz dicht will, legt im Reverse Proxy zusätzlich eine Zugriffsliste an, die die Runner-IP nur für Gitea zulässt.</p>
<h3 id="vm-starten-und-einrichten">VM starten und einrichten</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">qm start <span class="m">108</span>
</span></span></code></pre></div><p>Nach etwa einer Minute ist die VM erreichbar. Vom Laptop aus ein kurzer Test, ob das Netz so funktioniert wie gedacht:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh thomas@10.0.99.20
</span></span><span class="line"><span class="cl">getent hosts git.example.com                    <span class="c1"># DNS</span>
</span></span><span class="line"><span class="cl">curl -sI https://git.example.com <span class="p">|</span> head -n <span class="m">1</span>     <span class="c1"># Gitea: Firewall-Ausnahme</span>
</span></span><span class="line"><span class="cl">curl -sI https://github.com <span class="p">|</span> head -n <span class="m">1</span>          <span class="c1"># Internet</span>
</span></span></code></pre></div><p>Dann Updates, Gast-Agent, automatische Sicherheitsupdates und Docker aus dem offiziellen Repository:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo apt update <span class="o">&amp;&amp;</span> sudo apt full-upgrade -y
</span></span><span class="line"><span class="cl">sudo apt install -y qemu-guest-agent unattended-upgrades curl ca-certificates
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> --now qemu-guest-agent
</span></span><span class="line"><span class="cl">sudo dpkg-reconfigure -plow unattended-upgrades
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo install -m <span class="m">0755</span> -d /etc/apt/keyrings
</span></span><span class="line"><span class="cl">sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
</span></span><span class="line"><span class="cl">sudo chmod a+r /etc/apt/keyrings/docker.asc
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;deb [arch=</span><span class="k">$(</span>dpkg --print-architecture<span class="k">)</span><span class="s2"> signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian </span><span class="k">$(</span>. /etc/os-release <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$VERSION_CODENAME</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> stable&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="p">|</span> sudo tee /etc/apt/sources.list.d/docker.list
</span></span><span class="line"><span class="cl">sudo apt update
</span></span><span class="line"><span class="cl">sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
</span></span><span class="line"><span class="cl">sudo docker run --rm hello-world
</span></span></code></pre></div><h2 id="schritt-4-den-runner-registrieren">Schritt 4: Den Runner registrieren</h2>
<p>Kleine Begriffsverwirrung vorweg: Der Runner hieß früher <code>act_runner</code> und wurde 2026 in <strong>Gitea Runner</strong> umbenannt. Das alte Docker-Image <code>gitea/act_runner</code> ist als veraltet markiert, aktuell ist <code>gitea/runner</code>.</p>
<p>Den Runner registriere ich <strong>nur für dieses eine Repository</strong>, nicht für die ganze Gitea-Instanz. Dann führt er auch nur Jobs aus diesem Repository aus. Den Registrierungs-Token gibt es in Gitea unter <em>Repository → Einstellungen → Actions → Runners → „Neuen Runner erstellen“</em>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo mkdir -p /opt/runner <span class="o">&amp;&amp;</span> <span class="nb">cd</span> /opt/runner
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">sudo tee compose.yaml &gt; /dev/null <span class="s">&lt;&lt;&#39;EOF&#39;
</span></span></span><span class="line"><span class="cl"><span class="s">services:
</span></span></span><span class="line"><span class="cl"><span class="s">  runner:
</span></span></span><span class="line"><span class="cl"><span class="s">    image: gitea/runner:latest
</span></span></span><span class="line"><span class="cl"><span class="s">    container_name: gitea-runner
</span></span></span><span class="line"><span class="cl"><span class="s">    restart: unless-stopped
</span></span></span><span class="line"><span class="cl"><span class="s">    environment:
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_INSTANCE_URL=https://git.example.com
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_TOKEN}
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_RUNNER_NAME=ci-runner
</span></span></span><span class="line"><span class="cl"><span class="s">      - GITEA_RUNNER_LABELS=ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
</span></span></span><span class="line"><span class="cl"><span class="s">    volumes:
</span></span></span><span class="line"><span class="cl"><span class="s">      - ./data:/data
</span></span></span><span class="line"><span class="cl"><span class="s">      - /var/run/docker.sock:/var/run/docker.sock
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;RUNNER_TOKEN=&#39;&lt;TOKEN_AUS_GITEA&gt;&#39;&#34;</span> <span class="p">|</span> sudo tee .env &gt; /dev/null
</span></span><span class="line"><span class="cl">sudo chmod <span class="m">600</span> .env
</span></span><span class="line"><span class="cl">sudo docker compose up -d
</span></span><span class="line"><span class="cl">sudo docker logs -f gitea-runner
</span></span></code></pre></div><p>Die einfachen Anführungszeichen um den Token in der <code>.env</code> sind Absicht: Sie schützen Sonderzeichen wie <code>$</code>, die Docker Compose sonst als Variable interpretieren würde.</p>
<p>Im Log steht dann zuerst <code>.runner is missing or not a regular file</code> – das ist beim ersten Start normal, die Registrierungsdatei gibt es ja noch nicht – und danach <code>Runner registered successfully</code>.</p>
<h3 id="den-token-wieder-loswerden">Den Token wieder loswerden</h3>
<p>Der Token wird nur für die Registrierung gebraucht. Danach liegt alles Nötige in <code>data/.runner</code>. Also weg damit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo rm .env
</span></span><span class="line"><span class="cl">sudo sed -i <span class="s1">&#39;/GITEA_RUNNER_REGISTRATION_TOKEN/d&#39;</span> compose.yaml
</span></span><span class="line"><span class="cl">sudo docker compose up -d
</span></span><span class="line"><span class="cl">sudo docker logs gitea-runner
</span></span></code></pre></div><p>Lässt man die Zeile in der <code>compose.yaml</code> stehen, warnt Compose bei jedem Start <code>The &quot;RUNNER_TOKEN&quot; variable is not set</code>. Harmlos, aber unnötig. Wichtig ist, dass beim Neustart <strong>keine</strong> neue Registrierung im Log auftaucht, sondern nur <code>declare successfully</code>.</p>
<p>In Gitea steht der Runner jetzt mit dem Status <strong>Idle</strong> unter <em>Einstellungen → Actions → Runners</em>.</p>
<h3 id="ein-kurzer-funktionstest">Ein kurzer Funktionstest</h3>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .gitea/workflows/runner-test.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Runner-Test</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="l">workflow_dispatch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">test</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">head -n 5 hugo.toml</span><span class="w">
</span></span></span></code></pre></div><p><code>workflow_dispatch</code> heißt: Der Workflow startet nur auf Knopfdruck (<em>Actions → Runner-Test → Workflow ausführen</em>). Beim ersten Lauf lädt der Runner einmalig das Ubuntu-Image, danach war der Test nach 20 Sekunden grün.</p>
<h2 id="schritt-5-secrets-und-der-host-schlüssel">Schritt 5: Secrets und der Host-Schlüssel</h2>
<p>Die Pipeline braucht zwei Dinge aus Gitea:</p>
<p><strong>1. Den privaten Deploy-Schlüssel als Secret.</strong> <em>Einstellungen → Actions → Secrets</em>, Name <code>DEPLOY_KEY</code>, Wert: der komplette Inhalt von <code>~/.ssh/kabelbruch_deploy</code> inklusive der <code>BEGIN</code>- und <code>END</code>-Zeilen.</p>
<p><strong>2. Den Host-Schlüssel des VPS als Variable.</strong> Die Pipeline soll sich nur mit <strong>meinem</strong> Server verbinden und keinen fremden akzeptieren. Der bequeme Weg wäre <code>StrictHostKeyChecking=no</code> – genau den will ich nicht. Stattdessen hole ich den Host-Schlüssel einmal ab und prüfe ihn gegen:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># auf dem Laptop</span>
</span></span><span class="line"><span class="cl">ssh-keyscan -t ed25519 203.0.113.10 2&gt;/dev/null <span class="p">|</span> tee /tmp/kb_known_hosts
</span></span><span class="line"><span class="cl">ssh-keygen -lf /tmp/kb_known_hosts
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># auf dem VPS</span>
</span></span><span class="line"><span class="cl">ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
</span></span></code></pre></div><p>Nur wenn beide <code>SHA256:…</code>-Fingerprints übereinstimmen, kommt die Zeile aus <code>/tmp/kb_known_hosts</code> als <strong>Variable</strong> <code>KNOWN_HOSTS</code> in Gitea. Ein Secret ist dafür nicht nötig, der Host-Schlüssel ist öffentlich.</p>
<p>Kleiner Stolperstein im Eingabefeld: Die Zeile ist so lang, dass Gitea sie optisch umbricht. Ob das nur die Anzeige ist oder ein echter Zeilenumbruch, der den Eintrag kaputtmachen würde, sieht man nicht. Mein Trick: Cursor hinter <code>ssh-ed25519</code>, mit Entf alles bis zum <code>AAAA…</code> heranziehen, ein Leerzeichen setzen. Dann ist es garantiert eine Zeile.</p>
<h2 id="schritt-6-der-deploy-workflow">Schritt 6: Der Deploy-Workflow</h2>
<p>Das Herzstück, <code>.gitea/workflows/deploy.yml</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build &amp; Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.169.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checkout (inkl. Theme)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="l">recursive</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Werkzeuge installieren</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          apt-get update -qq
</span></span></span><span class="line"><span class="cl"><span class="sd">          apt-get install -y -qq rsync openssh-client
</span></span></span><span class="line"><span class="cl"><span class="sd">          curl -fsSL &#34;https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz&#34; \
</span></span></span><span class="line"><span class="cl"><span class="sd">            | tar -xz -C /usr/local/bin hugo
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo version</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Seite bauen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">hugo --minify --gc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">SSH vorbereiten</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">DEPLOY_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.DEPLOY_KEY }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">KNOWN_HOSTS</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.KNOWN_HOSTS }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          install -m 700 -d ~/.ssh
</span></span></span><span class="line"><span class="cl"><span class="sd">          printf &#39;%s\n&#39; &#34;$DEPLOY_KEY&#34; &gt; ~/.ssh/deploy_key
</span></span></span><span class="line"><span class="cl"><span class="sd">          chmod 600 ~/.ssh/deploy_key
</span></span></span><span class="line"><span class="cl"><span class="sd">          printf &#39;%s\n&#39; &#34;$KNOWN_HOSTS&#34; &gt; ~/.ssh/known_hosts
</span></span></span><span class="line"><span class="cl"><span class="sd">          ssh-keygen -lf ~/.ssh/known_hosts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          rsync -rltcz --delete --chmod=D755,F644 \
</span></span></span><span class="line"><span class="cl"><span class="sd">            -e &#34;ssh -i $HOME/.ssh/deploy_key -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts&#34; \
</span></span></span><span class="line"><span class="cl"><span class="sd">            public/ deploy@203.0.113.10:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Key entfernen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">always()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">rm -f ~/.ssh/deploy_key</span><span class="w">
</span></span></span></code></pre></div><p>Was die Details bewirken:</p>
<ul>
<li><strong><code>on: push</code> auf <code>main</code></strong>: Jeder Push veröffentlicht. Mit <code>workflow_dispatch</code> geht es zusätzlich per Knopfdruck.</li>
<li><strong><code>concurrency</code></strong>: Zwei schnelle Pushes hintereinander deployen nacheinander, nicht gleichzeitig.</li>
<li><strong><code>HUGO_VERSION</code> fest gepinnt</strong>: Dieselbe Version wie auf dem Laptop – keine Überraschungen nach einem Hugo-Update.</li>
<li><strong>Secrets über <code>env:</code> statt direkt im Skript</strong>: Gitea setzt den Wert nicht in den Befehlstext ein, sondern übergibt ihn als Umgebungsvariable. Sonderzeichen können so den Befehl nicht verbiegen.</li>
<li><strong><code>ssh-keygen -lf</code></strong> schreibt den Fingerprint ins Log. So sehe ich bei jedem Lauf, dass der richtige Host-Schlüssel benutzt wird.</li>
<li><strong><code>rsync -rltcz</code></strong>: <code>-c</code> vergleicht Prüfsummen statt Zeitstempel. Hugo erzeugt bei jedem Build neue Dateien, mit <code>-c</code> werden trotzdem nur wirklich geänderte übertragen. <code>--delete</code> räumt alte Dateien auf dem Server weg, <code>--chmod</code> setzt saubere Rechte.</li>
<li><strong><code>if: always()</code></strong>: Der private Schlüssel wird auch dann gelöscht, wenn ein Schritt vorher fehlschlägt.</li>
</ul>
<h3 id="gescheitert-teil-2-der-verschwundene-ordner">Gescheitert, Teil 2: Der verschwundene Ordner</h3>
<p>Den Test-Workflow wollte ich im selben Zug loswerden und habe zuerst <code>git rm .gitea/workflows/runner-test.yml</code> ausgeführt und danach die neue <code>deploy.yml</code> angelegt. Ergebnis:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">bash: .gitea/workflows/deploy.yml: No such file or directory
</span></span></code></pre></div><p>Git hatte mit der einzigen Datei auch den nun leeren Ordner entfernt. Committet und gepusht wurde nur das Löschen. Ein <code>mkdir -p .gitea/workflows</code> davor, und es lief.</p>
<h2 id="gescheitert-teil-3-der-artikel-der-nicht-erschien">Gescheitert, Teil 3: Der Artikel, der nicht erschien</h2>
<p>Als Teil 1 dieser Serie fertig war, habe ich ihn committet, gepusht, die Pipeline lief grün durch – und die Seite lieferte <code>404</code>.</p>
<p>Erster Verdacht: Timing. Der <code>curl</code> kam vielleicht, bevor das Deployment fertig war. War es aber nicht. Die Pipeline hatte sauber gebaut, denn andere Änderungen aus demselben Commit waren live.</p>
<p>Die Ursache stand in der ersten Zeile, die man immer übersieht: Der Artikel war im Commit noch auf <strong><code>draft: true</code></strong>. Ich hatte ihn zwar auf veröffentlicht umgestellt, im Projekt war aber eine ältere Fassung der Datei gelandet. Und Hugo macht genau, was es soll: <strong>Entwürfe werden beim Build einfach übersprungen</strong> – ohne Warnung, ohne Fehler, die Pipeline bleibt grün.</p>
<p>Seitdem prüfe ich vor dem Veröffentlichen, was wirklich im Commit steht, und nicht, was ich glaube, gespeichert zu haben:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git show HEAD:content/posts/mein-artikel.md <span class="p">|</span> grep <span class="s1">&#39;^draft&#39;</span>
</span></span></code></pre></div><h2 id="stolperfallen--learnings">Stolperfallen &amp; Learnings</h2>
<ul>
<li><strong>Den Runner isolieren.</strong> Er führt Code aus und hat über das Docker-Socket Root-Rechte auf seinem Host.</li>
<li><strong>Eine Firewall-Ausnahme, so schmal wie möglich:</strong> eine Quell-IP, ein Ziel, ein Dienst.</li>
<li><strong>Deploy-Schlüssel mit <code>restrict</code> und <code>rrsync</code> einschränken.</strong> Dann ist ein gestohlener Schlüssel kein Weg auf den Server.</li>
<li><strong>Host-Schlüssel prüfen statt <code>StrictHostKeyChecking=no</code>.</strong> Einmal den Fingerprint vergleichen, dann ist Ruhe.</li>
<li><strong>Registrierungs-Token nach dem ersten Start entfernen.</strong></li>
<li><strong><code>wget</code> hängt <code>.1</code> an</strong>, wenn die Datei schon existiert. Vor dem Import prüfen.</li>
<li><strong><code>git rm</code> löscht leere Ordner mit.</strong></li>
<li><strong>Eine grüne Pipeline heißt nicht, dass der Artikel online ist.</strong> Hugo überspringt Entwürfe stillschweigend.</li>
</ul>
<h2 id="fazit--ausblick">Fazit &amp; Ausblick</h2>
<p>Die Pipeline ist der Teil, der mir am meisten Spaß macht: Ich schreibe, pushe – und zehn Sekunden später ist der Artikel online. Dabei hat jeder Baustein nur genau die Rechte, die er braucht. Der Runner steht in einem isolierten Netz mit einer einzigen Ausnahme. Der Deploy-Schlüssel kann nur rsync in ein Verzeichnis. Und die Pipeline spricht nur mit einem Server, dessen Schlüssel sie kennt.</p>
<p><strong>Was als Nächstes kommt:</strong> In Teil 4 geht es um die Dinge, die eine Website erst „fertig“ machen: eine funktionierende Kontaktadresse (inklusive eines Mailservers, der nur noch im DNS existierte), SPF, DKIM und DMARC, Impressum und Datenschutzerklärung – und ein Favicon.</p>
]]></content:encoded></item></channel></rss>