Fuer Apps, die heute per SCP deployt werden. Schwerpunkt liegt auf den beiden Aenderungen, die nicht rueckwirkungsfrei sind: der feste Projektname benennt die Volumes um, der feste Container-Name kollidiert mit dem noch laufenden alten Stack. Enthaelt das Rezept zum Umkopieren der Volumes und einen Rueckweg, solange das alte Volume noch existiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 nodebzw. 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
- Repo in Gitea anlegen, pushen.
- Secret
DOTENVsetzen. - Pruefen, ob schon ein Container mit dem Namen laeuft
(
docker ps -a --filter name=<app>). Falls ja: im alten Verzeichnis einmaldocker compose down, sonst kollidiert der feste Container-Name. - Proxy Host im NPM anlegen.
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 |