Mein Portfolio mit Astro und GitHub Actions, von der Idee bis zur Pipeline
Ausgangslage
Auf meinem VPS lag bisher nur eine unbenutzte WordPress-Erstinstallation, keine fertige Seite. Ich wollte ein echtes Portfolio, das selbst zeigt, wie ich eine Plattform baue und betreibe: statisch, ohne Datenbank, ausgeliefert aus einem Container auf meinem eigenen VPS hinter Traefik.
Entscheidung
- Astro statt WordPress: Die Seite besteht aus Markdown- und TypeScript-Dateien. Es gibt keine Datenbank, keine Plugins und nichts, das ich laufend patchen muss.
- Astro statt Gatsby: Astro kommt ohne GraphQL aus und war schneller startklar. Gatsby wäre auch gegangen.
- Zweisprachig von Anfang an: Deutsch unter
/, Englisch unter/en/. - Alles in einem Repository: Inhalte, Konfiguration, Dockerfile und Pipeline liegen zusammen in Git.
Umsetzung: die Seite
- Beiträge liegen je Sprache in einem eigenen Ordner. Gleicher Dateiname heisst gleiche Seite, so führt der Sprachumschalter immer zur Übersetzung.
- Icons von Lucide und Simple Icons werden beim Build als Inline-SVG in die Seiten geschrieben. Die Seite lädt dafür keine fremden Dateien und kommt ohne Tracking aus.
- Es gibt einen hellen und einen dunklen Modus und fünf wählbare Akzentfarben. Mein Logo habe ich aus einer Handzeichnung als Vektorgrafik nachgezeichnet.
- Fast die ganze Seite ist reines, statisches HTML ohne Framework-JavaScript. Nur der Theme- und Akzentfarben-Umschalter ist eine einzelne kleine Preact-Komponente, geladen mit
client:idle. Das ist Astros Inseln-Architektur: Interaktivität wird gezielt einzeln hydriert, nicht die ganze Seite. - View Transitions sorgen für weiche Übergänge zwischen den Seiten, ohne vollständigen Reload. Die Insel selbst bleibt bei der Navigation erhalten (
transition:persist), damit sie nicht bei jedem Seitenwechsel neu aufgebaut werden muss. - Links werden beim Hovern im Hintergrund vorab geladen, das macht die Navigation spürbar schneller.
- Zwei Skripte nehmen mir Arbeit ab:
npm run new-postlegt einen Beitrag auf Deutsch und Englisch als Entwurf an.npm run checkprüft Übersetzungen, Icons und übrig gebliebene TODOs und läuft automatisch vor jedem Build. - Den Aufbau habe ich zusammen mit Claude als Assistent entwickelt. Entscheidungen und Kontrolle liegen bei mir, Entwürfe für Code und Texte kamen vom Assistenten.
Umsetzung: der Container
Ich hatte zuerst einen eigenen Container geplant: ein zweistufiges Dockerfile, das die Seite mit Node baut und mit nginx-unprivileged auf Port 8080 ausliefert, dazu ein neuer Compose-Service mit eigenen Traefik-Labels. Beim Abgleich mit der echten Server-Konfiguration hat sich das als unnötig herausgestellt: nginx-odabas, der Container, der heute WordPress ausliefert, terminiert sein TLS bereits selbst (Traefik reicht die Verbindung nur unverschlüsselt durch, “Passthrough”), lauscht intern auf Port 8081, und mountet sein conf.d sowie die Zertifikate vom Host. Ein Docker-Bind-Mount überschreibt beim Start den Zielordner im Image vollständig, mein Image muss also gar keine eigene nginx-Konfiguration mitbringen. Es genügt, dass es die fertig gebauten Astro-Dateien enthält. Für die Umstellung ändert sich nur das image:-Feld des bestehenden nginx-odabas-Service auf mein Image, dazu einmalig eine Zeile in seiner (weiterhin server-seitigen) Konfiguration: proxy_pass http://wordpress-odabas:80; wird zu root /usr/share/nginx/html;. Kein neuer Container, kein neuer Service, keine neuen Traefik-Labels — aber sehr wohl ein neues Image, jedes Mal wenn ich etwas veröffentliche. wordpress-odabas wird danach nur gestoppt, nicht gelöscht.
Umsetzung: Git und GitHub Actions
Das Repository liegt privat auf GitHub. Für Commits nutze ich die anonyme noreply-Adresse von GitHub und lasse Pushes mit meiner privaten Adresse blockieren. So taucht sie auch in einem späteren öffentlichen Repository nicht im Verlauf auf.
Der Workflow build-and-deploy startet bei jedem Push auf main:
- build baut das Docker-Image und lädt es in die GitHub Container Registry (
ghcr.io), einmal alslatestund einmal mit dem Commit als Tag. - deploy verbindet sich per SSH mit dem VPS und startet gezielt den
nginx-odabas-Container neu (docker compose pull nginx-odabas && docker compose up -d --force-recreate nginx-odabas).
Der erste Lauf endete rot, weil der Deploy-Schritt noch keine Zugangsdaten zum Server hatte. Das Image war trotzdem gebaut, in GitHub steht es unter Packages. Statt den Schritt auszukommentieren, habe ich einen Schalter eingebaut:
deploy:
needs: build
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
runs-on: ubuntu-latest
Solange die Variable DEPLOY_ENABLED nicht auf true steht, wird der Schritt übersprungen. Der zweite Lauf wurde grün und dauerte 39 Sekunden.
Erkenntnis
- Pipelines schalte ich in Etappen ein: erst der Build, dann das Ausrollen. Ein Schalter ist besser als auskommentierter Code, weil er im Repository sichtbar bleibt.
- Eine Vorprüfung vor dem Build fängt Fehler in den Inhalten ab, bevor sie online gehen.
- Kleine Commits mit englischen Nachrichten (
Add …,Fix …) halten den Verlauf lesbar. - Beim Abgleich mit der echten Server-Konfiguration hat sich gezeigt, dass odabas.ch gar keinen neuen Container braucht, aber sehr wohl das Docker-Image:
nginx-odabasübernimmt die Auslieferung selbst, indem seinimage:-Feld auf mein gebautes Image wechselt. Der neue Compose-Service von oben ist nicht mehr nötig, das Image dagegen schon — es ist am Ende genau das, was auf dem Server läuft. - Der Zugang zum Server war eingerichtet, ein befristeter Testweg ersetzte den alten, nicht mehr existierenden
webtest-Entrypoint, und kurz darauf folgte die eigentliche Umstellung vonodabas.ch— ohne spürbare Downtime. Wie das im Detail ablief, steht im nächsten Beitrag.