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.

  1. 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.
  2. 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:

  1. Forgejo empfängt den Push und triggert den Workflow.
  2. Der Runner mit dem passenden Label nimmt den Job.
  3. Kaniko baut das Image in Docker-in-Docker und pusht es als latest in die Forgejo-Registry.
  4. 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 scratch mit 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 😉