Umzug von Github Pages

Projekt-Steckbrief

  • Schwierigkeitsgrad: Mittel 3/5
  • Kosten: 5-50€/Monat
  • Zeitaufwand: ~10h

Motivation

Ich habe mich entschieden, meine Website von Github-Pages auf meinen eigenen Server umzuziehen. Dies geschieht aus zwei Gründen: Zum einen möchte ich den Server in Deutschland stehen haben (Datenschutz) und zum anderen bietet mir Github Pages keine einfach zu handhabende Möglichkeit, meine Site zweisprachig anzubieten.

Zusätzlich kann ich ebenfalls ein paar lang gehegte Wünsche realisieren: Ein eigener mini-Dateiserver und eine Webseite für meinen kleinen Nebenerwerb werden jetzt möglich. Aufsetzen werde ich sie allerdings in einem späteren Schritt.

Überblick

In diesem Projekt werde ich einen virtuellen Server anmieten und dort per SSH das Container-Managementsystem Docker installieren.

In einem Container soll eine Instanz von gitea laufen, welche das Versionsmanagement für mich übernehmen und über sogenannte Actions eine CI/CD-Pipeline1 bereitstellen wird.

Durch die Automation mit Gitea Actions möchte ich dann in der Lage sein, die von Jekyll per Workflow-Datei gebaute Site auf einem caddy-Server zu veröffentlichen, also in “Produktion” zu geben.

caddy stellt hier nicht nur den Server, sondern auch einen Reverse Proxy zur Verfügung, welcher mir Portmapping und die Adresszuordnung abnehmen kann. Ganz nebenbei kümmert sich caddy noch vollautomatisiert um die Zertifikatsverwaltung für https über Let’s Encrypt.

Da auch caddy in Docker laufen wird, müssen virtuelle Netzwerke angelegt und korrekt konfiguriert werden, sodass die Container miteinander “reden” können.

Schreiten wir zur Tat - aber immer Schritt für Schritt.

Konfiguration meines Servers

Die folgenden Abschnitte beschreiben Schritt für Schritt, wie ich vorgegangen bin, meinen Cloud-Server einzurichten und für das Veröffentlichen dieses Blogs vorzubereiten.

SSH auf dem Server einrichten

Direkt nachdem ich den sogenannten “Cloud-Server” angemietet hatte, habe ich den Zugriff per SSH konfiguriert und damit den Passwort-Login deaktiviert. Dafür prüfe ich zuerst, ob mein ssh-agent bereits läuft bzw. starte ihn direkt mit dem Befehl eval ssh-agent.

Sodann erstelle ich mir ein neues Schlüsselpaar per Befehl

ssh-keygen -t ed25519 -C "your_email@example.com" -f "~/.ssh/mein-cloud-server"

Dieses Beispiel erzeugt Schlüsseldateien mit dem Verfahren (-t) ed25519, welche per Kommentar (-C) eine Mailadresse enthalten und unter dem Dateinamen (-f) mein-cloud-server gespeichert werden.

Den öffentlichen Schlüssel mein-cloud-server.pub habe ich dann im Webportal meines Servers unter “Security” eingegeben und den dann erhaltenen Fingerprint verifiziert mittels

ssh-keygen -lf <fingerprint_from_server> "~/.ssh/mein-cloud-server"

Anschließend habe ich die config-Datei im .ssh-Verzeichnis so angepasst, dass ich nur noch ssh server statt der IP-Adresse eingeben muss, um mich drauf zu verbinden - Faulheit siegt 😀

#.ssh/config
Host server
  User root
  Hostname ip_address_of_server
  PreferredAuthentications publickey
  IdentityFile ~/.ssh/mein-cloud-server

