feat(site): add /apps and /homelab pages with shared nav

This commit is contained in:
Patrick Plate
2026-06-22 17:28:13 +02:00
parent 508ad9a7f8
commit 29015438e8
3 changed files with 243 additions and 1 deletions
+113
View File
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homelab — plate-software.de</title>
<meta name="description" content="Wie alles auf *.plate-software.de aus einem TrueNAS zu Hause, einem winzigen VPS und einem IONOS-Reverse-Proxy ins Netz kommt." />
<meta name="author" content="Patrick Plate" />
<meta name="robots" content="index, follow" />
<meta property="og:title" content="Homelab — plate-software.de" />
<meta property="og:description" content="TrueNAS + frps + IONOS-Reverse-Proxy: wie aus „push to Gitea" in unter einer Minute öffentliches HTTPS wird." />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<meta property="og:locale:alternate" content="en_US" />
<link rel="icon" href="data:image/svg+xml,&lt;svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22&gt;&lt;text y=%22.9em%22 font-size=%2290%22&gt;⬡&lt;/text&gt;&lt;/svg&gt;" />
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body>
<header>
<div class="wrap nav">
<a class="brand" href="/"><span class="mark"></span> plate-software.de</a>
<nav class="nav-links">
<a href="/">
<span data-lang-de>Start</span>
<span data-lang-en>Home</span>
</a>
<a href="/apps/">Apps</a>
<a href="/homelab/" class="active">Homelab</a>
</nav>
<div class="lang-toggle" role="group" aria-label="Language">
<button id="btn-de" class="active" onclick="setLang('de')">DE</button>
<button id="btn-en" onclick="setLang('en')">EN</button>
</div>
</div>
</header>
<main>
<section class="hero" style="padding: 56px 0 24px;">
<div class="wrap">
<a class="back-link" href="/">
<span data-lang-de>← Zurück</span>
<span data-lang-en>← Back</span>
</a>
<h1>
<span data-lang-de>Das <span class="grad">Homelab</span></span>
<span data-lang-en>The <span class="grad">Homelab</span></span>
</h1>
</div>
</section>
<section style="padding: 0 0 56px;">
<div class="wrap">
<div class="prose">
<p data-lang-de>Alles, was unter *.plate-software.de erreichbar ist, läuft auf einem TrueNAS bei mir zu Hause. Öffentlich wird das Ganze über einen winzigen VPS, der nichts anderes tut, als einen frps-Tunnel zu halten — davor steht ein IONOS-Reverse-Proxy, der TLS terminiert. Ein <code>git push</code> nach Gitea, der Runner baut das Docker-Image, und in unter einer Minute steht die neue Version öffentlich unter HTTPS.</p>
<p data-lang-en>Everything you can reach under *.plate-software.de runs on a TrueNAS at home. The whole thing is exposed publicly through a tiny VPS whose only job is to hold an frps tunnel — fronted by an IONOS reverse-proxy that terminates TLS. A <code>git push</code> to Gitea, the runner builds the Docker image, and in under a minute the new version is live on public HTTPS.</p>
<h3 data-lang-de>Der Stack</h3>
<h3 data-lang-en>The stack</h3>
<p data-lang-de>TrueNAS SCALE hostet Gitea, den act_runner und pro App einen Docker-Stack. Der VPS fährt ausschließlich frps (frp-Server) — kein Anwendungs-Code, keine Datenbank, einfach nur Tunnel-Endpunkt. Eingehender Traffic landet zuerst auf IONOS-Apache-vHosts, die per Reverse-Proxy in den frps-Tunnel zeigen. Let's-Encrypt-Zertifikate kommen via acme.sh.</p>
<p data-lang-en>TrueNAS SCALE hosts Gitea, the act_runner, and a per-app Docker stack. The VPS runs only frps (the frp server) — no application code, no database, just a tunnel endpoint. Public traffic enters IONOS Apache vhosts that reverse-proxy into the frps tunnel. Let's Encrypt certificates via acme.sh.</p>
<h3 data-lang-de>Warum so?</h3>
<h3 data-lang-en>Why this setup?</h3>
<p data-lang-de>Vor allem die Kosten: rund 10 €/Monat für VPS und Domain — bei einer vergleichbar gemanageten Hosting-Lösung wären es eher 80 €. Dazu volle Kontrolle, kein Vendor-Lock-in und ehrlich gesagt: Infrastruktur zu bauen ist die halbe Miete vom Spaß.</p>
<p data-lang-en>Mostly cost: about €10/month for VPS plus domain, versus around €80/month for an equivalent managed hosting setup. Add full control, no vendor lock-in, and frankly — building the infrastructure is half of why this is fun.</p>
<h3 data-lang-de>Was ich gelernt habe</h3>
<h3 data-lang-en>Lessons learned</h3>
<p data-lang-de>Zwei systemische Stolpersteine sind hängen geblieben: in einer Proxy-Kette muss <code>auth()</code> von NextAuth verwendet werden, <code>getToken()</code> bricht durch die mehrfachen Hops. Und die DNS-A-Records zeigen auf IONOS, nicht auf den VPS — wer das vergisst, wundert sich lange über kaputtes Routing.</p>
<p data-lang-en>Two systemic gotchas have stuck with me: in a proxy chain you need NextAuth's <code>auth()</code>, not <code>getToken()</code> — the latter breaks across the multiple hops. And the DNS A-records point to IONOS, not the VPS — forget that and you'll spend a long time wondering why routing is broken.</p>
</div>
</div>
</section>
</main>
<footer>
<div class="wrap foot-row">
<div>© <span id="year"></span> Patrick Plate · plate-software.de</div>
<div class="foot-links">
<a href="/">
<span data-lang-de>Start</span>
<span data-lang-en>Home</span>
</a>
<a href="/apps/">Apps</a>
<a href="/homelab/">Homelab</a>
</div>
</div>
<div class="wrap">
<p class="deploy-badge" data-de="Letzter Deploy: <!--DEPLOY_INFO-->" data-en="Last deploy: <!--DEPLOY_INFO-->"></p>
</div>
</footer>
<script>
function setLang(lang) {
document.documentElement.lang = lang;
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
document.querySelectorAll('[data-de][data-en]').forEach(function (el) {
el.textContent = el.getAttribute(lang === 'en' ? 'data-en' : 'data-de');
});
try { localStorage.setItem('ps-lang', lang); } catch (e) {}
}
(function () {
var saved;
try { saved = localStorage.getItem('ps-lang'); } catch (e) {}
var nav = (navigator.language || 'de').slice(0, 2);
setLang(saved || (nav === 'en' ? 'en' : 'de'));
var y = document.getElementById('year');
if (y) y.textContent = new Date().getFullYear();
})();
</script>
</body>
</html>