Content-Split-Pipeline für Astro JS
Architektur mit Auto-Build über workflow_dispatch, sieben Security-Layern und fine-grained PATs. Zwei Repos, drei Trigger, klare Zuständigkeiten.
Ich bin ein Freund davon, klare Zuständigkeiten zu haben. Was mich bei Astro JS ein bisschen nervt: Code und Content liegen nah beieinander. Das Problem? Keine klaren Zuständigkeiten, alles in einer Codebasis, und es verwischt sich. Was, wenn ich unabhängig am Laptop statt am PC einen Beitrag schreiben will? Ich brauche doch nicht die gesamten node_modules — ich will nur einen Beitrag schreiben und definieren, ob er Draft ist oder live gehen kann. Danach möchte ich mich nicht mehr kümmern müssen.
Und es nervt nicht, weil es technisch kaputt ist. Es nervt, weil jeder Halbsatz, den du als Draft commitest, in der git log deines Code-Repos auftaucht. Es nervt, weil dein Editor zwischen .astro-Komponenten und Markdown-Drafts in derselben Workspace-Tab-Leiste rotiert. Es nervt, weil du zwei Modi gleichzeitig denkst und keiner sauber bleibt.
Das widerspricht meinem inneren Monk von Zuständigkeiten. Also musste eine Lösung her.
Die offensichtliche Lösung: Headless-CMS. Strapi. Sanity. Contentful. Plugin-Marktplatz, Auth-Layer, REST-API, Webhooks, Dashboards, Editor-Plugins, Image-Pipeline, Preview-Modes. Hundert Euro im Monat oder mehr. Und dazwischen Fünf-Minuten-Tutorials auf YouTube, die so tun, als sei das trivial.
Nicht mit karameht! Ich wollte zwei Codeberg-Repos, einen Static-Site-Generator und Apache. Mehr nicht. Hier ist, wie das funktioniert — und warum das niemand sonst so erklärt. Ah ja, es fehlen noch die ganzen Deploy-Keys, Variablen und Secrets — aber das bekommt ihr selber hin, mit ein bisschen Recherche und notfalls mit KI.
Warum überhaupt splitten
Das Problem ist nicht „die Files liegen am falschen Ort”. Das Problem ist klare Verantwortlichkeiten.
Ein Code-Repo wird gecodet. Branches heißen feature/dark-mode oder fix/please-claude-code-fix-my-bug-please. Pull-Requests sind Refactorings. History ist Engineering.
Ein Content-Repo wird beschrieben. Branches existieren nicht, es gibt nur main. Commits heißen „karameht hello-world fertig” oder „draft anpassen Absatz 3”. History ist Redaktion. Und wenn du die Codebasis deiner Website public stellst und Beiträge vorausplanst, ist alles ersichtlich, alles nachvollziehbar. Du willst Transparenz — aber als Leser sollst du nicht wissen, was in drei Wochen live geht. Du sollst wiederkommen. Ich hoffe, dass du wiederkommst.
Mischt du beide, bekommst du eine git log, in der ein Tippfehler-Fix im Markdown neben einem Refactoring-Commit zu BaseLayout.astro steht. Das ist keine Katastrophe — aber es ist nervig. Und nervig summiert sich, bis man um 4:20 Uhr seine Nachbarn weckt, weil man eine git history durchsuchen will und liebevoll seine Tischkante anbeißt.
Dazu kommen zwei Dinge, die niemand erwähnt:
Backup-Klarheit. Code kann ich neu schreiben. Posts, die ich 2017 angefangen und 2023 fertiggestellt habe — die kann ich nicht neu schreiben. Content ist die Substanz. Substanz gehört in ein eigenes Repo mit eigenem Backup-Kontext, eigener Auth-Domäne, eigener Zugriffshistorie.
Multi-Site-Pfad. Wenn karameht.com irgendwann auch Astro wird (Stand heute: vanilla PHP, kein Plan), kann sie aus demselben Content-Repo lesen. Eine Wahrheit, mehrere Renderer.
Was NICHT als Grund zählt: „wäre sauberer” und „andere machen das auch”. Premature ist premature. Konformität ist Konformität. Mein Trigger war konkret: sechs von fünfzehn geplanten Posts, und die Draft-Commits ins Code-Repo waren der Punkt, an dem ich aufgehört habe zu pushen — weil ich keinen Lärm wollte. Ich wollte Zuständigkeiten.
Repos und Rollen
content-refactor daily-rebuild
(privates Codeberg-Repo) (Cron 06:00 UTC, im Astro-Repo)
│ │
│ git push main │ schedule
▼ │
notify-astro.yml │
(Forgejo Actions) │
│ │
│ POST workflow_dispatch │ POST workflow_dispatch
│ ref: develop │ ref: develop
└──────────────┬───────────────────────┘
▼
astro-repo / deploy.yml Renderer
(Astro 6)
│
│ pnpm build mit CONTENT_REPO_PATH
▼
static hosting Live
(staging → main)
Zwei Repos, drei Trigger.
content-refactor ist die Wahrheit für das, was publiziert wird. Drafts, fertige Posts, scheduled Posts mit pubDate in der Zukunft. Privates Codeberg-Repo. Source of Truth.
refactor.karameht.com rendert. Astro 6, eigenes Base-CSS, Layouts, Tokens, Komponenten. Hier gibt es kein einziges Markdown-File mehr. Der Code weiß, wie er rendert — was er rendert, holt er sich woanders.
Wichtig ist die Trennung der Modi: Schreibakte bleiben Hand. Render-Akte sind Mechanik. Content-Push triggert CI, CI baut, CI deployed. Kein Knopf, kein Bestätigungs-Dialog, kein „nochmal kurz pushen”.
daily-rebuild ist dabei kein drittes Repo, sondern ein dritter Workflow im Astro-Repo. Dazu gleich mehr.
Daily Rebuild — Cron als dritter Trigger
Eine Lücke, die nicht offensichtlich ist: Was passiert mit Posts, deren pubDate in der Zukunft liegt?
Beispiel: Ich schreibe heute einen Post fertig, setze pubDate: 2026-07-05 und draft: false, pushe. Der notify-Workflow feuert, Astro baut, Gating greift (pubDate > now → unsichtbar), Post bleibt verborgen. So weit korrekt. Nur — am 5. Juli passiert ohne weiteren Push nichts. Astro baut nur bei Push oder Dispatch. Ohne Build am pubDate-Tag erscheint der Post nie.
Lösung: ein dritter Trigger via Cron. Täglich 06:00 UTC dispatcht ein eigener Workflow deploy.yml mit ref: develop.
# .forgejo/workflows/daily-rebuild.yml (im Astro-Repo)
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
trigger:
runs-on: codeberg-small-lazy
container:
image: docker.io/alpine:3.23
steps:
- name: Trigger daily Astro rebuild on develop
run: |
apk add --no-cache curl
curl -fsSL --max-time 30 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
https://codeberg.org/api/v1/repos/<your-user>/<your-astro-repo>/actions/workflows/deploy.yml/dispatches \
-d '{"ref": "develop"}'
echo "Daily dispatch sent to Astro-Repo (ref: develop)"
Zwei Sachen, die hier wichtig sind:
${{ secrets.GITHUB_TOKEN }} wird von Forgejo Actions auto-provided.1 Ja, das heißt wirklich GITHUB_TOKEN — auch auf Codeberg. Forgejo stellt das Auto-Token aus GitHub-Kompatibilitätsgründen unter diesem Namen bereit, nicht weil hier irgendwo GitHub im Spiel ist. Nicht umbenennen, sonst ist das Secret leer und der Run failt mit 401. Gleiches Repo, kein neuer Secret-Eintrag nötig — der Workflow läuft im Astro-Repo und dispatcht im selben Repo.
Cron dispatcht immer nur develop (staging). Nie prod. Prod-Push bleibt manuelle Hand. Die drei-Stufen-Promotion (schreiben → staging → prod) bleibt heil.
Drei Trigger-Pfade für deploy.yml insgesamt:
- push auf
developodermain(Astro-Repo) workflow_dispatchvon content-refactornotify-astro.yml(Content-Push)workflow_dispatchvondaily-rebuild.yml(Cron)
Warum ein eigener Workflow statt Erweiterung von deploy.yml: Separation of Concerns. Cron-Versagen blockiert keine Deploys, Deploy-Versagen blockiert keinen Cron. Beide unabhängig debuggbar. Manueller workflow_dispatch auf daily-rebuild ist außerdem Test-Hook fürs pubDate-Gating — drücken, schauen, ob der scheduled Post live geht.
Phase A — Content-Repo
Codeberg-Repo content-refactor, privat. Drafts-Schutz über zwei Layer: Das Repo selbst ist privat (nur ich sehe den Code auf Codeberg), und der Astro-Build filtert draft: true raus (selbst wenn jemand den Build-Output abfangen würde, kommen nur Live-Posts an).
Struktur bewusst flach:
content-refactor/
├── blog/
│ ├── architektur/
│ ├── notizen/
│ └── security/
├── README.md
└── .gitignore
blog/ als Top-Level-Ordner bleibt — kein Rename auf posts/. Begründung: Das war vorher in Astro auch src/content/blog/, das mentale Modell bleibt gleich, die Loader-Anpassung im Astro-Repo bleibt minimal.
Kein zod-Schema im Content-Repo. Single-Source-of-Truth fürs Schema bleibt refactor.karameht.com/src/content.config.ts. Im Content-Repo liegt nur eine README.md mit Frontmatter-Regeln als menschliche Doku. Frontmatter-Drift wird beim Build als zod-Error sichtbar — gewollt. Ein Schema an zwei Orten zu pflegen wäre genau die Sorte Redundanz, die später jemand vergisst zu synchronisieren.
Phase B — Astro liest extern
Die Loader-Base wird zur Laufzeit gewählt:2
// src/content.config.ts
const CONTENT_BASE = process.env.CONTENT_REPO_PATH ?? '../content-refactor/blog';
const blog = defineCollection({
loader: glob({ base: CONTENT_BASE, pattern: '**/[^_]*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
draft: z.boolean().optional().default(false),
}),
});
Default ist ein relativer Pfad — funktioniert lokal, weil beide Repos parallel im selben Parent-Folder liegen. CI überschreibt via env-var auf /tmp/content-refactor/blog nach dem Clone.
Was sich nicht geändert hat: zod-Schema, glob-Pattern, Helper in src/lib/posts.ts, alle Komponenten, BaseLayout. Eine einzige Loader-Zeile, der Rest blieb stehen.
Verifikations-Trick: Statt src/content/blog/ direkt zu löschen, habe ich den Ordner per mv nach /tmp/blog-backup-* verschoben. Rollback wäre ein mv zurück, fünf Sekunden. pnpm build lief durch — acht Pages built, alle Live-Posts drin, alle Drafts gefiltert. Das war der Beweis, dass extern gelesen wird. rm -rf hätte auch funktioniert (git restore wäre Rollback), aber mv ist mental schneller und visuell verifizierbar. Solche Tricks sind günstig, kosten nichts, geben Sicherheit.
Phase C — workflow_dispatch chain
Wichtige Erkenntnis vorweg: Forgejo Actions (was Codeberg nutzt) hat KEIN repository_dispatch.3 Das ist GitHub-Pattern. Wer das überträgt, sucht stundenlang nach einem Event-Type, den es nicht gibt.
Stattdessen gibt es workflow_dispatch — mit zwei Trigger-Pfaden: UI oder API. Das ist tatsächlich sauberer als repository_dispatch, weil du einen konkreten Target-Workflow ansprichst statt einen Event-Type. Du sagst: „triggere deploy.yml mit ref develop”. Eindeutig.
Der notify-Workflow im content-refactor-Repo:
# .forgejo/workflows/notify-astro.yml
on:
push:
branches:
- main
paths:
- 'blog/**'
jobs:
notify:
runs-on: codeberg-small-lazy
container:
image: docker.io/alpine:3.23
steps:
- name: Trigger Astro rebuild on develop
run: |
apk add --no-cache curl
curl -fsSL --max-time 30 -X POST \
-H "Authorization: token ${{ secrets.ASTRO_DISPATCH_TOKEN }}" \
-H "Content-Type: application/json" \
https://codeberg.org/api/v1/repos/<your-user>/<your-astro-repo>/actions/workflows/deploy.yml/dispatches \
-d '{"ref": "develop"}'
echo "Dispatch sent to Astro-Repo (ref: develop)"
Schmal: Alpine, curl, ein POST. Wenn die API mit 204 No Content antwortet, grün. Bei 401/403/404 failt CI und ich sehe sofort, dass Token oder URL falsch sind.
-fsSL bei curl: fail-on-http-error, silent, show-error, follow-redirects. Standard-Pattern für „muss klappen oder abbrechen”. --max-time 30 killt curl, wenn die API hängt — der Cold-Start des lazy-Runners davor ist davon unbetroffen.
paths: blog/** filtert: README-Edits, .gitignore-Anpassungen, Workflow-Edits triggern keinen Astro-Build. Nur echte Content-Änderungen. alpine:3.23 statt :latest — pinned, weil supply-chain-konsistent mit dem Rest des Setups.
"ref": "develop" ist Absicht. Notify triggert immer nur staging, nie prod. Prod-Push bleibt manuelle Hand (push auf main im Astro-Repo). Drei-Stufen-Promotion bleibt erhalten: schreiben → staging → prod.
Fine-grained Personal Access Token. Codeberg unterstützt PATs mit drei Begrenzungen:4
- Repository-Access: „Limit access to a specific list” → nur
<your-user>/<your-astro-repo> - Permissions:
repository: Read and Write(alles andereNo access) - Name:
content-refactor → astro dispatch(man erkennt den Zweck wieder bei Rotation)
Worst Case Leak: Ein Angreifer kann Workflows in genau einem Repo triggern. Mehr kann das Token nicht. Kein User-Scope, kein Org-Scope, keine Notification-API, keine Secrets-API. Nichts.
Das Token liegt als Forgejo-Secret ASTRO_DISPATCH_TOKEN im content-refactor-Repo. Wird im Workflow als ${{ secrets.ASTRO_DISPATCH_TOKEN }} referenziert. Im Container ephemeral — weg nach Run.
Defense in Depth
| Layer | Was | Wenn das bricht |
|---|---|---|
| Repo-Visibility | content-refactor privat | Drafts werden öffentlich sichtbar auf Codeberg |
| Build-Filter | zod + posts.ts filtern draft: true | Drafts würden in dist/ landen |
| Deploy-Key Scope | read-only auf content-refactor | Angreifer könnte content-refactor schreiben |
| PAT Scope | nur ein Repo, nur repository-API | Angreifer könnte alle Repos triggern |
| HTTPS + SSH | beide CI-Chains verschlüsselt | MITM-Risiko |
| StrictHostKeyChecking | known_hosts vor clone | Falscher Server würde akzeptiert |
| Ephemeral Container | Keys weg nach Run | Secrets würden persistieren |
Sieben Layer. Damit ein Draft öffentlich wird, müssen die ersten vier gleichzeitig brechen: Repo wird versehentlich public und zod-Filter failt und Deploy-Key wird kompromittiert und PAT wird kompromittiert. Jeder einzelne Layer ist ein Stopper.
Das ist hier kein Paranoia-Theater. Ich schreibe seit 2017 Drafts, die nie öffentlich werden sollten — Halbfertiges, Persönliches, Sachen, die liegen, weil sie noch nicht durchdacht sind. Versehentliche Veröffentlichung wäre kein „peinlich, schnell löschen”. Das wäre Substanz-Verlust. Das Internet vergisst nicht.
Host-Key-Pinning explizit: Statt ssh-keyscan codeberg.org zur Build-Zeit liegt der Fingerprint statisch in einer Repo-Variable CODEBERG_HOST_FINGERPRINT. Wird vor dem Clone in known_hosts geschrieben. StrictHostKeyChecking=yes ausgewertet gegen genau diesen Wert. TOFU-Window zu, MITM-Risiko entfällt.5
Pitfalls
codeberg-tiny-lazy ist instabil. Symptom: ⚙️ [runner]: context deadline exceeded vom podman socket. Tritt nicht reproduzierbar auf — manchmal im Build, manchmal im Deploy, manchmal gar nicht. Wechsel auf codeberg-small-lazy: gleiche „lazy”-Semantik (freier verfügbar, gut für non-critical Static-Builds), aber stabil. Direkte Lesson fürs nächste Repo: niemals tiny-lazy für CI.6
Forgejo ≠ GitHub Actions bei Dispatch-Patterns. Hatte den Plan ursprünglich mit repository_dispatch gebaut (GitHub-Idiom). Forgejo unterstützt das nicht direkt. Doku-Recherche statt Annahmen ist hier kein Spruch — das war konkret eine Stunde verschenkt. Bei abweichenden CI-Systemen immer die Plattform-Doku lesen, nicht GitHub-Patterns übertragen.
Schema-Änderung als stiller Failure-Pfad. paths: blog/** filtert Content-Pushes sauber — aber wenn ich das zod-Schema in content.config.ts im Astro-Repo verschärfe und ein bestehender (auch scheduled) Post dadurch zod-bricht, failt der Build. Beim nächsten Cron um 06:00. Heißt: Ein scheduled Post geht stillschweigend nicht live, und ich merke es erst, wenn ich nachschaue. Konsequenz: Schema-Changes immer mit lokalem pnpm build über den vollen Content-Bestand testen, bevor sie ins Astro-Repo gehen — nicht nur gegen die Posts, die gerade live sind. Notification auf failed CI-Runs ist hier kein Nice-to-have, sondern Pflicht.
Memory ≠ Repo. Beim Recap zu Session-Beginn sagte mein Memory-System „Impressum + Datenschutz live”. git status sagte: uncommitted, WIP. Memories sind Schnappschüsse, nicht Live-Wahrheit. Vor Status-Aussagen immer den Working-Tree checken.
mv statt rm als Migration-Strategie. Beim Astro-Cleanup wurde src/content/blog/ per mv nach /tmp/ verschoben statt direkt gelöscht. Rollback in fünf Sekunden, visuell verifizierbar, keine Angst beim Schritt. Solche Tricks sind günstig — und genau deshalb mache ich sie. Ein verschobener Ordner ist ein Backup mit null Aufwand.
Aphorismus
content := blog
code := renderer(content)
site := deploy(build(code))
∀ post ∈ content: post.draft = true ⊕ post.live = true
trust := scope(token) × scope(key) × |private(repo)|
Schluss
Was diese Pipeline beweist: Man kann eine sichere, vollautomatische Content-Pipeline mit zwei Repos, einer Handvoll YAML-Zeilen und null CMS-Backend bauen. Kein Plugin-Marketplace. Kein Auth-Server. Kein npm-Slop. Vanilla Codeberg, vanilla Astro, vanilla Apache.
Niemand wird dir das so verkaufen. Es gibt keinen Kurs, den ich dir verlinken kann, kein YouTube-Video mit Affiliate-Link, keinen Tutorial-Funnel, der dich am Ende auf ein 99-Euro-im-Monat-SaaS verweist. Das hier zu bauen kostet einen halben Tag und null laufende Kosten. Das ist nicht im Interesse von Leuten, die dir Pipelines verkaufen wollen.
Genau deshalb steht es hier.
Das Tool ändert sich. Die Architektur bleibt.
Euer Mehmet