Gebe ich nun ssh server in die Konsole ein, so erscheint beim ersten Mal die Meldung, dass der Verbindungsaufbau zu einem unbekannten Partner mit Fingerprint soundso stattfinden wird und bittet um explizite Zustimmung. Nun kann der Fingerprint mit dem des Servers (auf der Webseite des Anbieters) abgeglichen und somit sichergestellt werden, dass man auch wirklich zu dem angestrebten, eigenen Server verbindet und nicht zu einem Dritten, der die Anfragen lediglich transparent weiterleitet. Stimmt man mit yes zu, so wird der Fingerprint der known_hosts-Datei hinzugefügt und das Zielsystem in Zukunft als bekannt angesehen.

Docker installieren 🐳

Auf meinem Server läuft ein Standard-Ubuntu, für die die von Docker bereitgestellte Anleitung zur Installation des Docker-Repository per apt reibungslos funktioniert. Einfach per SSH drauf einwählen und die entsprechenden Befehle ausführen - fertig. Dabei wird der von mir benutzte Dienst docker-compose gleich mit installiert, welcher Konfigurationsdateien für die Zielcontainer interpretieren und die Container mit den entsprechenden Parametern gleich richtig hoch- und herunterfahren kann.

caddy installieren 🛒

Zuerst lege ich den Ordner per mkdir caddy2 im Stammverzeichnis meines Servers an - soviel Zeit muss sein. Um caddy im Docker laufen lassen zu können, habe ich in diesem Ordner die folgende docker-compose.yml erstellt:

#caddy2/docker-compose.yml

version: '3'
services:
  caddy:
    image: caddy:latest
    container_name: caddy
    ports:
      - "80:80"
      - "443:443"
    environment:
      - ACME_AGREE=true
    restart: unless-stopped
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./caddy_data:/data
      - ./caddy_config:/config
      - ./www:/www
    networks:
      - caddy-proxy

networks:
  caddy-proxy:
    external: true

Diese Datei sagt Docker, dass es für den Service das aktuellste Image von caddy verwenden und dem Container den Namen caddy geben soll. Des Weiteren sollen die Ports 80 (http) sowie 443 (https) verfügbar gemacht und die Zertifikatsverwaltung per ACME_AGREE zugelassen werden. Der Container bindet die von Caddy benötigten Dateien (Caddyfile, data, config) ein und kann ebenso auf die von mir unter /www abgelegten Dateien zum späteren Veröffentlichen meiner Website zugreifen. Der Container befindet sich im Netzwerk caddy-proxy und durch external: true weiß der Container, dass dieses Netzwerk unabhängig von diesem Container bereits besteht.

Damit wir den caddy-Container korrekt aufsetzen können, müssen wir also das Docker-Netzwerk vorher per docker network create caddy-proxy erstellen. Mit docker network ls kann man nun prüfen, ob das Netzwerk korrekt angelegt wurde.

NETWORK ID   NAME            DRIVER    SCOPE
<redacted>  bridge           bridge    local
<redacted>  caddy2_default   bridge    local
<redacted>  caddy-proxy      bridge    local

Sieht gut aus!

Anschließend erstelle ich eine Caddyfile zur Konfiguration von Caddy:

# caddy2/Caddyfile
blog.schallbert.de {
        root * /www/blog
        encode gzip
        file_server
}

Dieser Eintrag sagt caddy, dass der Inhalt des Ordners /www/blog als Dateiserver, der die gzip-Komprimierung unterstützt, unter blog.schallbert.de verfügbar machen soll.

Test-Website erstellen 🧪

Ach ja, was auch noch wichtig ist, bevor wir den Container starten: Der DNS2 sollte die Adresse auch auflösen können. Dafür logge ich mich in meinem DNS-Manager ein und ordne der Subdomain die feste IP-Adresse meines Servers zu:

TYPE  NAME                VALUE            TTL
A     blog.schallbert.de  <redacted>       86400

Schlussendlich soll der Server ja auch etwas anzeigen können. Eine Testseite genügt ja schon. Also ganz fix:

mkdir ~/caddy2/www/blog
cd ~/caddy2/www/blog
nano index.html

