Files
sendsecret/DEPLOY.md
Lucas Orth 681fc1649a SendSecret: Erstimport und Umstellung auf Tag-Deployment
Wrapper-Frontend vor cryptgeon: der Browser verschluesselt lokal, per Mail
geht nur der Link raus.

Fuer das Deployment nach den Konventionen aus DEPLOY.md hergerichtet:

- docker-compose.yml mit festem Projekt- und Container-Namen, kein
  ports-Mapping, Healthcheck als Deploy-Gate. cryptgeon und redis liegen
  im internen Netz, nur app haengt im Web-Netz.
- cryptgeon von latest auf 2.9.3 gepinnt. Das ist derselbe Stand, den
  latest bisher geliefert hat; 2.6.2 existiert nicht.
- /healthz in server.js, vor dem Catch-all-Proxy registriert.
- Dockerfile auf npm ci mit Lockfile und non-root umgestellt.
- .gitea/workflows/deploy.yml: Build und Syntaxpruefung vor dem Deploy,
  .env aus dem Repo-Secret DOTENV.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:26:15 +02:00

6.0 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.

Bestehende App umstellen

Laeuft die App heute schon manuell auf dem Server, gilt statt des folgenden Abschnitts MIGRATION.md - dort steht insbesondere, wie die Volumes den Wechsel des Projektnamens ueberleben.

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