
Automatisiertes Deployment mit Forgejo CI/CD und Container Registry
Folgendes Szenario: Jemand pusht auf ein Repository und in paar Sekunden später steht der fertige Container in der Forgejo-Registry bereit. Ein Klick auf Update im selbstgehosteten Komodo auf den Stack oder im Terminal docker compose pull und up eingebegen und die neue Seite geht live.
In diesem Post zeige ich, wie das geht: Forgejo als CI-CD-Plattform, ein selbstgehosteter Runner und eine schlanke Container-Registry. Für den Blog selbst reicht dann folgende docker-compose.yml:
blog:
image: forgejo.example.com/myuser/blog:latest
restart: always
ports:
- "1234:80"
Das Setup: Forgejo, Runner und Docker-in-Docker
Damit die Pipeline läuft, brauche ich drei Dinge auf dem Server: Forgejo selbst, einen Runner, der die Jobs ausführt, und eine Docker-Instanz, in der der Runner seine Container baut:
volumes:
forgejo-data:
runner-data:
services:
forgejo:
image: codeberg.org/forgejo/forgejo:16
container_name: forgejo
restart: always
ports:
- "3000:3000"
- "2222:22"
volumes:
- forgejo-data:/data
- /etc/localtime:/etc/localtime:ro
docker-in-docker:
image: docker:dind
container_name: docker_dind
privileged: true
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "--tls=false"]
restart: unless-stopped
mem_limit: 4g
runner:
image: data.forgejo.org/forgejo/runner:12
container_name: runner
depends_on:
docker-in-docker:
condition: service_started
environment:
DOCKER_HOST: tcp://docker-in-docker:2375
user: 1001:1001
volumes:
- runner-data:/data
restart: unless-stopped
mem_limit: 4g
command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"'
Die Pipeline
Jetzt zur eigentlichen Pipeline. Die liegt als .forgejo/workflows/deploy.yml direkt im Repo der Website und ist erstaunlich kurz:
name: Build and Push Astro Container
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: docker
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Prepare Kaniko Authentication
run: |
mkdir -p ${{ github.workspace }}/.docker
echo "{\"auths\":{\"${{ secrets.REGISTRY_URL }}\":{\"username\":\"${{ secrets.REGISTRY_USER }}\",\"password\":\"${{ secrets.REGISTRY_PASSWORD }}\"}}}" > ${{ github.workspace }}/.docker/config.json
- name: Build and Push with Kaniko
uses: https://github.com/aevea/action-kaniko@master
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
with:
image: ${{ secrets.REGISTRY_URL }}/${{ github.repository }}
tag: latest
cache: true
extra-args: --snapshot-mode=redo
Zwei Dinge fallen hier auf.
runs-on: docker— das ist genau das Label, das man dem Runner bei der Registrierung gegeben hat. Forgejo schickt den Job also an den eigenen Runner auf dem Server.- Zum Bauen nutze ich Kaniko statt dem normalen
docker build. Kaniko kann ein Image bauen und direkt in eine Registry pushen, ohne dass der Build-Container selbst privilegiert sein muss. Das passt gut zu Docker-in-Docker und hält die Build-Umgebung sauber.
Die Authentifizierung an der Forgejo-eigenen Container Registry läuft über eine kleine config.json im Workspace. Username, Passwort und Registry-URL liegen als Secrets im Forgejo-Projekt. Forgejo bringt eine eigene Container Registry mit die unter der Forgejo-Adresse läuft (im Beispiel oben forgejo.example.com), aus der sich der Blog-Container sein Image zieht.
Das Image: so klein wie möglich
Das Dockerfile ist zweistufig und baut am Ende auf scratch:
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM ghcr.io/static-web-server/static-web-server:2-alpine AS sws
FROM scratch AS runtime
COPY --from=sws /usr/local/bin/static-web-server /static-web-server
COPY --from=build /app/dist /public
EXPOSE 80
STOPSIGNAL SIGQUIT
ENTRYPOINT ["/static-web-server"]
CMD ["-w", "/public", "-p", "80", "-z", "true", "--compression-static", "true", "--health", "true"]
Weil die Seite ein reiner Astro-Static-Build ist, brauche ich zur Laufzeit weder Node noch ein OS. static-web-server ist ein einzelner, statisch gelinkter Rust-Binary mit HTTP/2, Brotli/Gzip und Range-Requests. Den kopiere ich zusammen mit dist/ in ein scratch-Image — fertig. Keine Shell, kein libc, keine Angriffsfläche. Der --health-Flag schaltet den /health-Endpunkt frei, den der Reverse Proxy für den Healthcheck nutzt.
Was nach dem Push passiert
Wenn jetzt ein Commit auf main landet, läuft das so ab:
- Forgejo empfängt den Push und triggert den Workflow.
- Der Runner mit dem passenden Label nimmt den Job.
- Kaniko baut das Image in Docker-in-Docker und pusht es als
latestin die Forgejo-Registry. - In Komodo klicke ich auf „Update“ für den Ziel-Stack. Komodo pullt das neue
:latest-Image und startet den Container neu — der Service-Eintrag von ganz oben referenziert genau dieses Image.
Warum ich das so gemacht habe
- Reproduzierbarkeit: Jeder Build erzeugt das gleiche, versionierte Image. Wenn etwas kaputt ist, weiß ich genau, welcher Stand läuft.
- Minimalismus: Forgejo, Runner und Registry sind ein einziger Stack. Keine externen Dienste, keine Cloud-Abhängigkeit.
- Sicherheit zur Laufzeit: Der productive Container ist
scratchmit einem Binary — mehr gibt es nicht angreifbar. - Caching: Kaniko cached die Layer, dadurch dauert ein Build nach der ersten Runde nur noch Sekunden bis wenige Minuten.
Am Ende ist das Ganze weniger Magie, als es auf den ersten Blick wirkt: ein Compose-File mit drei Containern, ein Workflow-File mit drei Steps und ein zweistufiges Dockerfile. Wer selbst so etwas aufbauen will, fängt mit dem Forgejo-Stack an, registriert den Runner mit einem eigenen Label und legt dann die Secrets für die Registry an — der Rest ist im Grunde Standard-Workflow.
Btw., dieser Artikel hier ist übrigens auch über genau diese Pipeline live gegangen 😉