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:
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:
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:
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.
-
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. ↩
-
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 statten.wikipedia.org
in die Adresszeile eines Browsers einzugeben. ↩