#!/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 # --------------------------------------------------------------------------- # 6–8. .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. # # The value is written double-quoted, with backslashes and double quotes # escaped: deploy.sh later `source`s this file as a bash script, so an # unquoted value containing shell metacharacters (e.g. the default # SMTP_FROM="ANOUMA ", whose `<`/`>` are redirections) breaks # with "syntax error near unexpected token `newline'". Quoting also works # fine for Docker Compose's own env_file parsing, which strips matching # surrounding quotes. env_set() { local key="$1" value="$2" tmp escaped escaped="${value//\\/\\\\}" escaped="${escaped//\"/\\\"}" 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" "$escaped" >> "$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 " 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 }" "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. # --------------------------------------------------------------------------- # 11–13. 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 "")" 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 ""