Files
anouma/setup.sh
T
maroandClaude Sonnet 5 56b2a6497b Overhaul deployment to Gitea Actions CI/CD, remove update.sh
Production no longer builds from source or self-updates via cron/git pull:
setup.sh now only provisions the server once (Docker, /opt/anouma, a
restricted `anouma-deploy` SSH user whose key can only ever run
deploy.sh, generated secrets). All future deployments run through
.gitea/workflows/ci.yml (lint/typecheck/test/build on every push) and
release.yml (on a vX.Y.Z tag: build the image, push it to the Gitea
registry, then SSH-trigger deploy.sh on the server), which pulls,
migrates, restarts, healthchecks, backs up the database first, and
automatically rolls back the code on a failed healthcheck.

docker-compose.yml's app service now runs a registry image
(${ANOUMA_IMAGE}) instead of building locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:58:09 +02:00

668 lines
27 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ANOUMA — production server installer.
#
# git clone https://git.maro.run/maro/anouma.git && cd anouma
# chmod +x setup.sh && ./setup.sh
#
# Prepares a fresh Ubuntu/Debian server for ANOUMA: installs Docker if
# needed, creates /opt/anouma, writes a production .env with generated
# secrets, creates a restricted `anouma-deploy` SSH user for Gitea Actions,
# and installs the server-side deploy.sh + docker-compose.yml.
#
# From then on, Gitea Actions owns all deployments (see
# .gitea/workflows/release.yml) — there is no update.sh. Pushing a release
# tag (vX.Y.Z) is the only way production gets updated.
#
# Safe to re-run: never regenerates an existing secret or SSH key, never
# deletes a volume/database/user, never touches Nginx Proxy Manager (or any
# other existing container) beyond optionally joining its Docker network.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
REPO_DIR="$(pwd)"
PROD_DIR="/opt/anouma"
DEPLOY_USER="anouma-deploy"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
COLOR_INFO="\033[36m"; COLOR_WARN="\033[33m"; COLOR_ERROR="\033[31m"; COLOR_OK="\033[32m"; COLOR_RESET="\033[0m"
log_info() { printf "${COLOR_INFO}[INFO]${COLOR_RESET} %s\n" "$1"; }
log_warn() { printf "${COLOR_WARN}[WARN]${COLOR_RESET} %s\n" "$1"; }
log_error() { printf "${COLOR_ERROR}[ERROR]${COLOR_RESET} %s\n" "$1" >&2; }
log_success() { printf "${COLOR_OK}[SUCCESS]${COLOR_RESET} %s\n" "$1"; }
step() { printf "\n${COLOR_INFO}== %s ==${COLOR_RESET}\n" "$1"; }
fail() { log_error "$1"; [ -n "${2:-}" ] && log_error " → $2"; exit 1; }
# ---------------------------------------------------------------------------
# Args
# ---------------------------------------------------------------------------
NON_INTERACTIVE=false
SKIP_SEED=false
for arg in "$@"; do
case "$arg" in
--non-interactive) NON_INTERACTIVE=true ;;
--skip-seed) SKIP_SEED=true ;;
--help|-h)
echo "Usage: ./setup.sh [--non-interactive] [--skip-seed]"
exit 0
;;
esac
done
# ask PROMPT DEFAULT [ENV_VAR_NAME] -> echoes the answer.
# In --non-interactive mode: uses a real environment variable of the given
# name if the caller exported one (e.g. SMTP_HOST=... ./setup.sh
# --non-interactive), otherwise falls back to DEFAULT.
ask() {
local prompt="$1" default="$2" env_name="${3:-}" answer
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
if [ -n "$env_name" ]; then
answer="$(eval "echo \"\${$env_name:-}\"")"
[ -n "$answer" ] && { echo "$answer"; return; }
fi
echo "$default"
return
fi
if [ -n "$default" ]; then
read -r -p "$prompt [Default: $default]: " answer || true
else
read -r -p "$prompt: " answer || true
fi
echo "${answer:-$default}"
}
ask_secret() {
local prompt="$1" env_name="${2:-}" answer=""
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
[ -n "$env_name" ] && answer="$(eval "echo \"\${$env_name:-}\"")"
echo "$answer"
return
fi
read -r -s -p "$prompt: " answer || true; echo "" >&2
echo "$answer"
}
ask_yn() {
local prompt="$1" default="$2" answer
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
[ "$default" = "Y" ] && echo true || echo false
return
fi
local hint="y/N"; [ "$default" = "Y" ] && hint="Y/n"
read -r -p "$prompt [$hint]: " answer || true
answer="${answer:-$default}"
case "$answer" in
y|Y|yes|Yes|YES) echo true ;;
*) echo false ;;
esac
}
echo ""
echo "ANOUMA Setup"
echo "============"
echo ""
# ---------------------------------------------------------------------------
# 3. System check
# ---------------------------------------------------------------------------
step "System check"
OS_NAME="$(uname -s)"
ARCH_NAME="$(uname -m)"
log_info "OS: $OS_NAME, Architecture: $ARCH_NAME"
[ "$OS_NAME" = "Linux" ] || log_warn "Dieses Script wurde für Linux (Ubuntu/Debian) gebaut — andere Betriebssysteme werden nicht offiziell unterstützt."
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
else
command -v sudo >/dev/null 2>&1 || fail "Dieses Script braucht root-Rechte (Systembenutzer, /opt, Paketinstallation)." "Als root ausführen oder sudo installieren."
SUDO="sudo"
log_info "Läuft ohne root — verwende sudo für privilegierte Schritte."
fi
PKG_INSTALL=""
if command -v apt-get >/dev/null 2>&1; then
PKG_INSTALL="$SUDO apt-get install -y"
fi
ensure_tool() {
local bin="$1" pkg="$2"
if command -v "$bin" >/dev/null 2>&1; then
log_success "$bin gefunden."
return
fi
if [ -n "$PKG_INSTALL" ]; then
log_warn "$bin nicht gefunden — installiere Paket '$pkg' …"
$SUDO apt-get update -y >/dev/null 2>&1 || true
$PKG_INSTALL "$pkg" || fail "$bin konnte nicht automatisch installiert werden." "Installiere '$pkg' manuell und starte setup.sh erneut."
command -v "$bin" >/dev/null 2>&1 || fail "$bin fehlt weiterhin nach der Installation."
log_success "$bin installiert."
else
fail "$bin wurde nicht gefunden und kein unterstützter Paketmanager (apt) ist vorhanden." "Installiere '$pkg' manuell."
fi
}
ensure_tool git git
ensure_tool openssl openssl
ensure_tool curl curl
ensure_tool ssh-keygen openssh-client
command -v wget >/dev/null 2>&1 || log_info "wget nicht gefunden — wird hier nicht benötigt."
if systemctl is-active --quiet ssh 2>/dev/null || systemctl is-active --quiet sshd 2>/dev/null; then
log_success "SSH-Server läuft."
else
log_warn "Kein laufender SSH-Server erkannt (ssh/sshd) — Gitea Actions kann ohne einen laufenden SSH-Server nicht deployen. Installiere/starte z. B. 'openssh-server'."
fi
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
echo ""
echo "Docker ist nicht installiert."
INSTALL_DOCKER="$(ask_yn "Soll Docker automatisch installiert werden?" "Y")"
if [ "$INSTALL_DOCKER" = true ]; then
log_info "Installiere Docker (offizielles Installationsscript get.docker.com) …"
curl -fsSL https://get.docker.com | $SUDO sh || fail "Docker-Installation fehlgeschlagen." "Installiere Docker manuell: https://docs.docker.com/engine/install/"
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
log_success "Docker installiert."
else
fail "Docker wird benötigt." "Installiere Docker manuell: https://docs.docker.com/engine/install/"
fi
else
log_success "Docker erkannt: $(docker --version)"
fi
# Figure out whether docker commands need sudo in this session (e.g. right
# after a fresh install, before the invoking user's docker-group membership
# has taken effect).
DOCKER="docker"
if ! docker info >/dev/null 2>&1; then
if $SUDO docker info >/dev/null 2>&1; then
DOCKER="$SUDO docker"
log_warn "Docker braucht hier noch sudo (Gruppenmitgliedschaft wirkt erst nach erneutem Login) — setup.sh verwendet sudo für diesen Lauf."
else
fail "Docker-Daemon läuft nicht oder ist nicht erreichbar." "Starte Docker: sudo systemctl start docker"
fi
fi
COMPOSE=""
if $DOCKER compose version >/dev/null 2>&1; then
COMPOSE="$DOCKER compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
fi
[ -n "$COMPOSE" ] || fail "Docker Compose wurde nicht gefunden." "Installiere das Compose-Plugin: https://docs.docker.com/compose/install/"
log_success "Docker Compose erkannt: $($COMPOSE version | head -n1)"
if command -v node >/dev/null 2>&1; then
log_info "Node.js auf dem Host erkannt: $(node --version) (wird nicht benötigt — ANOUMA läuft und wird ausschließlich in Docker gebaut/betrieben)."
else
log_info "Node.js nicht auf dem Host installiert — das ist beabsichtigt, npm wird nur innerhalb von Docker (CI-Build) verwendet."
fi
# ---------------------------------------------------------------------------
# 4. /opt/anouma
# ---------------------------------------------------------------------------
step "Projektverzeichnis"
FRESH_INSTALL=false
if [ -d "$PROD_DIR" ] && [ -f "$PROD_DIR/.env" ]; then
echo "ANOUMA installation already exists at $PROD_DIR."
echo ""
echo "1. Bestehende Installation verwenden"
echo "2. Setup abbrechen"
CHOICE="$(ask "Auswahl" "1")"
case "$CHOICE" in
2) log_info "Setup abgebrochen. Keine Änderungen vorgenommen."; exit 0 ;;
*) log_info "Verwende bestehende Installation in $PROD_DIR — bestehende Daten bleiben unangetastet." ;;
esac
else
FRESH_INSTALL=true
$SUDO mkdir -p "$PROD_DIR"
log_success "$PROD_DIR erstellt."
fi
$SUDO mkdir -p "$PROD_DIR/backups" "$PROD_DIR/media" "$PROD_DIR/.deploy"
# ---------------------------------------------------------------------------
# 5. Docker network / Nginx Proxy Manager detection
# ---------------------------------------------------------------------------
step "Docker-Netzwerk"
EXISTING_NETWORKS="$($DOCKER network ls --format '{{.Name}}' 2>/dev/null || true)"
detect_npm_network() {
local npm_container
npm_container="$($DOCKER ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'nginx-proxy-manager|jc21/nginx-proxy-manager' | head -n1 | cut -f1 || true)"
if [ -n "$npm_container" ]; then
$DOCKER inspect "$npm_container" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | grep -v '^bridge$' | head -n1
return
fi
echo "$EXISTING_NETWORKS" | grep -iE 'nginx-proxy-manager|^npm' | head -n1 || true
}
DETECTED_NPM_NETWORK="$(detect_npm_network || true)"
NPM_DETECTED=false
if [ -n "$DETECTED_NPM_NETWORK" ]; then
NPM_DETECTED=true
log_success "Nginx Proxy Manager erkannt (Netzwerk: $DETECTED_NPM_NETWORK)"
else
log_info "Nginx Proxy Manager wurde nicht erkannt — ANOUMA läuft eigenständig, das ist kein Fehler."
fi
# ---------------------------------------------------------------------------
# 68. .env — full generation, secrets, interactive questions
# ---------------------------------------------------------------------------
step ".env"
ENV_FILE="$PROD_DIR/.env"
gen_secret() { openssl rand -hex 32; }
env_get() { $SUDO grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
# Rewrites the file via a local temp file + `printf` (normal argument
# passing, never re-interpolated into a nested shell string) so arbitrary
# characters in $value — quotes, backslashes, anything a typed SMTP/registry
# password might contain — can never break quoting or be misinterpreted.
env_set() {
local key="$1" value="$2" tmp
tmp="$(mktemp)"
if $SUDO test -f "$ENV_FILE"; then
$SUDO grep -vE "^${key}=" "$ENV_FILE" > "$tmp" 2>/dev/null || true
fi
printf '%s=%s\n' "$key" "$value" >> "$tmp"
$SUDO cp "$tmp" "$ENV_FILE"
rm -f "$tmp"
}
# Only fills a key in if currently missing/blank — makes generation safe to
# re-run (existing secrets/config survive every future setup.sh run).
env_ensure() {
local key="$1" value="$2"
[ -z "$(env_get "$key")" ] || return 1
env_set "$key" "$value"
}
if [ ! -f "$ENV_FILE" ]; then
log_info "Erstelle neue $ENV_FILE …"
$SUDO tee "$ENV_FILE" >/dev/null <<'EOF'
# Generated by setup.sh — see DEPLOYMENT.md for the full reference.
DATABASE_URI=
POSTGRES_USER=postgres
POSTGRES_PASSWORD=
POSTGRES_DB=anouma
PAYLOAD_SECRET=
MEETING_SESSION_SECRET=
CRON_SECRET=
NEXT_PUBLIC_SERVER_URL=
APP_PORT=3000
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
STUN_SERVER=stun:stun.l.google.com:19302
TURN_SERVER=
TURN_USERNAME=
TURN_PASSWORD=
GOOGLE_SITE_VERIFICATION=
DOCKER_NETWORK=anouma-network
NPM_NETWORK=
BACKUP_RETENTION_DAYS=14
REGISTRY=git.maro.run
REGISTRY_REPO=maro/anouma
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
ANOUMA_IMAGE=
AUTO_DEPLOY=true
EOF
fi
# --- secrets --------------------------------------------------------------
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
env_ensure MEETING_SESSION_SECRET "$(gen_secret)" && log_info "MEETING_SESSION_SECRET generiert." || log_info "MEETING_SESSION_SECRET bereits vorhanden — unverändert."
env_ensure CRON_SECRET "$(gen_secret)" && log_info "CRON_SECRET generiert." || log_info "CRON_SECRET bereits vorhanden — unverändert."
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
log_info "POSTGRES_PASSWORD generiert."
PG_USER="$(env_get POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}"
PG_DB="$(env_get POSTGRES_DB)"; PG_DB="${PG_DB:-anouma}"
PG_PASSWORD="$(env_get POSTGRES_PASSWORD)"
env_set DATABASE_URI "postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}"
else
log_info "POSTGRES_PASSWORD bereits vorhanden — unverändert."
fi
# --- interactive questions (only for values with no safe automatic value) -
if [ -z "$(env_get NEXT_PUBLIC_SERVER_URL)" ]; then
DOMAIN="$(ask "Domain" "anouma.org" "DOMAIN")"
APP_URL="$(ask "Application URL" "https://$DOMAIN" "APP_URL")"
env_set NEXT_PUBLIC_SERVER_URL "$APP_URL"
[ -z "$(env_get SMTP_FROM)" ] && env_set SMTP_FROM "ANOUMA <no-reply@${DOMAIN}>"
else
log_info "NEXT_PUBLIC_SERVER_URL bereits vorhanden — unverändert."
fi
if [ -z "$(env_get SMTP_HOST)" ]; then
SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")"
if [ "$SETUP_SMTP" = true ]; then
env_set SMTP_HOST "$(ask "SMTP Host" "" "SMTP_HOST")"
env_set SMTP_PORT "$(ask "SMTP Port" "587" "SMTP_PORT")"
env_set SMTP_USER "$(ask "SMTP Username" "" "SMTP_USER")"
env_set SMTP_PASSWORD "$(ask_secret "SMTP Password" "SMTP_PASSWORD")"
FROM_DEFAULT="$(env_get SMTP_FROM)"
env_set SMTP_FROM "$(ask "SMTP From Address" "${FROM_DEFAULT:-ANOUMA <no-reply@example.org>}" "SMTP_FROM")"
fi
fi
if [ -z "$(env_get NPM_NETWORK)" ] && [ "$NPM_DETECTED" = true ]; then
USE_NPM="$(ask_yn "Nginx Proxy Manager Network verwenden (Netzwerk beitreten)?" "Y")"
if [ "$USE_NPM" = true ]; then
env_set NPM_NETWORK "$(ask "Nginx Proxy Manager Network" "$DETECTED_NPM_NETWORK" "NPM_NETWORK")"
fi
fi
# --- registry --------------------------------------------------------------
REGISTRY_HOST="$(env_get REGISTRY)"; REGISTRY_HOST="${REGISTRY_HOST:-git.maro.run}"
log_info "Prüfe Registry-Erreichbarkeit ($REGISTRY_HOST) …"
REGISTRY_REACHABLE=false
REGISTRY_HTTP_CODE="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 5 "https://${REGISTRY_HOST}/v2/" 2>/dev/null || echo "000")"
if [ "$REGISTRY_HTTP_CODE" = "200" ] || [ "$REGISTRY_HTTP_CODE" = "401" ]; then
REGISTRY_REACHABLE=true
log_success "Registry erreichbar (HTTP $REGISTRY_HTTP_CODE)."
else
log_warn "Registry unter https://${REGISTRY_HOST}/v2/ nicht erreichbar (HTTP $REGISTRY_HTTP_CODE) — ggf. später erneut prüfen."
fi
if [ -z "$(env_get REGISTRY_USERNAME)" ]; then
DOCKER_CONFIG_FILE="${HOME}/.docker/config.json"
REUSED_AUTH=false
if [ -f "$DOCKER_CONFIG_FILE" ] && command -v grep >/dev/null 2>&1 && grep -q "\"$REGISTRY_HOST\"" "$DOCKER_CONFIG_FILE" 2>/dev/null; then
log_info "Vorhandene Docker-Login-Daten für $REGISTRY_HOST gefunden (~/.docker/config.json)."
REUSE="$(ask_yn "Diese Zugangsdaten für die Registry verwenden?" "Y")"
if [ "$REUSE" = true ]; then
REUSED_AUTH=true
log_info "Registry-Zugangsdaten werden zur Laufzeit über die bestehende Docker-Session verwendet — trage sie zusätzlich unten ein, damit auch deploy.sh (als $DEPLOY_USER) einloggen kann."
fi
fi
NEED_REGISTRY_AUTH="$(ask_yn "Werden Zugangsdaten für die Container Registry benötigt (privates Repository)?" "Y")"
if [ "$NEED_REGISTRY_AUTH" = true ]; then
REG_USER="$(ask "Registry Username" "" "REGISTRY_USERNAME")"
REG_PASS="$(ask_secret "Registry Password/Token (Gitea Personal Access Token empfohlen)" "REGISTRY_PASSWORD")"
if [ -z "$REG_USER" ] || [ -z "$REG_PASS" ]; then
if [ "$NON_INTERACTIVE" = true ]; then
fail "Registry-Zugangsdaten fehlen." "Setze die Umgebungsvariablen REGISTRY_USERNAME und REGISTRY_PASSWORD und starte setup.sh erneut, oder führe es interaktiv aus."
fi
log_warn "Keine Registry-Zugangsdaten eingegeben — kann später manuell in $ENV_FILE ergänzt werden (REGISTRY_USERNAME / REGISTRY_PASSWORD)."
else
env_set REGISTRY_USERNAME "$REG_USER"
env_set REGISTRY_PASSWORD "$REG_PASS"
fi
fi
else
log_info "REGISTRY_USERNAME bereits vorhanden — unverändert."
fi
if [ -z "$(env_get AUTO_DEPLOY)" ] || [ "$FRESH_INSTALL" = true ]; then
AUTO_DEPLOY_ANSWER="$(ask_yn "Enable automatic deployment (Gitea Actions deployt automatisch bei neuen Releases)?" "Y")"
env_set AUTO_DEPLOY "$AUTO_DEPLOY_ANSWER"
fi
$SUDO chmod 600 "$ENV_FILE"
log_success ".env ist bereit ($ENV_FILE)."
# ---------------------------------------------------------------------------
# 9. ENV summary (secrets masked)
# ---------------------------------------------------------------------------
step "Konfigurierte Umgebung"
mask() { [ -n "$1" ] && echo "********" || echo "(nicht gesetzt)"; }
echo "NEXT_PUBLIC_SERVER_URL: $(env_get NEXT_PUBLIC_SERVER_URL)"
echo "APP_PORT: $(env_get APP_PORT)"
echo "POSTGRES_DB: $(env_get POSTGRES_DB)"
echo "POSTGRES_PASSWORD: $(mask "$(env_get POSTGRES_PASSWORD)")"
echo "PAYLOAD_SECRET: $(mask "$(env_get PAYLOAD_SECRET)")"
echo "MEETING_SESSION_SECRET: $(mask "$(env_get MEETING_SESSION_SECRET)")"
echo "CRON_SECRET: $(mask "$(env_get CRON_SECRET)")"
echo "SMTP_HOST: $(env_get SMTP_HOST)"
echo "SMTP_PASSWORD: $(mask "$(env_get SMTP_PASSWORD)")"
echo "NPM_NETWORK: $(env_get NPM_NETWORK)"
echo "REGISTRY: $(env_get REGISTRY)/$(env_get REGISTRY_REPO)"
echo "REGISTRY_USERNAME: $(env_get REGISTRY_USERNAME)"
echo "REGISTRY_PASSWORD: $(mask "$(env_get REGISTRY_PASSWORD)")"
echo "AUTO_DEPLOY: $(env_get AUTO_DEPLOY)"
# ---------------------------------------------------------------------------
# Compose files + deploy.sh — always refreshed from the repo checkout
# (generated artifacts, not secrets/user data — safe to overwrite every run).
# ---------------------------------------------------------------------------
step "Deployment-Dateien"
$SUDO cp "$REPO_DIR/docker-compose.yml" "$PROD_DIR/docker-compose.yml"
$SUDO cp "$REPO_DIR/docker-compose.override.yml.example" "$PROD_DIR/docker-compose.override.yml.example"
$SUDO cp "$REPO_DIR/deploy.sh" "$PROD_DIR/deploy.sh"
$SUDO chmod +x "$PROD_DIR/deploy.sh"
log_success "docker-compose.yml + deploy.sh nach $PROD_DIR übertragen."
NPM_NETWORK_VALUE="$(env_get NPM_NETWORK)"
if [ -n "$NPM_NETWORK_VALUE" ]; then
sed "s/\${NPM_NETWORK}/$NPM_NETWORK_VALUE/g" "$PROD_DIR/docker-compose.override.yml.example" | $SUDO tee "$PROD_DIR/docker-compose.override.yml" >/dev/null
log_success "docker-compose.override.yml für Reverse-Proxy-Netzwerk '$NPM_NETWORK_VALUE' erstellt."
fi
# ---------------------------------------------------------------------------
# 10. Deploy user
# ---------------------------------------------------------------------------
step "Deployment-User"
if id "$DEPLOY_USER" >/dev/null 2>&1; then
log_info "Benutzer '$DEPLOY_USER' existiert bereits — unverändert."
else
DEPLOY_SHELL="/bin/bash"
command -v bash >/dev/null 2>&1 || DEPLOY_SHELL="/bin/sh"
# A real shell is required here — OpenSSH executes a forced authorized_keys
# "command=" via the account's login shell (`$SHELL -c "command"`). A
# nologin shell would swallow that command and refuse to run it, breaking
# deployment entirely. "No normal interactive login" is instead enforced
# by locking the password (below) and by the SSH key's forced command
# (see step 11) — never by the shell field.
$SUDO useradd --system --create-home --shell "$DEPLOY_SHELL" "$DEPLOY_USER"
$SUDO usermod -L "$DEPLOY_USER" >/dev/null 2>&1 || true
log_success "Benutzer '$DEPLOY_USER' erstellt (kein Passwort, kein normaler Login möglich)."
fi
if $DOCKER info >/dev/null 2>&1 && getent group docker >/dev/null 2>&1; then
$SUDO usermod -aG docker "$DEPLOY_USER"
log_success "'$DEPLOY_USER' zur docker-Gruppe hinzugefügt (notwendig für docker compose)."
fi
# Deliberately NO sudo rights of any kind for this user — see DEPLOYMENT.md
# ("Security") for the docker-group-is-root-equivalent trade-off and how the
# restricted SSH key + fixed deploy.sh logic mitigate it.
DEPLOY_HOME="$(getent passwd "$DEPLOY_USER" | cut -d: -f6)"
$SUDO chown -R "$DEPLOY_USER:$DEPLOY_USER" "$PROD_DIR" \
"$PROD_DIR/backups" "$PROD_DIR/media" "$PROD_DIR/docker-compose.yml" "$PROD_DIR/deploy.sh" "$ENV_FILE" 2>/dev/null || true
# .deploy/ (the SSH keypair) stays out of that blanket chown — see step 11.
# ---------------------------------------------------------------------------
# 1113. SSH deployment key + authorized_keys restriction
# ---------------------------------------------------------------------------
step "SSH Deployment Key"
KEY_PATH="$PROD_DIR/.deploy/id_ed25519"
SSH_KEY_FRESH=false
if [ -f "$KEY_PATH" ]; then
echo "Existing deployment key found."
log_info "SSH-Key wird NICHT neu generiert."
else
$SUDO ssh-keygen -t ed25519 -N "" -f "$KEY_PATH" -C "anouma-deploy@$(hostname -f 2>/dev/null || hostname)" >/dev/null
SSH_KEY_FRESH=true
log_success "Neuer SSH-Deployment-Key erzeugt."
fi
$SUDO chmod 700 "$PROD_DIR/.deploy"
$SUDO chmod 600 "$KEY_PATH"
$SUDO chmod 644 "$KEY_PATH.pub"
PUBLIC_KEY="$($SUDO cat "$KEY_PATH.pub")"
$SUDO mkdir -p "$DEPLOY_HOME/.ssh"
AUTH_KEYS="$DEPLOY_HOME/.ssh/authorized_keys"
$SUDO touch "$AUTH_KEYS"
FORCED_LINE="restrict,command=\"$PROD_DIR/deploy.sh\" $PUBLIC_KEY"
if $SUDO grep -qF "$PUBLIC_KEY" "$AUTH_KEYS" 2>/dev/null; then
log_info "Public Key ist bereits in authorized_keys eingetragen — unverändert."
else
printf '%s\n' "$FORCED_LINE" | $SUDO tee -a "$AUTH_KEYS" >/dev/null
log_success "Public Key mit eingeschränktem Forced-Command in authorized_keys eingetragen."
fi
$SUDO chmod 700 "$DEPLOY_HOME/.ssh"
$SUDO chmod 600 "$AUTH_KEYS"
$SUDO chown -R "$DEPLOY_USER:$DEPLOY_USER" "$DEPLOY_HOME/.ssh"
# ---------------------------------------------------------------------------
# Start Postgres now (harmless, needed regardless of whether a release has
# ever been deployed yet). The app image itself is only ever started by
# deploy.sh once at least one release has been pushed — a fresh install has
# no image to run yet.
# ---------------------------------------------------------------------------
step "Datenbank"
cd "$PROD_DIR"
# Relies on cwd (now $PROD_DIR) for compose file discovery — same as every
# other compose call below, so it consistently picks up
# docker-compose.override.yml too when one exists.
wait_healthy() {
local service="$1" timeout_iterations="$2" id status
for _ in $(seq 1 "$timeout_iterations"); do
id="$($COMPOSE --env-file "$ENV_FILE" ps -q "$service" 2>/dev/null || true)"
if [ -n "$id" ]; then
status="$($DOCKER inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)"
[ "$status" = "healthy" ] && return 0
fi
sleep 2
done
return 1
}
log_info "Starte Postgres …"
$COMPOSE --env-file "$ENV_FILE" up -d postgres
DB_READY=false
wait_healthy postgres 60 && DB_READY=true
if [ "$DB_READY" = true ]; then
log_success "Datenbank ist bereit."
else
log_warn "Datenbank wurde nicht rechtzeitig healthy — prüfe: $COMPOSE logs postgres"
fi
EXISTING_IMAGE="$(env_get ANOUMA_IMAGE)"
APP_READY=false
if [ -n "$EXISTING_IMAGE" ]; then
log_info "ANOUMA_IMAGE ist bereits gesetzt ($EXISTING_IMAGE) — starte Anwendung …"
if [ -n "$(env_get REGISTRY_USERNAME)" ]; then
echo "$(env_get REGISTRY_PASSWORD)" | $DOCKER login "$(env_get REGISTRY)" -u "$(env_get REGISTRY_USERNAME)" --password-stdin >/dev/null 2>&1 || log_warn "Registry-Login fehlgeschlagen — 'docker compose pull' könnte fehlschlagen."
fi
if $COMPOSE --env-file "$ENV_FILE" pull app 2>/dev/null && $COMPOSE --env-file "$ENV_FILE" run --rm app npm run migrate && $COMPOSE --env-file "$ENV_FILE" up -d; then
wait_healthy app 60 && APP_READY=true
fi
if [ "$APP_READY" = false ]; then
log_warn "Anwendung konnte nicht (neu) gestartet werden — prüfe: $COMPOSE logs app"
fi
else
log_info "Noch kein Release deployt — die Anwendung wird beim ersten Git-Tag automatisch über Gitea Actions gestartet."
if [ "$SKIP_SEED" = false ]; then
log_info "Hinweis: Inhalte können nach dem ersten Deploy mit 'docker compose run --rm app npm run seed' eingespielt werden."
fi
fi
cd "$REPO_DIR"
# ---------------------------------------------------------------------------
# 31. Summary
# ---------------------------------------------------------------------------
echo ""
echo "========================================"
echo "ANOUMA SETUP COMPLETE"
echo "========================================"
echo ""
echo "Application:"
echo " $(env_get NEXT_PUBLIC_SERVER_URL)"
echo ""
echo "Project:"
echo " maro/anouma"
echo ""
echo "Docker:"
echo " ✓"
echo ""
echo "Docker Compose:"
echo " ✓"
echo ""
echo "Database:"
if [ "$DB_READY" = true ]; then echo " ✓"; else echo " ✗ (siehe oben)"; fi
echo ""
echo "Docker Network:"
echo " ✓ ($(env_get DOCKER_NETWORK))"
echo ""
echo "Nginx Proxy Manager:"
if [ "$NPM_DETECTED" = true ]; then echo " ✓ ($DETECTED_NPM_NETWORK)"; else echo " not detected"; fi
echo ""
echo "Production User:"
echo " $DEPLOY_USER"
echo ""
echo "Deployment:"
echo " ✓"
echo ""
echo "Registry:"
if [ "$REGISTRY_REACHABLE" = true ]; then echo " ✓ ($(env_get REGISTRY))"; else echo " ⚠ nicht erreichbar geprüft ($(env_get REGISTRY))"; fi
echo ""
echo "Gitea Actions:"
echo " Ready"
echo ""
echo "Automatic Deployment:"
if [ "$(env_get AUTO_DEPLOY)" = "true" ]; then echo " Enabled"; else echo " Disabled"; fi
echo ""
echo "========================================"
echo "GITEA SECRETS"
echo "========================================"
echo ""
if [ "$SSH_KEY_FRESH" = true ]; then
echo "Trage folgende Werte EINMALIG unter Gitea → maro/anouma → Settings → Secrets → Actions ein."
echo "Der private Key wird nach diesem Lauf nicht erneut angezeigt."
echo ""
echo "DEPLOY_HOST:"
echo " $(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "<Server-IP eintragen>")"
echo ""
echo "DEPLOY_PORT:"
echo " 22"
echo ""
echo "DEPLOY_USER:"
echo " $DEPLOY_USER"
echo ""
echo "DEPLOY_SSH_KEY:"
echo "----------------------------------------"
$SUDO cat "$KEY_PATH"
echo "----------------------------------------"
echo ""
echo "REGISTRY_USERNAME:"
echo " $(env_get REGISTRY_USERNAME)"
echo ""
echo "REGISTRY_PASSWORD:"
echo " $(env_get REGISTRY_PASSWORD)"
else
echo "Existing deployment key found — Gitea Secrets wurden bereits bei der ersten Einrichtung ausgegeben."
echo "Falls sie nicht mehr vorliegen: DEPLOY_SSH_KEY kann sicher eingesehen werden mit:"
echo " sudo cat $KEY_PATH"
fi
echo ""
echo "========================================"
echo "NEXT STEP"
echo "========================================"
echo ""
echo "Add these values to:"
echo " Gitea → maro/anouma → Settings → Secrets → Actions"
echo ""
echo "Danach reicht für jedes zukünftige Deployment:"
echo " git tag v1.0.0 && git push origin v1.0.0"
echo ""
echo "Siehe DEPLOYMENT.md für den vollständigen Ablauf."
echo ""