Files
deploy-kit/DEPLOY.md
Lucas Orth eeb4477476 Deployment-Konventionen und Projektvorlagen
DEPLOY.md beschreibt, wie ein Container aussehen muss, damit er mit dem
Runner und dem Nginx Proxy Manager zusammenspielt: fester Projektname,
Image mit Versionstag, kein ports-Mapping, Healthcheck als Deploy-Gate,
named volumes statt relativer Bind-Mounts.

bootstrap.sh holt die Vorlagen per curl in ein neues Projekt und ersetzt
__APP__ und __PORT__. Vorhandene Dateien bleiben unangetastet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:18:06 +02:00

5.8 KiB

Deployment-Konventionen

Verbindlich fuer jede App, die auf dem VPS per Tag-Push deployt wird. Abweichungen brauchen einen Grund, der hier nicht steht.

Ablauf

git tag v1.2.3 && git push --tags
  -> Gitea Actions (act_runner auf dem VPS)
  -> npm ci / build / test          <- rot bricht ab, nichts wird deployt
  -> .env aus Repo-Secret DOTENV
  -> docker compose up -d --build --wait

Kein SCP, kein manuelles Neubauen. Der Code landet nie im Dateisystem des Hosts - der Job-Container klont, baut gegen den Docker-Daemon des Hosts und startet den Container.

Infrastruktur (existiert bereits, nicht neu anlegen)

Ding Wert
Git Gitea, https://gitea.lucas-orth.de, Container gitea, intern :3000
Runner act_runner, Container gitea-runner, Label ubuntu-latest
Netz CI gitea-ci - Gitea + Runner + Job-Container
Netz Web nginx-proxy-manager_default - Proxy + alle App-Container
Job-Image catthehacker/ubuntu:act-latest (node + docker-cli + compose)

Die beiden Netze sind bewusst getrennt: der Runner spricht mit Gitea, nicht mit dem Proxy. App-Container haengen im Web-Netz, nicht im CI-Netz.

docker-compose.yml

# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen
# ab - und der ist im CI-Job ein anderer als auf dem Host.
name: <app>

services:
  app:
    # Versionstag ermoeglicht Rollback ohne Rebuild.
    image: <app>:${IMAGE_TAG:-latest}
    build: .
    # Fester Name: das ist die Adresse, auf die der Proxy zeigt.
    container_name: <app>
    restart: unless-stopped
    # Wird im Workflow aus dem Secret DOTENV erzeugt.
    env_file: .env
    networks:
      - nginx-proxy-manager_default
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:<port>/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 60s
      timeout: 5s
      retries: 3

networks:
  nginx-proxy-manager_default:
    external: true

volumes:
  <app>-data:
    # Fester Name, damit das Volume nicht am Projektnamen haengt.
    name: <app>-data

Kein ports:. Der Proxy erreicht den Container ueber das gemeinsame Netz unter seinem Container-Namen. Ein Port-Mapping wuerde den Dienst zusaetzlich offen ans Internet haengen.

Healthcheck ist Pflicht. Der Deploy-Schritt nutzt --wait und macht ihn zum Gate. Ohne Healthcheck wird der Lauf gruen, auch wenn der Container in einer Crash-Schleife haengt.

Keine relativen Bind-Mounts (./data:/data). Compose laeuft im Job-Container, der Pfad wuerde auf dem Host ins Leere zeigen. Persistente Daten gehoeren in ein named volume mit festem name:.

.gitea/workflows/deploy.yml

name: Build & Deploy

on:
  push:
    tags:
      - 'v*'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      # Intern klonen. Die oeffentliche Domain ist aus Containern heraus
      # NICHT erreichbar: Gitea laeuft auf demselben Host, das Paket geht
      # an die oeffentliche IP und findet nicht zurueck (NAT-Hairpin).
      - uses: actions/checkout@v4
        with:
          github-server-url: http://gitea:3000

      # --- Build & Tests, projektspezifisch ---

      - name: Tag ermitteln
        run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV

      - name: .env aus Secret erzeugen
        env:
          DOTENV: ${{ secrets.DOTENV }}
        run: |
          if [ -z "$DOTENV" ]; then
            echo "Secret DOTENV ist leer oder nicht gesetzt."
            exit 1
          fi
          umask 077
          echo "$DOTENV" > .env

      - name: Deployen
        run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180

      - name: Alte Layer aufraeumen
        run: docker image prune -f

Tests gehoeren vor den Deploy-Schritt. Ein roter Lauf darf den Server nicht anfassen.

Dockerfile

  • Multi-Stage: Build-Abhaengigkeiten landen nicht im finalen Image.
  • USER node bzw. non-root im finalen Stage.
  • EXPOSE <port> dokumentiert den internen Port (oeffnet nichts).
  • Ein Health-Endpoint (/healthz), auf den der Healthcheck zeigt.

Secrets

Ein einziges Repo-Secret DOTENV mit dem kompletten .env-Inhalt, mehrzeilig. Anlegen unter Repo -> Einstellungen -> Actions -> Secrets.

Gitea speichert Secrets write-only: nach dem Anlegen nicht mehr lesbar, nur ersetzbar. Die kanonische Fassung gehoert deshalb in den Passwortmanager, Gitea haelt nur die Arbeitskopie fuer die Pipeline.

Der Wert wird ueber env: an die Shell gereicht, nie direkt interpoliert - sonst zerlegt der Shell-Parser Werte mit $, Backticks oder Quotes.

Nginx Proxy Manager

Neuer Proxy Host:

Feld Wert
Domain Names <app>.lucas-orth.de
Scheme http
Forward Hostname / IP <app> (= container_name)
Forward Port <port>

Danach Let's-Encrypt-Zertifikat ausstellen und "Force SSL" aktivieren.

Weil der Container-Name die Adresse ist: aendert sich container_name, zeigt der Proxy-Host ins Leere.

Erstes Deployment einer App

  1. Repo in Gitea anlegen, pushen.
  2. Secret DOTENV setzen.
  3. Pruefen, ob schon ein Container mit dem Namen laeuft (docker ps -a --filter name=<app>). Falls ja: im alten Verzeichnis einmal docker compose down, sonst kollidiert der feste Container-Name.
  4. Proxy Host im NPM anlegen.
  5. git tag v1.0.0 && git push origin v1.0.0

Fallstricke

Symptom Ursache
Job haengt beim Checkout github-server-url fehlt, klont ueber die oeffentliche Domain
network not found App-Container im CI-Netz statt im Web-Netz (oder umgekehrt)
Zweiter Container statt Update name: im Compose fehlt, Projektname weicht ab
Lauf gruen, App tot --wait fehlt oder kein Healthcheck definiert
Volume leer nach Umstellung Projektname geaendert, altes Volume hiess anders
Proxy liefert 502 container_name geaendert oder Container nicht im Web-Netz