Ghost war lange mein Blog-System der Wahl. Markdown-Editor, schönes Casper-Theme, alles schick. Aber mit der Zeit hat es mich genervt: Membership-Features die ich nie brauchte, eine Datenbank die gepflegt sein will, und ein Deployment das immer ein bisschen mehr war als nötig. Irgendwann habe ich angefangen, meine Notizen in Obsidian zu schreiben – und da kam die Idee: warum nicht alles in Git, alles in Markdown, alles selbst gehostet?
Das Ergebnis ist ein Stack aus Forgejo, Hugo mit Blowfish-Theme und Obsidian – und der fühlt sich heute deutlich richtiger an.
Warum der Wechsel von Ghost? #
Ghost ist kein schlechtes System. Aber es ist ein System für andere Zwecke als meinen Blog. Die Membership-Verwaltung, der Newsletter, die Analytics – alles Features die bei mir dauerhaft deaktiviert waren und trotzdem Ressourcen gefressen haben.
Der entscheidende Unterschied zu Hugo: Ghost speichert Inhalte in einer Datenbank. Hugo kennt nur Dateien. Markdown-Dateien, in einem Ordner, unter Versionskontrolle. Das bedeutet:
- Kein Datenbankbackup mehr nötig
- Inhalte liegen in Git – vollständige History, Branching, alles
- Bearbeiten geht überall wo ein Texteditor läuft
- Deployment ist ein
git push
Ghost-Inhalte lassen sich übrigens per Settings → Labs → Export als JSON exportieren, und dann manuell oder per Skript nach Markdown konvertieren.
Forgejo: Das Herzstück #
Forgejo ist ein leichtgewichtiger, selbst hostbarer Git-Server – ein Community-Fork von Gitea, nachdem die Entwicklung dort in eine problematische Richtung lief. Im Vergleich zu GitLab ist es ein Witz: kein RAM-Hunger, kein Komplexitäts-Overhead, einfach ein sauberes Docker-Image.
Docker Compose Setup #
services:
forgejo:
image: codeberg.org/forgejo/forgejo:14
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- ./data:/data:z
ports:
- "3000:3000"
- "2222:22"
restart: unless-stopped
forgejo-runner:
image: code.forgejo.org/forgejo/runner:9
container_name: forgejo-runner
working_dir: /data
command: ["/bin/forgejo-runner", "daemon", "--config", "/data/config.yml"]
user: "0:0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./runner-data:/data
restart: unless-stoppedEin paar wichtige Hinweise dazu:
- Forgejo hat kein
:latest-Tag – bewusst, weil Major-Version-Upgrades manuelle Eingriffe erfordern. Das14-Tag zeigt immer auf den neuesten Patch-Release der 14.x-Reihe. - Der Runner braucht einen eigenen Volume-Ordner, getrennt vom Forgejo-Datenordner.
- Auf SELinux-Systemen (Fedora, RHEL) das
:z-Flag an Volumes anhängen.
Runner einrichten #
Zuerst die Konfigurationsdatei generieren:
# Runner kurz im no-op Modus starten
docker compose up -d forgejo-runner
# Config generieren
docker exec -it forgejo-runner forgejo-runner generate-config | sudo tee ./runner-data/config.yml > /dev/null
# Runner registrieren
docker exec -it forgejo-runner forgejo-runner registerBei der Registrierung: Forgejo-URL eingeben, Token aus Site Administration → Actions → Runners → Create new Runner einfügen, Label docker lassen.
Danach in der Compose-Datei den daemon-Befehl aktivieren und neu starten:
docker compose up -d --force-recreate forgejo-runnerIn Forgejo unter Site Administration → Actions → Runners sollte der Runner jetzt als inaktiv erscheinen – das ist korrekt, er wartet auf Jobs.
SSH auf Port 2222 #
Da Forgejo auf einem anderen Server läuft als Caddy, kommt SSH direkt über Port 2222 rein – kein Caddy-Proxy nötig. Am Router Port 2222 auf den Forgejo-Server forwarden, fertig.
In ~/.ssh/config:
Host git.example.de
Port 2222
User gitDann funktioniert git clone git@git.example.de:user/repo.git ganz normal.
Bestehende Repos von Codeberg migrieren #
Forgejo hat eine eingebaute Migrationsfunktion: New Repository → Migrate → Gitea (Codeberg läuft selbst auf Forgejo, wird aber als Gitea-kompatibel erkannt). Issues, Labels, Milestones und Releases werden optional mitgezogen. Öffentliche Repos funktionieren ohne Token.
Hugo + Blowfish: Der Blog #
Hugo ist ein Static Site Generator – kein laufender Prozess, keine Datenbank, nur ein Build-Schritt der Markdown in HTML verwandelt.
Warum Blowfish? #
Nach einigem Hin und Her mit dem Paper-Theme bin ich bei Blowfish gelandet. Es ist feature-reich, aktiv gepflegt, und kommt Casper optisch am nächsten. Mit 500 MB als Submodule ist es nicht gerade schlank, aber die Konfigurierbarkeit ist es wert.
Grundkonfiguration #
# Repo clonen
git clone https://user:TOKEN@git.example.de/user/blog.git ~/blog
cd ~/blog
# Hugo initialisieren
hugo new site . --force
# Blowfish als Submodule
git submodule add https://github.com/nunocoracao/blowfish.git themes/blowfish
# Blowfish-Konfiguration übernehmen
mkdir -p config/_default
cp themes/blowfish/config/_default/*.toml config/_default/
rm hugo.tomlIn config/_default/hugo.toml:
baseURL = "https://blog.example.de"
defaultContentLanguage = "en"In config/_default/languages.en.toml:
title = "Mein Blog"
[params.author]
name = "Martin"
bio = "Self-hosting Enthusiast"
image = "img/avatar.jpg"In config/_default/params.toml die wichtigsten Einstellungen:
colorScheme = "noir"
enableCodeCopy = true
[homepage]
layout = "hero"
homepageImage = "img/background.jpg"
showRecent = true
showRecentItems = 5
[article]
showHero = true
heroStyle = "big"
showTableOfContents = trueBlogpost-Struktur #
Jeder Post bekommt einen eigenen Ordner – das ist Hugos Page Bundle Konzept und der Schlüssel dazu dass Bilder sowohl in Hugo als auch in Forgejo korrekt angezeigt werden:
content/posts/
└── mein-post/
├── index.md
├── featured.webp ← Hero-Bild, automatisch erkannt
└── images/
└── screenshot.webpFrontmatter-Vorlage: draft: true - für alle Blog-Einträge die noch nicht veröffentlicht sein sollen.
---
title: "Titel des Posts"
date: 2026-01-01
draft: false
description: "Kurze Zusammenfassung"
tags: ["linux", "selfhosting"]
categories: ["tech"]
author: "Martin"
---Bilder im Text relativ verlinken:
Das funktioniert in Hugo, Forgejo und Obsidian gleichermaßen.
Schriftarten selbst hosten #
Keine Google Fonts – Schriftarten liegen direkt im Repo unter static/fonts/. In assets/css/custom.css:
@font-face {
font-family: 'Atkinson Hyperlegible Next';
src: url('/fonts/AtkinsonHyperlegibleNextVF-Variable.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: '0xProto';
src: url('/fonts/0xProto-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
html, body {
font-family: 'Atkinson Hyperlegible Next', sans-serif;
}
code, pre, kbd {
font-family: '0xProto', monospace;
}In params.toml aktivieren:
customCSS = ["css/custom.css"]CI/CD: Automatisches Deployment #
Der Workflow: Push zu Forgejo → Runner baut Hugo → fertige HTML-Files landen per rsync auf dem Webserver → Caddy served sie statisch.
SSH-Key für Deployment einrichten #
# Auf dem Runner-Server
ssh-keygen -t ed25519 -C "forgejo-runner" -f ~/.ssh/forgejo_runner -N ""
cat ~/.ssh/forgejo_runner.pub
# → Public Key auf dem Webserver in ~/.ssh/authorized_keys eintragen
# Private Key Base64-kodieren (verhindert Zeilenumbruch-Probleme in Secrets)
base64 -w 0 ~/.ssh/forgejo_runnerDen Base64-String als Secret DEPLOY_KEY in Forgejo hinterlegen: Repository → Settings → Secrets → Add Secret. Dazu noch DEPLOY_HOST und DEPLOY_USER.
Workflow-Datei #
# .forgejo/workflows/deploy.yml
name: Deploy Hugo
on:
push:
branches:
- main
jobs:
deploy:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Build Hugo
uses: docker://hugomods/hugo:latest
with:
args: hugo
- name: Deploy
uses: docker://alpine:latest
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
with:
args: |
sh -c "
apk add --no-cache openssh rsync &&
mkdir -p ~/.ssh &&
echo $DEPLOY_KEY | base64 -d > ~/.ssh/id_ed25519 &&
chmod 600 ~/.ssh/id_ed25519 &&
ssh-keyscan $DEPLOY_HOST >> ~/.ssh/known_hosts &&
rsync -avz public/ $DEPLOY_USER@$DEPLOY_HOST:/var/www/blog/
"Caddy-Konfiguration #
blog.example.de {
root * /var/www/blog
file_server
}Caddy läuft in Docker und braucht /var/www/blog als Volume-Mount:
volumes:
- /var/www/blog:/var/www/blogObsidian: Der tägliche Workflow #
Obsidian ist eine lokale Markdown-App – der Vault-Ordner ist einfach das geklonte Blog-Repo. Das Obsidian Git-Plugin übernimmt commit, pull und push automatisch im Hintergrund.
Einrichtung #
git clone https://user:TOKEN@git.example.de/user/blog.git ~/blog
# Credentials dauerhaft speichern
cd ~/blog
git remote set-url origin https://user:TOKEN@git.example.de/user/blog.gitObsidian öffnen → ~/blog als Vault → Community Plugins → Obsidian Git installieren.
Plugin-Einstellungen:
- Vault backup interval: z.B. 10 Minuten (automatisches commit + push)
- Pull before push: aktivieren (verhindert Konflikte)
- Auto pull interval: z.B. 5 Minuten
Wichtige .gitignore-Einträge
#
.obsidian/workspace.json
public/
resources/
.hugo_build.lockworkspace.json ändert sich bei jedem Obsidian-Start und gehört nicht ins Repo – ohne diesen Eintrag gibt es ständig unnötige Konflikte.
Der tägliche Ablauf #
Ein neuer Post entsteht so:
mkdir -p content/posts/mein-neuer-post/images
# index.md anlegen, Frontmatter einfügen, schreibenOder direkt in Obsidian – neuen Ordner anlegen, index.md erstellen, schreiben. Obsidian Git pushed automatisch, der Runner baut, wenige Minuten später ist der Post live.
Für Drafts einfach draft: true im Frontmatter – Hugo baut sie nicht mit, sie liegen aber ganz normal im Repo.
Konflikte vermeiden #
Der häufigste Fallstrick: in Forgejo direkt etwas bearbeiten während lokal auch Änderungen liegen. Resultat ist ein rejected-Fehler beim Push. Lösung:
git pull --rebase
git push--rebase legt lokale Commits sauber auf den Remote-Stand drauf, ohne Merge-Commit.
Fazit #
Der Stack ist jetzt: Forgejo als Git-Server und CI/CD-Trigger, Hugo + Blowfish als Static Site Generator, Obsidian als Schreibumgebung, Caddy als Webserver. Alles selbst gehostet, alles Open Source, keine externen Abhängigkeiten.
Was ich gegenüber Ghost vermisse: ehrlich gesagt wenig. Der Workflow ist schneller, die Infrastruktur schlanker, und das Schreiben in Obsidian fühlt sich natürlicher an als jeder Web-Editor. Der einzige echte Mehraufwand war die Einrichtung – aber die ist einmalig.
obsidian → git push → forgejo actions → hugo build → rsync → caddy → fertig