Und dort dann ein unverkennbares Hello World eintragen:

<!-- blog/index.html -->
<h1>ALL YOUR BASE ARE BELONG TO US</h1>

Speichern, fertig.

Das Stammverzeichnis von caddy sieht nun so aus:

*caddy_config*  *caddy_data*  Caddyfile  docker-compose.yml *www*

Jetzt endlich kann der Container erstellt und caddy darin hochgefahren werden:

docker compose up -d

Die Option -d bedeutet “detach”, also wird der Container hochgefahren und von der Konsole abgekoppelt, sodass sie nicht blockiert wird.

[+] Running 1/1
 ✔ Container caddy  Started

Super, das sieht auch gut aus. Jetzt schnell mal auf die Seite navigieren:

Image: caddy is displaying the test page correctly

Gitea installieren 🍵

Wie oben kurz angerissen, benötige ich Gitea für zwei Zwecke: Erstens möchte ich für meine Dateien wie von Github gewohnt ein Versionsmanagement haben und zweitens möchte ich meine Seite mit Hilfe von Gitea über Jekyll bauen und dann auf den Server schieben.

Wie schon bei caddy, beginnt alles in einem entsprechenden Ordner:

cd ~
mkdir gitea
cd gitea
nano docker-compose.yml

Die docker-compose.yml für Gitea habe ich mir aus verschiedenen Beispielen zusammengebastelt. Hier ist sie:

# gitea/docker-compose.yml
version: "3"

networks:
  gitea:
    external: false
  caddy-proxy:
    external: true
    name: caddy-proxy

services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    restart: always
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - SSH_DOMAIN=git.schallbert.de
      - GITEA_HOSTNAME=git.schallbert.de
    networks:
      - gitea
      - caddy-proxy
    volumes:
      - ./gitea:/data
      - ./log:/app/gitea/log
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "127.0.0.1:3000:3000"
      - "222:22"
  runner:
    image: gitea/act_runner:nightly
    environment:
      - CONFIG_FILE=/config.yml
      - GITEA_INSTANCE_URL=https://git.schallbert.de
      - GITEA_RUNNER_NAME=ichlaufe
      - GITEA_RUNNER_REGISTRATION_TOKEN=
    volumes:
      - ./runner/config.yml:/config.yml
      - ./runner/data:/data
      - /var/run/docker.sock:/var/run/docker.sock

Die Bedeutung ist in etwa wie folgt: gitea ist Teil der Docker-Netzwerke gitea sowie caddy-proxy. Der Container, in dem der Service läuft, heißt gitea und wird bei Absturz stets automatisch neu gestartet. Die User-ID wird mit 1000 in einem Wertebereich gesetzt, welcher weder für Administratoren noch andere besondere Benutzer reserviert ist. Per SSH und http ist gitea unter git.schallbert.de erreichbar, und zwar auf Ports 22 bzw. 3000 - dort aber nur per localhost, also nicht “von außen”.

Gitea hat Zugriff auf Zeitzone und Lokalzeit des Servers, außerdem legt es Logs unter /log und Dateien unter /data ab. Gitea verfügt über einen action runner, der auf der gitea-Instanz läuft, ichlaufe heißt und dessen Konfiguration in der config.yml zu finden ist. Der Runner kann im Docker über dessen Standard-Socket Aktionen ausführen, z.B. ubuntu-latest installieren und darauf Jekyll zum Laufen bringen. Die Basiskonfiguration des Runners habe ich dem offiziellen Repository entnommen.

Gitea akzeptiert nur Runner, deren Token unter GITEA_RUNNER_REGISTRATION_TOKEN registriert sind - Dies holen wir zu einem späteren Zeitpunkt nach.

Nun starten wir Gitea mal, sodass wir die erforderlichen Ordner zur Konfiguration erstellt bekommen.

docker compose up

So sieht die Konsolenausgabe bei mir aus:

