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

182 lines
5.8 KiB
Markdown

# 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
```yaml
# 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
```yaml
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 |