Skip to main content
Von Ghost zu Hugo: selbst gehosteter Blog-Stack

Von Ghost zu Hugo: selbst gehosteter Blog-Stack

·1353 words·7 mins
Table of Contents

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-stopped

Ein paar wichtige Hinweise dazu:

  • Forgejo hat kein :latest-Tag – bewusst, weil Major-Version-Upgrades manuelle Eingriffe erfordern. Das 14-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 register

Bei 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-runner

In 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 git

Dann 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.toml

In 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 = true

Blogpost-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.webp

Frontmatter-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:

![Beschreibung](images/screenshot.webp)

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_runner

Den 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/blog

Obsidian: 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.git

Obsidian ö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.lock

workspace.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, schreiben

Oder 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