[+] Running 4/2
 ✔ Network gitea_default     Created                                                                0.0s 
 ✔ Network gitea_gitea       Created                                                                0.1s 
 ✔ Container gitea           Created                                                                0.1s 
 ✔ Container gitea-runner-1  Created                                                                0.0s 
Attaching to gitea, gitea-runner-1
gitea           | Server listening on :: port 22.
gitea           | Server listening on 0.0.0.0 port 22.
gitea           | 2023/11/18 17:19:52 cmd/web.go:242:runWeb() [I] Starting Gitea on PID: 15
gitea           | 2023/11/18 17:19:52 cmd/web.go:111:showWebStartupMessage() [I] Gitea version: 1.21.0 built with GNU Make 4.4.1, go1.21.4 : bindata, timetzdata, sqlite, sqlite_unlock_notify
gitea           | 2023/11/18 17:19:52 cmd/web.go:112:showWebStartupMessage() [I] * RunMode: prod
gitea           | 2023/11/18 17:19:52 cmd/web.go:113:showWebStartupMessage() [I] * AppPath: /usr/local/bin/gitea
gitea           | 2023/11/18 17:19:52 cmd/web.go:114:showWebStartupMessage() [I] * WorkPath: /data/gitea
gitea           | 2023/11/18 17:19:52 cmd/web.go:115:showWebStartupMessage() [I] * CustomPath: /data/gitea
gitea           | 2023/11/18 17:19:52 cmd/web.go:116:showWebStartupMessage() [I] * ConfigFile: /data/gitea/conf/app.ini
gitea           | 2023/11/18 17:19:52 cmd/web.go:117:showWebStartupMessage() [I] Prepare to run web server

Webzugriff einrichten

Nun gehe ich erneut in meinen DNS-Manager und setze die Adresse für den Webzugriff:

TYPE  NAME                VALUE            TTL
A     git.schallbert.de   <redacted>       86400

Außerdem muss die Caddyfile angepasst werden, damit Anfragen aus dem Web auch richtig weitergeleitet werden können. Also zurück in den caddy2 Ordner und mit nano Caddyfile folgenden Abschnitt hinzufügen:

# caddy2/Caddyfile
git.schallbert.de {
        reverse_proxy * http://gitea:3000
}

Nun werden Anfragen nach git.schallbert.de auf den im Docker geöffneten Port 3000 des Gitea-Containers weitergeleitet.

Gitea per Web-Oberfläche konfigurieren

Navigiere ich im Browser jetzt auf die entsprechende Adresse, so bekomme ich ein Login-Fenster angezeigt - genau wie in dieser Demo.

Nun denke ich mir ein schönes Passwort aus, klicke auf Register und bekomme hernach die Möglichkeit, Gitea per Weboberfläche zu konfigurieren. Dies tue ich aber nur rudimentär, denn ich möchte am Ende ja meine eigene Konfiguration verwenden.

Anschließend erstelle ich wie im Kapitel SSH einrichten oben beschrieben ein weiteres SSH-Schlüsselpaar per ssh-keygen und passendem Dateinamen -f ~/.ssh/gitea. Diesen lade ich dann auf Gitea unter Settings->SSH/GPG Keys hoch und verifiziere ihn.

Nun kann ich auf meinem Rechner erneut in der config-Datei des .ssh-Verzeichnisses den Abschnitt zum Login auf Gitea erstellen:

# .ssh/config
Host gitea
  User schallbert
  Hostname git.schallbert.de
  PreferredAuthentications publickey
  Identityfile ~/.ssh/schallbert_gitea

Jetzt schnell prüfen, ob auch alles funktioniert:

ssh gitea
Host 'git.schallbert.de' is known and matches the ED25519 host key.
debug1: Found key in /home/schallbert/.ssh/known_hosts

Perfekt.

Gitea-Konfiguration per app.ini fertigstellen

Da die Konfiguration aber noch nicht abgeschlossen ist, fahre ich den Container nun wieder herunter:

docker compose down

und navigiere nach /gitea/gitea/conf. Dort öffne ich die app.ini und modifiziere sie wie folgt:

# conf/app.ini
APP_NAME = Gitea: Git with a cup of tea
RUN_MODE = prod
RUN_USER = git
WORK_PATH = /data/gitea

[repository]
ROOT = /data/git/repositories
DEFAULT_BRANCH = main

[repository.local]
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo

[repository.upload]
TEMP_PATH = /data/gitea/uploads

[server]
APP_DATA_PATH = /data/gitea
DOMAIN = git.schallbert.de
SSH_DOMAIN = git.schallbert.de
HTTP_PORT = 3000
ROOT_URL = https://git.schallbert.de/
DISABLE_SSH = false
SSH_PORT = 222
SSH_LISTEN_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = <redacted>
OFFLINE_MODE = false

[database]
PATH = /data/gitea/gitea.db
DB_TYPE = sqlite3
HOST = localhost:3306
NAME = gitea
USER = root
PASSWD =
LOG_SQL = false
SCHEMA =
SSL_MODE = disable

[indexer]
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve

[session]
PROVIDER_CONFIG = /data/gitea/sessions
PROVIDER = file

[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars

[attachment]
PATH = /data/gitea/attachments

[log]
MODE = file
LEVEL = info
ROOT_PATH = /data/gitea/log

[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = <redacted>
PASSWORD_HASH_ALGO = pbkdf2

[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = false
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost

[lfs]
PATH = /data/git/lfs

[mailer]
ENABLED = false

[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true

[cron.update_checker]
ENABLED = false

[repository.pull-request]
DEFAULT_MERGE_STYLE = merge

[repository.signing]
DEFAULT_TRUST_MODEL = committer

[oauth2]
JWT_SECRET = <redacted>

[actions]
ENABLED = true

Hier ist wichtig zu sicherzustellen, dass ROOT_URL und die Portkonfiguration mit jener der docker-compose.yml übereinstimmt. Die Datenbank (sqlite3) wird konfiguriert und gitea zugänglich gemacht. Außerdem deaktiviere ich aus Sicherheitsgründen die Registrierungsmöglichkeit neuer Benutzer DISABLE_REGISTRATION = true sowie jene per Openid ENABLE_OPENID_SIGNIN = false ENABLE_OPENID_SIGNUP = false und schalte Gitea Actions per ENABLED = true ein.

Nun kann der Container neu gestartet werden:

[+] Running 4/4
 ✔ Network gitea_default     Created 0.1s 
 ✔ Network gitea_gitea       Created 0.0s 
 ✔ Container gitea           Started 0.0s 
 x Container gitea-runner-1  Error   0.1s 

Gut, Gitea läuft schonmal. In einem letzten Schritt kümmere ich mich nun um den Action Runner.

Action Runner einrichten 🏃

Für die ersten Schritte hilft die offizielle Anleitung weiter.

Ich logge mich erneut auf der Weboberfläche über git.schallbert.de ein. Die Aktivierung von Actions in der app.ini im vorigen Abschnitt lässt jetzt bei Settings->Actions ein neues Menü erscheinen. Hier klicke ich nun auf Create new Runner und kopiere mir das REGISTRATION TOKEN.

Anschließend öffne ich die docker-compose.yml im Gitea-Stammverzeichnis erneut und füge wie hier vorgeschlagen Folgendes ein:

# gitea/docker-compose.yml
# [...]
runner:
    image: gitea/act_runner:nightly
    environment:
      - CONFIG_FILE=/config.yml
      - GITEA_INSTANCE_URL=https://git.schallbert.de
      - GITEA_RUNNER_NAME=ichlaufe
      - GITEA_RUNNER_REGISTRATION_TOKEN= <redacted>

Nach einem Neustart des Containers mit docker compose down && docker compose up -d erscheint nun der Runner als “gestartet” im Log:

✔ Network gitea_default     Created  0.1s 
✔ Network gitea_gitea       Created  0.0s 
✔ Container gitea           Started  0.0s 
✔ Container gitea-runner-1  Started  0.1s 

Hervorragend, jetzt schauen wir auf der Weboberfläche nach und siehe da:

Image: Gitea action runner is up and - running.

Fehlt noch der Beweis, dass der Runner auch wirklich arbeitet.

Funktionsprüfung Runner

Für die Prüfung des Action Runner nehme ich erneut die Dokumentation von Gitea zur Hilfe. Ich erstelle ein neues Repository auf der Gitea-Weboberfläche mit Namen runner-test und füge den Inhalt der oben verlinkten Datei im Verzeichnis /.gitea/workflows/01-test.yml hinzu.

Commit, push, und - nichts.

Ah, der Workflow startet nur on: [push]. Er kann also gar nicht starten, wenn er gerade selbst erst auf den Server geladen wurde.

Gut, erstelle ich halt noch eine Datei, commit, push. Ergebnis: Image: Gitea action runner demo passes integration

Wahnsinn! 🥳

Zwischenfazit

Ich habe hier so getan, als wäre der Weg in einer geraden Linie verlaufen und als hätte alles reibungslos geklappt. Leider traf das so nicht zu. Es war zwar überraschend einfach, die Programme in Docker zum Laufen zu bringen, doch miteinander reden wollten sie lange nicht. Ich hatte zudem ziemliche Probleme mit der Caddyfile So einfach sie auch erscheinen mag, lange wollte so gar nichts in meinem Browser erscheinen - weder Gitea noch die blog-Testseite. Ein Ping an die Seite vom Terminal des Servers aus funktionierte aber, also konnte ich den Fehler schnell auf die Konfiguration von caddy zurückführen.

Die Ursache: Da caddy im Docker läuft, muss die Caddyfile als Volume eingebunden werden. Im Caddyfile selber muss der Ordner vom Root des Volumes aus, also mit führendem /, angegeben werden.

Auf der anderen Seite hat die Konfiguration von Gitea viel reibungsloser geklappt, als ich dachte. Sogar das aufwändige Freischalten des Runners, welcher auch im Docker nochmal extra geroutet werden muss, war weniger schlimm als ich dachte.

Mit diesem Grundgerüst habe ich jetzt einen flinken Server, welcher meinen Blog später bestimmt reibungslos hosten kann.

Die nächsten Schritte - das Aufsetzen des Bauprozesses für meine Website - werden in diesem Artikel hier und dann auch in Jekyll nativ hier abgehandelt.

Update: Server-Architektur im Januar 2024

Ich habe den Server inzwischen mehrfach um Peripheriefunktionen erweitert. So habe ich mich um Sicherheit und Backups ebenso gekümmert wie um automatische Updates, wobei mir die Kapselung durch Docker nicht nur einmal auf die Füße gefallen ist und einiges an Zeit gekostet hat. Auch wenn noch immer nicht alle Aspekte vollständig abgedeckt sind (Benachrichtigungen sind z.B noch ein offenes Thema), komme ich meinem Ziel, einen weitgehend selbstständig laufendes System zu schaffen, spürbar näher.

Am Ende möchte ich schließlich wieder dahin zurückkehren, Inhalte zu erzeugen. Und mich nicht mehr als nötig mit dem Drumherum beschäftigen.

  1. CI/CD steht für “Continuous Integration / Continuous deployment” und bedeutet, dass das System bei Update der verwalteten Dateien automatisch die Website neu baut, testet und veröffentlicht. 

  2. DNS steht für Domain Name System, ein verteiltes hierarchisches System zur Namensauflösung von Websites. Hintergrund ist, dass es viel schwieriger ist, sich eine IPV6-Adresse wie 2a02:ec80:300:ed1a:0:0:0:1 zu merken statt en.wikipedia.org in die Adresszeile eines Browsers einzugeben.