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>
This commit is contained in:
@@ -13,3 +13,6 @@ backups
|
|||||||
*.AppImage
|
*.AppImage
|
||||||
README.md
|
README.md
|
||||||
DEPLOYMENT.md
|
DEPLOYMENT.md
|
||||||
|
setup.sh
|
||||||
|
deploy.sh
|
||||||
|
.gitea
|
||||||
|
|||||||
+34
-10
@@ -1,3 +1,9 @@
|
|||||||
|
# This file documents every variable the app/deployment actually reads.
|
||||||
|
# In production, setup.sh generates the real file at /opt/anouma/.env for
|
||||||
|
# you — you shouldn't need to hand-edit this. For local development, copy
|
||||||
|
# this to .env at the repo root and fill in DATABASE_URI/PAYLOAD_SECRET
|
||||||
|
# (see README.md → "Lokale Entwicklung").
|
||||||
|
|
||||||
# ── Database ────────────────────────────────────────────────────────────
|
# ── Database ────────────────────────────────────────────────────────────
|
||||||
# PostgreSQL connection string used by Payload. setup.sh fills this in
|
# PostgreSQL connection string used by Payload. setup.sh fills this in
|
||||||
# automatically (host "postgres" when running the full stack via Docker
|
# automatically (host "postgres" when running the full stack via Docker
|
||||||
@@ -12,11 +18,11 @@ POSTGRES_PASSWORD=replace-with-a-long-random-secret
|
|||||||
POSTGRES_DB=anouma
|
POSTGRES_DB=anouma
|
||||||
|
|
||||||
# ── Core secrets ────────────────────────────────────────────────────────
|
# ── Core secrets ────────────────────────────────────────────────────────
|
||||||
# Signs Payload's auth tokens/cookies. Generate with: openssl rand -base64 48
|
# Signs Payload's auth tokens/cookies. Generate with: openssl rand -hex 32
|
||||||
PAYLOAD_SECRET=replace-with-a-long-random-secret
|
PAYLOAD_SECRET=replace-with-a-long-random-secret
|
||||||
|
|
||||||
# Signs meeting join tokens (kept separate from PAYLOAD_SECRET on purpose).
|
# Signs meeting join tokens (kept separate from PAYLOAD_SECRET on purpose).
|
||||||
# Generate with: openssl rand -base64 48
|
# Generate with: openssl rand -hex 32
|
||||||
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
|
MEETING_SESSION_SECRET=replace-with-a-long-random-secret
|
||||||
|
|
||||||
# Sent as "Authorization: Bearer <value>" by the external cron job that
|
# Sent as "Authorization: Bearer <value>" by the external cron job that
|
||||||
@@ -48,6 +54,12 @@ TURN_SERVER=
|
|||||||
TURN_USERNAME=
|
TURN_USERNAME=
|
||||||
TURN_PASSWORD=
|
TURN_PASSWORD=
|
||||||
|
|
||||||
|
# ── SEO ──────────────────────────────────────────────────────────────────
|
||||||
|
# Optional Google Search Console domain-verification token (Search Console →
|
||||||
|
# Settings → Ownership verification → HTML tag → the "content" value only).
|
||||||
|
# Leave empty if not verifying with Google. Never commit a real value here.
|
||||||
|
GOOGLE_SITE_VERIFICATION=
|
||||||
|
|
||||||
# ── Docker networking ───────────────────────────────────────────────────
|
# ── Docker networking ───────────────────────────────────────────────────
|
||||||
# Internal network name for ANOUMA's own containers.
|
# Internal network name for ANOUMA's own containers.
|
||||||
DOCKER_NETWORK=anouma-network
|
DOCKER_NETWORK=anouma-network
|
||||||
@@ -57,12 +69,24 @@ DOCKER_NETWORK=anouma-network
|
|||||||
# directly. Leave empty if you don't use one — setup.sh auto-detects this.
|
# directly. Leave empty if you don't use one — setup.sh auto-detects this.
|
||||||
NPM_NETWORK=
|
NPM_NETWORK=
|
||||||
|
|
||||||
# ── Updates & backups (used by update.sh) ──────────────────────────────
|
# ── Backups (used by deploy.sh) ─────────────────────────────────────────
|
||||||
# When true, setup.sh installs a cron entry that checks for new releases
|
# How long (days) to keep database backups created before every deployment.
|
||||||
# and updates automatically. Off by default — update.sh can always be run
|
|
||||||
# manually regardless of this setting.
|
|
||||||
AUTO_UPDATE=false
|
|
||||||
|
|
||||||
# How long (days) to keep database backups created by update.sh before
|
|
||||||
# deleting them.
|
|
||||||
BACKUP_RETENTION_DAYS=14
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# ── Container registry & deployment (used by deploy.sh) ────────────────
|
||||||
|
# Gitea's built-in container registry. deploy.sh builds nothing — it only
|
||||||
|
# ever pulls this image (see .gitea/workflows/release.yml, which is what
|
||||||
|
# actually builds and pushes it).
|
||||||
|
REGISTRY=git.maro.run
|
||||||
|
REGISTRY_REPO=maro/anouma
|
||||||
|
# Only needed if the registry/repository requires authentication to pull.
|
||||||
|
# Use a Gitea access token as the password, not your account password.
|
||||||
|
REGISTRY_USERNAME=
|
||||||
|
REGISTRY_PASSWORD=
|
||||||
|
# Set automatically by deploy.sh on every successful deploy — do not edit
|
||||||
|
# by hand. Empty until the first release has been deployed.
|
||||||
|
ANOUMA_IMAGE=
|
||||||
|
# Informational only (shown in the setup summary) — actual automatic
|
||||||
|
# deployment is controlled by whether release.yml + its Gitea secrets are
|
||||||
|
# configured, not by this flag.
|
||||||
|
AUTO_DEPLOY=true
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-alpine
|
||||||
|
# No live database is needed here — every Payload-backed route in this
|
||||||
|
# app is force-dynamic, so `next build` never touches Postgres. These
|
||||||
|
# are dummy values only so a real config error (a missing secret) is
|
||||||
|
# never silently masked by CI happening to run without one.
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||||
|
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||||
|
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
||||||
|
CRON_SECRET: ci-only-not-a-real-secret
|
||||||
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install build dependencies (sharp needs libc6-compat on alpine)
|
||||||
|
run: apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:22-alpine
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
|
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||||
|
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||||
|
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
||||||
|
CRON_SECRET: ci-only-not-a-real-secret
|
||||||
|
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: apk add --no-cache libc6-compat
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npx tsc --noEmit
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# No container here — this job needs the Docker CLI + an SSH client
|
||||||
|
# directly on the runner host, not inside a Node container.
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Determine image tags
|
||||||
|
id: image
|
||||||
|
# Gitea Actions' runner is protocol-compatible with GitHub Actions,
|
||||||
|
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
|
||||||
|
# mechanism (the `gitea.*` vs `github.*` expression contexts are
|
||||||
|
# just aliases of each other for `${{ }}` templating).
|
||||||
|
run: |
|
||||||
|
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
REGISTRY: git.maro.run
|
||||||
|
REGISTRY_REPO: maro/anouma
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ steps.image.outputs.registry }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
||||||
|
VERSION="${{ steps.image.outputs.version }}"
|
||||||
|
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
|
||||||
|
VERSION="${{ steps.image.outputs.version }}"
|
||||||
|
docker push "$IMAGE:$VERSION"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
|
||||||
|
- name: Deploy to production
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
# Optional: pin the exact host key instead of trusting it on
|
||||||
|
# first connect (see DEPLOYMENT.md). Safe to leave unset.
|
||||||
|
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
|
||||||
|
VERSION: ${{ steps.image.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
key_file="$(mktemp)"
|
||||||
|
known_hosts_file="$(mktemp)"
|
||||||
|
trap 'rm -f "$key_file" "$known_hosts_file"' EXIT
|
||||||
|
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > "$key_file"
|
||||||
|
chmod 600 "$key_file"
|
||||||
|
|
||||||
|
if [ -n "${DEPLOY_HOST_KEY:-}" ]; then
|
||||||
|
printf '%s\n' "$DEPLOY_HOST_KEY" > "$known_hosts_file"
|
||||||
|
else
|
||||||
|
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > "$known_hosts_file" 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The forced command in the server's authorized_keys (see
|
||||||
|
# setup.sh) ignores this and always runs deploy.sh itself — the
|
||||||
|
# string below only ever reaches it via $SSH_ORIGINAL_COMMAND.
|
||||||
|
ssh -i "$key_file" -o UserKnownHostsFile="$known_hosts_file" -p "${DEPLOY_PORT:-22}" \
|
||||||
|
"${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${VERSION}"
|
||||||
+6
-3
@@ -49,9 +49,12 @@ next-env.d.ts
|
|||||||
*.deb
|
*.deb
|
||||||
*.AppImage
|
*.AppImage
|
||||||
|
|
||||||
# docker / deployment (machine-specific, generated by setup.sh)
|
# docker / deployment (machine-specific, generated by setup.sh — production
|
||||||
|
# artifacts actually live in /opt/anouma on the server, never inside this
|
||||||
|
# repo checkout, but these are ignored here too in case of local testing)
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
/backups
|
/backups
|
||||||
.update.lock
|
.deploy.lock
|
||||||
.installed-version
|
.current-version
|
||||||
|
.deploy/
|
||||||
|
|
||||||
|
|||||||
+128
-89
@@ -1,48 +1,118 @@
|
|||||||
# ANOUMA — Deployment
|
# ANOUMA — Deployment
|
||||||
|
|
||||||
Full Docker-based deployment guide. For local development without Docker (running `next dev` directly against a Dockerized Postgres), see `README.md` instead.
|
Production deployment is fully CI/CD-driven via Gitea Actions. There is no `update.sh` and the production server never builds anything — it only ever pulls a tested image from the registry.
|
||||||
|
|
||||||
## Quick start
|
```text
|
||||||
|
Developer → git push → Gitea → Gitea Actions
|
||||||
|
→ Tests → Docker Build → Registry Push
|
||||||
|
→ Release (git tag vX.Y.Z) → SSH → deploy.sh
|
||||||
|
→ docker compose pull → migrate → up -d → Healthcheck → LIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
## One-time server setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.maro.run/maro/anouma.git
|
git clone https://git.maro.run/maro/anouma.git
|
||||||
cd anouma
|
cd anouma
|
||||||
|
chmod +x setup.sh
|
||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it — `setup.sh` builds the images, starts Postgres, waits for it to be healthy, runs migrations, optionally seeds the original ANOUMA content, starts the app, and prints a status summary. Docker and Docker Compose are the only host requirements; Node.js/npm are **not** needed on the host — everything runs inside the `app` container.
|
`setup.sh` needs root (directly or via `sudo`) because it creates a system user and writes to `/opt`. It:
|
||||||
|
|
||||||
Run `./setup.sh --non-interactive` for sensible defaults with no prompts (useful for scripted/CI installs), or `--skip-seed` to skip content seeding.
|
1. Checks/installs Docker, Docker Compose, git, openssl, curl, ssh.
|
||||||
|
2. Creates `/opt/anouma` (refuses to touch it if an installation already exists there — you choose "use existing" or abort; nothing is ever deleted).
|
||||||
|
3. Detects an existing Nginx Proxy Manager network and optionally joins it.
|
||||||
|
4. Writes `/opt/anouma/.env` with every secret the app needs, freshly generated (`openssl rand -hex 32`) and **never regenerated** on a later run.
|
||||||
|
5. Asks the few things that can't be generated (domain, optionally SMTP, optionally the registry credentials) — skipped automatically if already answered before.
|
||||||
|
6. Creates a restricted `anouma-deploy` system user, generates a dedicated SSH keypair for it, and installs the public key with a **forced command** so that key can only ever run `deploy.sh` — never an interactive shell.
|
||||||
|
7. Copies `docker-compose.yml` and `deploy.sh` into `/opt/anouma`.
|
||||||
|
8. Starts Postgres (so it's ready for the first deploy).
|
||||||
|
9. Prints the exact values to paste into **Gitea → maro/anouma → Settings → Secrets → Actions**, including the deploy private key — shown only once, the first time the key is generated.
|
||||||
|
|
||||||
`setup.sh` is safe to re-run on an existing installation — it never overwrites a secret that's already in `.env`, never deletes a volume, network or container, and never touches an existing Nginx Proxy Manager (or any other container) beyond optionally joining its network.
|
Run `./setup.sh --non-interactive` for a scripted install; any value that has no safe default and wasn't exported as an environment variable beforehand aborts with a clear message naming the missing variable, instead of silently guessing.
|
||||||
|
|
||||||
## Architecture
|
Re-running `./setup.sh` later (e.g. after pulling script updates) is always safe: existing secrets, the SSH key, the database and all volumes are left exactly as they are — only `docker-compose.yml` and `deploy.sh` themselves get refreshed, since those are generated artifacts, not data.
|
||||||
|
|
||||||
```
|
### After setup: add the Gitea secrets
|
||||||
docker-compose.yml
|
|
||||||
│
|
Copy the block `setup.sh` printed into **Gitea → maro/anouma → Settings → Secrets → Actions**:
|
||||||
├── postgres PostgreSQL 16, persisted in the "pgdata" volume
|
|
||||||
│
|
| Secret | Value |
|
||||||
└── app Next.js + Payload CMS + WebRTC signaling (server.ts)
|
| --- | --- |
|
||||||
— one process, one container. There is no separate
|
| `DEPLOY_HOST` | the server's IP/hostname |
|
||||||
"signaling" service: the WebSocket signaling server is
|
| `DEPLOY_PORT` | `22` (or your SSH port) |
|
||||||
attached to the same custom Node server that serves the
|
| `DEPLOY_USER` | `anouma-deploy` |
|
||||||
website and admin panel (see server.ts), so it scales and
|
| `DEPLOY_SSH_KEY` | the private key setup.sh printed |
|
||||||
deploys as a single unit.
|
| `DEPLOY_HOST_KEY` | optional — pin the server's SSH host public key instead of trusting it on first connect (`ssh-keyscan -p <port> <host>` on a machine you already trust) |
|
||||||
|
| `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | registry push credentials (a Gitea access token as the password, not your account password) |
|
||||||
|
|
||||||
|
### First deploy
|
||||||
|
|
||||||
|
Nothing runs on the server yet at this point — no release has been built. Push the first tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v1.0.0
|
||||||
|
git push origin v1.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Both containers join the internal `anouma-network` (name configurable via `DOCKER_NETWORK` in `.env`). The `app` container optionally also joins an external reverse-proxy network — see below.
|
Gitea Actions takes it from there (see below). Once it's live, seed the original content once: `ssh anouma-deploy@<host>` won't work (the key is restricted — see Security), so run it directly on the server instead: `cd /opt/anouma && docker compose run --rm app npm run seed`.
|
||||||
|
|
||||||
|
## Gitea Actions
|
||||||
|
|
||||||
|
### CI (`.gitea/workflows/ci.yml`)
|
||||||
|
|
||||||
|
Runs on every push and PR: `npm ci` → lint → typecheck → test → build, on the `ubuntu-latest`-labelled runner. No database is required — every Payload-backed route in this app is `force-dynamic`, so a production build never touches Postgres. (There's no automated test suite yet — `npm test` is an honest placeholder; add a real one, e.g. vitest, whenever the project needs it, and it'll run here automatically.)
|
||||||
|
|
||||||
|
### Release (`.gitea/workflows/release.yml`)
|
||||||
|
|
||||||
|
Triggered by pushing a tag matching `v*` (e.g. `v1.2.0`):
|
||||||
|
|
||||||
|
1. Re-runs the same quality gates as CI.
|
||||||
|
2. Builds the Docker image and pushes `git.maro.run/maro/anouma:vX.Y.Z` **and** `:latest`.
|
||||||
|
3. SSHes into the production server as `anouma-deploy` and runs `deploy.sh deploy vX.Y.Z`.
|
||||||
|
|
||||||
|
Production always runs a specific version tag — `latest` is pushed for convenience/reference only, `deploy.sh` never uses it.
|
||||||
|
|
||||||
|
## `deploy.sh` (server-side, `/opt/anouma/deploy.sh`)
|
||||||
|
|
||||||
|
Not a manual tool — it's invoked exclusively by Gitea Actions over SSH. What it does for `deploy.sh deploy vX.Y.Z`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Lock (refuse a second concurrent deploy)
|
||||||
|
→ Backup the database
|
||||||
|
→ Set ANOUMA_IMAGE in .env to the new tag
|
||||||
|
→ Registry login (if credentials are configured)
|
||||||
|
→ docker compose pull
|
||||||
|
→ docker compose run --rm app npm run migrate
|
||||||
|
→ docker compose up -d
|
||||||
|
→ Healthcheck
|
||||||
|
→ success, or: automatic rollback to the previous version
|
||||||
|
```
|
||||||
|
|
||||||
|
If the post-deploy healthcheck fails, `deploy.sh` automatically redeploys the **previous** version (recorded from `ANOUMA_IMAGE` before the attempt) and reports the failed deploy as exit code 1 — Gitea Actions shows the release as failed even though production recovered. Database migrations from the failed release are **not** reverted (write migrations to be forward-compatible); restore the pre-deploy backup manually if a migration truly needs undoing.
|
||||||
|
|
||||||
|
A lock file (`/opt/anouma/.deploy.lock`) prevents two deploys from running at once; a stale lock from a killed process is detected and ignored automatically.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Every deploy backs up the database first, to `/opt/anouma/backups/database-YYYY-MM-DD-HHMM.sql.gz`, before anything else changes. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically — the backup just created is never deleted, even if retention is set very low.
|
||||||
|
|
||||||
|
Restore manually if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/anouma
|
||||||
|
gunzip -c backups/database-2026-08-25-1430.sql.gz | docker compose exec -T postgres psql -U postgres anouma
|
||||||
|
```
|
||||||
|
|
||||||
## Nginx Proxy Manager (or any reverse proxy)
|
## Nginx Proxy Manager (or any reverse proxy)
|
||||||
|
|
||||||
`setup.sh` looks for a running Nginx Proxy Manager container (or a Docker network whose name looks like one) and, if found, asks whether to join its network. If you'd rather do this manually (or NPM wasn't running yet when you set up ANOUMA):
|
`setup.sh` detects a running NPM container and, if you confirm, generates `/opt/anouma/docker-compose.override.yml` joining its network automatically. To do it manually instead:
|
||||||
|
|
||||||
1. Find its network: `docker network ls`
|
1. Find its network: `docker network ls`
|
||||||
2. Set `NPM_NETWORK=<that-name>` in `.env`
|
2. Set `NPM_NETWORK=<that-name>` in `/opt/anouma/.env`
|
||||||
3. `cp docker-compose.override.yml.example docker-compose.override.yml`
|
3. `cp docker-compose.override.yml.example docker-compose.override.yml` (in `/opt/anouma`), replacing `${NPM_NETWORK}`
|
||||||
4. `docker compose up -d` — Compose picks up `docker-compose.override.yml` automatically
|
4. `docker compose up -d`
|
||||||
|
|
||||||
`docker-compose.override.yml` is machine-specific and gitignored on purpose.
|
|
||||||
|
|
||||||
In Nginx Proxy Manager, add a Proxy Host:
|
In Nginx Proxy Manager, add a Proxy Host:
|
||||||
|
|
||||||
@@ -50,30 +120,10 @@ In Nginx Proxy Manager, add a Proxy Host:
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Domain | your domain, e.g. `anouma.org` |
|
| Domain | your domain, e.g. `anouma.org` |
|
||||||
| Scheme | `http` |
|
| Scheme | `http` |
|
||||||
| Forward Host | `app` (the Compose service name — reachable by name once on the same network) |
|
| Forward Host | `app` (the Compose service name) |
|
||||||
| Forward Port | `3000` |
|
| Forward Port | `3000` |
|
||||||
| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) |
|
| **Websockets Support** | **enabled** — required for the video-call signaling (`/ws/signaling`) |
|
||||||
|
|
||||||
Once a reverse proxy reaches the container directly over the shared network, you can remove the `ports:` mapping for `app` in `docker-compose.yml` so the app isn't also reachable directly on the host.
|
|
||||||
|
|
||||||
If no reverse proxy is configured, ANOUMA still works standalone — the app is published on `http://localhost:${APP_PORT:-3000}` (bound to `127.0.0.1` by default; open that up in `docker-compose.yml` if you need it reachable from outside without a proxy).
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
See `.env.example` for the full list with inline explanations. Only variables the application actually reads are defined — highlights:
|
|
||||||
|
|
||||||
| Variable | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection — kept in sync automatically by `setup.sh` |
|
|
||||||
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated on first run, never overwritten afterwards |
|
|
||||||
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site (used for admin/media links and email links) |
|
|
||||||
| `SMTP_*` | The existing ANOUMA mail system (booking/reminder emails) |
|
|
||||||
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers — see below |
|
|
||||||
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names (see above) |
|
|
||||||
| `AUTO_UPDATE`, `BACKUP_RETENTION_DAYS` | See Updates below |
|
|
||||||
|
|
||||||
Secrets are only ever written to `.env` on your server, never committed (`.gitignore` excludes `.env*` except `.env.example`).
|
|
||||||
|
|
||||||
## WebRTC: signaling, STUN, TURN
|
## WebRTC: signaling, STUN, TURN
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -91,68 +141,57 @@ Client A ═══════════ P2P WebRTC ════════
|
|||||||
Audio / Video / Screen
|
Audio / Video / Screen
|
||||||
```
|
```
|
||||||
|
|
||||||
The signaling server never sees media. By default, clients use Google's public STUN server (`STUN_SERVER` in `.env`), which is enough for most networks. Behind strict NATs/corporate firewalls, P2P via STUN alone can fail — add a TURN server by setting `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` in `.env` and restarting the app container; no code changes are required. (This project doesn't run a TURN server itself — coturn is a common self-hosted option if you need one.)
|
STUN is on by default (`STUN_SERVER` in `.env`). Add a TURN server for restrictive NATs/firewalls by setting `TURN_SERVER`/`TURN_USERNAME`/`TURN_PASSWORD` and redeploying — no code changes needed.
|
||||||
|
|
||||||
## Updates
|
## Environment variables
|
||||||
|
|
||||||
```bash
|
See `.env.example` for the full, commented list. `setup.sh` writes the real file to `/opt/anouma/.env` for you.
|
||||||
./update.sh # update to the latest release tag
|
|
||||||
./update.sh v1.2.0 # update to a specific tag
|
|
||||||
```
|
|
||||||
|
|
||||||
Update flow: backup → fetch tags → checkout the release → rebuild → migrate → restart → healthcheck. If the healthcheck fails, the **code** is automatically rolled back to the previous version (database migrations are not reverted — write migrations to be forward-compatible; see Rollback below).
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
Only real release tags (`vX.Y.Z`) are deployed — `update.sh` deliberately never force-deploys whatever happens to be on `main`. If no tags exist yet in the repository, it does nothing and says so.
|
| `DATABASE_URI`, `POSTGRES_*` | Postgres connection |
|
||||||
|
| `PAYLOAD_SECRET`, `MEETING_SESSION_SECRET`, `CRON_SECRET` | Auto-generated, never overwritten afterwards |
|
||||||
An update lock (`.update.lock`) prevents two updates from running concurrently.
|
| `NEXT_PUBLIC_SERVER_URL` | Public URL of the site |
|
||||||
|
| `SMTP_*` | The existing ANOUMA mail system |
|
||||||
### Automatic updates
|
| `STUN_SERVER`, `TURN_SERVER`, `TURN_USERNAME`, `TURN_PASSWORD` | WebRTC ICE servers |
|
||||||
|
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
|
||||||
Off by default (`AUTO_UPDATE=false`). Set `AUTO_UPDATE=true` in `.env` and re-run `./setup.sh` to install a cron job that checks for new releases every 30 minutes and updates automatically when one appears — otherwise it's a no-op. Disable again by setting `AUTO_UPDATE=false` and removing the `# anouma-auto-update` line from `crontab -e`.
|
| `BACKUP_RETENTION_DAYS` | Deploy-backup retention |
|
||||||
|
| `REGISTRY`, `REGISTRY_REPO`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD` | Where `deploy.sh` pulls the image from |
|
||||||
### Backups
|
| `ANOUMA_IMAGE` | Set automatically by `deploy.sh` on every deploy — don't edit by hand |
|
||||||
|
|
||||||
Every update creates `backups/database-YYYY-MM-DD-HHMM.sql.gz` before touching anything. Backups older than `BACKUP_RETENTION_DAYS` (default 14) are cleaned up automatically — the backup just created is never deleted, even if retention is set very low. Uploaded media lives in the `./media` bind mount, which isn't touched by updates at all.
|
|
||||||
|
|
||||||
### Rollback
|
|
||||||
|
|
||||||
Automatic on a failed post-update healthcheck (see above). To roll back manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout vX.Y.Z
|
|
||||||
docker compose build
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
If a migration from the failed release isn't backward-compatible, restore the pre-update backup instead:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gunzip -c backups/database-2026-08-25-1430.sql.gz | docker compose exec -T postgres psql -U postgres anouma
|
|
||||||
```
|
|
||||||
|
|
||||||
## Persistent data
|
## Persistent data
|
||||||
|
|
||||||
Nothing about rebuilding or updating containers ever deletes data:
|
Deployments never delete data:
|
||||||
|
|
||||||
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
- **Database** — the `pgdata` named volume, independent of the `postgres` container's lifecycle.
|
||||||
- **Uploaded media** — the `./media` bind mount, independent of the `app` container's lifecycle.
|
- **Uploaded media** — the `/opt/anouma/media` bind mount, independent of the `app` container's lifecycle.
|
||||||
- **Backups** — the `./backups` directory on the host.
|
- **Backups** — `/opt/anouma/backups`.
|
||||||
|
|
||||||
`setup.sh` and `update.sh` never run `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v` — none of the scripts in this repo do.
|
Neither `setup.sh` nor `deploy.sh` ever runs `docker system prune`, `docker volume prune`, `docker network prune`, or `docker compose down -v`. Neither touches a container or network it doesn't own (an existing NPM install is never modified beyond optionally joining its network).
|
||||||
|
|
||||||
## Healthchecks
|
## Healthchecks
|
||||||
|
|
||||||
- **app**: `GET /api/health` (built into the Docker image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
|
- **app**: `GET /api/health` (built into the image's `HEALTHCHECK`) — checks that the app can actually query Postgres, not just that the process is listening.
|
||||||
- **postgres**: `pg_isready`.
|
- **postgres**: `pg_isready`.
|
||||||
|
|
||||||
`docker compose ps` shows both statuses.
|
`docker compose ps` (from `/opt/anouma`) shows both statuses.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- `anouma-deploy` has **no sudo rights at all** and **no password** — the only way in is its dedicated SSH key.
|
||||||
|
- That SSH key is restricted with `restrict,command="/opt/anouma/deploy.sh"` in `authorized_keys`: whatever command an SSH client requests is ignored and `deploy.sh` runs instead (the client's actual request only reaches it via `$SSH_ORIGINAL_COMMAND`, which `deploy.sh` validates against a strict `deploy vX.Y.Z` pattern before doing anything). No arbitrary shell access is possible through this key, even if it leaks.
|
||||||
|
- `anouma-deploy` **is** a member of the `docker` group, because running `docker compose` requires it — and Docker-group membership is well known to be effectively root-equivalent (a container can bind-mount the host filesystem). This is a deliberate, documented trade-off: the blast radius is contained by the two points above (no interactive access, and the only reachable code path is the fixed `deploy.sh` logic), not eliminated. For a stricter setup, consider rootless Docker or a dedicated deployment tool with finer-grained Docker API ACLs — out of scope here.
|
||||||
|
- `.env` and the deploy SSH private key are `chmod 600`.
|
||||||
|
- Secrets are never committed, never logged, and never printed more than once (`setup.sh` shows the private key only the first time it's generated; on a later run it prints "Existing deployment key found." instead).
|
||||||
|
- `REGISTRY_PASSWORD`/`SMTP_PASSWORD`/etc. are masked (`********`) in `setup.sh`'s configuration summary — only the one-time Gitea-secrets block shows real values, since that block exists specifically for you to copy them into Gitea.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
| Symptom | Check |
|
| Symptom | Check |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
|
| `setup.sh` fails at "Datenbank wurde nicht rechtzeitig healthy" | `docker compose logs postgres` (from `/opt/anouma`) — usually a bad `POSTGRES_PASSWORD`/`DATABASE_URI` mismatch if you hand-edited `.env` |
|
||||||
| App container unhealthy | `docker compose logs app`, then `curl http://localhost:3000/api/health` from inside the network (`docker compose exec app wget -qO- http://127.0.0.1:3000/api/health`) |
|
| Release workflow fails at "Deploy to production" | Check the Gitea secrets are all set correctly, and that the server's SSH port is reachable from the runner |
|
||||||
|
| App container unhealthy after a deploy | `ssh` directly to the server (not via the restricted key) and run `docker compose logs app` from `/opt/anouma` |
|
||||||
| Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) |
|
| Video calls connect but no audio/video | Likely a restrictive NAT — configure a TURN server (see above) |
|
||||||
| Reverse proxy shows a 502/connection reset on the call page | Websockets Support isn't enabled on the Nginx Proxy Manager proxy host |
|
| Reverse proxy shows a 502/connection reset on the call page | Websockets Support isn't enabled on the Nginx Proxy Manager proxy host |
|
||||||
| `update.sh` says "uncommittete Änderungen" | Someone edited files directly on the server outside of a release — `git status` to see what, then commit/stash before updating |
|
| Need to redeploy the same version | `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`, or push a new patch tag |
|
||||||
|
|||||||
@@ -40,19 +40,26 @@ Der Versand ist idempotent (`reminder60Sent`/`reminder30Sent` je Anmeldung, `hos
|
|||||||
|
|
||||||
## Setup (Produktion / Deployment)
|
## Setup (Produktion / Deployment)
|
||||||
|
|
||||||
Der empfohlene Weg ist vollständig dockerisiert und braucht auf dem Host weder Node noch npm:
|
Produktion läuft komplett über Gitea Actions CI/CD — der Server baut nichts selbst, er zieht ein fertiges, getestetes Image aus der Registry. Einmalig auf dem Server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.maro.run/maro/anouma.git
|
git clone https://git.maro.run/maro/anouma.git
|
||||||
cd anouma
|
cd anouma
|
||||||
|
chmod +x setup.sh
|
||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`setup.sh` fragt interaktiv nach Domain, ob eine bestehende Nginx Proxy Manager-Instanz eingebunden werden soll usw., generiert fehlende Secrets automatisch und überschreibt nie bereits gesetzte. Baut Images, startet Postgres, wartet auf dessen Healthcheck, migriert, seedet optional die Inhalte und startet die App. `./setup.sh --non-interactive` läuft ohne Rückfragen mit sinnvollen Defaults.
|
`setup.sh` installiert bei Bedarf Docker, legt `/opt/anouma` an, generiert alle Secrets automatisch (nie erneut, wenn schon vorhanden), richtet einen eingeschränkten Deployment-User samt SSH-Key für Gitea Actions ein und zeigt am Ende genau die Werte an, die als Gitea Secrets hinterlegt werden müssen. `./setup.sh --non-interactive` läuft ohne Rückfragen.
|
||||||
|
|
||||||
Updates auf ein neues Release: `./update.sh` (nur echte Release-Tags, nie ungetaggte `main`-Commits; Backup vor jedem Update, automatischer Rollback bei fehlgeschlagenem Healthcheck).
|
Jedes weitere Deployment läuft danach ausschließlich über einen Git-Tag:
|
||||||
|
|
||||||
Für alle Details (NPM-Reverse-Proxy-Einrichtung, Environment-Variablen, WebRTC/STUN/TURN, Backups, Rollback, Auto-Updates, Troubleshooting) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
```bash
|
||||||
|
git tag v1.2.0 && git push origin v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Gitea Actions testet, baut das Image, pusht es in die Registry und deployt es per SSH auf den Server — inklusive Backup, Healthcheck und automatischem Rollback bei Fehlschlag. Es gibt kein separates Update-Script.
|
||||||
|
|
||||||
|
Für alle Details (Architektur, Gitea Secrets, CI/Release-Workflows, NPM-Reverse-Proxy-Einrichtung, Environment-Variablen, WebRTC/STUN/TURN, Backups, Rollback, Security) siehe **[DEPLOYMENT.md](./DEPLOYMENT.md)**.
|
||||||
|
|
||||||
## Lokale Entwicklung (ohne Docker für die App)
|
## Lokale Entwicklung (ohne Docker für die App)
|
||||||
|
|
||||||
@@ -107,6 +114,8 @@ Für einen vollständig dockerisierten Produktionsbetrieb (App + Datenbank, Heal
|
|||||||
| `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
|
| `npm run generate:importmap`| Admin-Importmap neu erzeugen (nach neuen Feldtypen) |
|
||||||
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
| `npm run migrate:create` | Neue Datenbank-Migration aus Config-Änderungen bauen |
|
||||||
| `npm run lint` | ESLint |
|
| `npm run lint` | ESLint |
|
||||||
|
| `npm test` | Test-Suite (aktuell ein Platzhalter — noch keine Tests vorhanden) |
|
||||||
|
| `npm run seo:check` | Optionale SEO-Prüfung (doppelte Slugs, fehlende Alt-Texte) — nicht produktionskritisch |
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Used by the Docker healthcheck (see docker-compose.yml) and by
|
* Used by the Docker healthcheck (see docker-compose.yml) and by
|
||||||
* setup.sh/update.sh to confirm the app can actually reach Postgres before
|
* setup.sh/deploy.sh to confirm the app can actually reach Postgres before
|
||||||
* being considered "up" — not just that the Node process is listening.
|
* being considered "up" — not just that the Node process is listening.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ANOUMA — server-side deployment script.
|
||||||
|
#
|
||||||
|
# This is NOT a manual update tool. It is invoked exclusively by Gitea
|
||||||
|
# Actions over SSH as the restricted `anouma-deploy` user (see setup.sh,
|
||||||
|
# which installs a forced-command SSH key that can only ever run this
|
||||||
|
# script). The production server never builds an image — it only ever
|
||||||
|
# pulls an already-built, already-tested tag from the registry.
|
||||||
|
#
|
||||||
|
# deploy.sh deploy vX.Y.Z
|
||||||
|
#
|
||||||
|
# Runs from /opt/anouma (installed there by setup.sh). Safe by design:
|
||||||
|
# never deletes volumes, never force-deploys an untagged commit, always
|
||||||
|
# backs up the database first, and rolls the *code* back automatically if
|
||||||
|
# the post-deploy healthcheck fails.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
|
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"; }
|
||||||
|
fail() { log_error "$1"; exit 1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# When invoked through the restricted SSH key (see setup.sh), OpenSSH's
|
||||||
|
# forced "command=" ignores whatever the SSH client asked for and runs this
|
||||||
|
# script with NO arguments — the client's actual request arrives in
|
||||||
|
# $SSH_ORIGINAL_COMMAND instead. Re-split it into "$@" so the rest of this
|
||||||
|
# script doesn't need to care which path it came from. This is intentionally
|
||||||
|
# unquoted (word-splitting, not `eval`) — no shell metacharacter in
|
||||||
|
# SSH_ORIGINAL_COMMAND is ever interpreted as code, it just becomes literal
|
||||||
|
# argument text, which the strict version regex below then validates.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
if [ $# -eq 0 ] && [ -n "${SSH_ORIGINAL_COMMAND:-}" ]; then
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $SSH_ORIGINAL_COMMAND
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTION="${1:-}"
|
||||||
|
VERSION="${2:-}"
|
||||||
|
|
||||||
|
[ -f .env ] || fail ".env not found — run setup.sh on this server first."
|
||||||
|
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deployment lock — never run two deployments concurrently.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LOCK_FILE="$(pwd)/.deploy.lock"
|
||||||
|
if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then
|
||||||
|
log_warn "Deployment already running."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo $$ > "$LOCK_FILE"
|
||||||
|
trap 'rm -f "$LOCK_FILE"' EXIT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# .env helpers — same idempotent read/write approach as setup.sh.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
||||||
|
env_set() {
|
||||||
|
local key="$1" value="$2"
|
||||||
|
if grep -qE "^$key=" .env; then
|
||||||
|
local tmp; tmp="$(mktemp)"
|
||||||
|
awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> .env
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_healthy() {
|
||||||
|
local service="$1" timeout_iterations="$2" id status
|
||||||
|
for _ in $(seq 1 "$timeout_iterations"); do
|
||||||
|
id="$($COMPOSE 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
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_database() {
|
||||||
|
mkdir -p backups
|
||||||
|
local file="backups/database-$(date +%Y-%m-%d-%H%M).sql.gz"
|
||||||
|
if $COMPOSE ps -q postgres >/dev/null 2>&1 && [ -n "$($COMPOSE ps -q postgres)" ]; then
|
||||||
|
log_info "Backing up database → $file"
|
||||||
|
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$file"
|
||||||
|
log_success "Backup created ($(du -h "$file" | cut -f1))."
|
||||||
|
else
|
||||||
|
log_warn "Postgres is not running yet — skipping backup (first-ever deploy?)."
|
||||||
|
fi
|
||||||
|
local retention="${BACKUP_RETENTION_DAYS:-14}"
|
||||||
|
find backups -name 'database-*.sql.gz' -mtime "+${retention}" -not -name "$(basename "$file")" -delete 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
registry_login() {
|
||||||
|
if [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY:-git.maro.run}" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null 2>&1 \
|
||||||
|
&& log_success "Registry login OK." \
|
||||||
|
|| fail "Registry login failed — check REGISTRY_USERNAME/REGISTRY_PASSWORD in .env."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sets ANOUMA_IMAGE in .env to the given release tag, then re-sources .env so
|
||||||
|
# the rest of this script (and `docker compose`, via env_file) picks it up.
|
||||||
|
set_image_version() {
|
||||||
|
local version="$1"
|
||||||
|
env_set ANOUMA_IMAGE "${REGISTRY:-git.maro.run}/${REGISTRY_REPO:-maro/anouma}:${version}"
|
||||||
|
set -a; source .env; set +a
|
||||||
|
}
|
||||||
|
|
||||||
|
# One full deploy attempt for a given version tag: pull → migrate → start →
|
||||||
|
# healthcheck. Returns non-zero on any failure without partially cleaning up
|
||||||
|
# — the caller decides whether to retry with the previous version.
|
||||||
|
deploy_version() {
|
||||||
|
local version="$1"
|
||||||
|
set_image_version "$version"
|
||||||
|
registry_login
|
||||||
|
log_info "Pulling ${ANOUMA_IMAGE} …"
|
||||||
|
$COMPOSE pull app || return 1
|
||||||
|
log_info "Running database migrations …"
|
||||||
|
$COMPOSE run --rm app npm run migrate || return 1
|
||||||
|
log_info "Starting containers …"
|
||||||
|
$COMPOSE up -d || return 1
|
||||||
|
log_info "Waiting for healthcheck …"
|
||||||
|
wait_healthy app 60
|
||||||
|
}
|
||||||
|
|
||||||
|
do_deploy() {
|
||||||
|
local version="$1"
|
||||||
|
[[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Invalid version '${version}' (expected vX.Y.Z)."
|
||||||
|
|
||||||
|
set -a; source .env; set +a
|
||||||
|
local previous_version=""
|
||||||
|
local previous_image; previous_image="$(env_get ANOUMA_IMAGE)"
|
||||||
|
if [[ "$previous_image" =~ :(v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
|
previous_version="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Deploying ${version} (current: ${previous_version:-none}) …"
|
||||||
|
backup_database
|
||||||
|
|
||||||
|
if deploy_version "$version"; then
|
||||||
|
log_success "${version} is live."
|
||||||
|
echo "$version" > .current-version
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_error "${version} FAILED post-deploy healthcheck."
|
||||||
|
if [ -z "$previous_version" ]; then
|
||||||
|
fail "No previous version on record — nothing to roll back to. Check: $COMPOSE logs app"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_warn "Rolling back to ${previous_version} …"
|
||||||
|
if deploy_version "$previous_version"; then
|
||||||
|
log_warn "Rollback to ${previous_version} succeeded — production is back on the previous version."
|
||||||
|
log_warn "Database migrations from the failed deploy were NOT reverted (see DEPLOYMENT.md) — restore the backup above manually if ${version} shipped an incompatible migration."
|
||||||
|
echo "$previous_version" > .current-version
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "Rollback to ${previous_version} ALSO failed — manual intervention required: $COMPOSE logs app"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
deploy)
|
||||||
|
[ -n "$VERSION" ] || fail "Usage: deploy.sh deploy vX.Y.Z"
|
||||||
|
do_deploy "$VERSION"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: deploy.sh deploy vX.Y.Z" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+6
-2
@@ -29,7 +29,11 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
# Production never builds from source — the image is built once by
|
||||||
|
# Gitea Actions on release and pushed to the registry; this host only
|
||||||
|
# ever pulls a specific, already-tested tag (see deploy.sh, which is the
|
||||||
|
# only thing that ever changes ANOUMA_IMAGE in .env).
|
||||||
|
image: ${ANOUMA_IMAGE:-git.maro.run/maro/anouma:latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- anouma-network
|
- anouma-network
|
||||||
@@ -45,7 +49,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# Container-level healthcheck is inherited from the Dockerfile's
|
# Container-level healthcheck is inherited from the image's own
|
||||||
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
|
# HEALTHCHECK instruction (GET /api/health, which itself checks Postgres
|
||||||
# connectivity) — nothing to duplicate here.
|
# connectivity) — nothing to duplicate here.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -8,12 +8,14 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "NODE_ENV=production tsx server.ts",
|
"start": "NODE_ENV=production tsx server.ts",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "echo \"No automated test suite configured yet — add one (e.g. vitest) and update this script.\"",
|
||||||
"payload": "payload",
|
"payload": "payload",
|
||||||
"generate:types": "payload generate:types",
|
"generate:types": "payload generate:types",
|
||||||
"generate:importmap": "payload generate:importmap",
|
"generate:importmap": "payload generate:importmap",
|
||||||
"migrate": "payload migrate",
|
"migrate": "payload migrate",
|
||||||
"migrate:create": "payload migrate:create",
|
"migrate:create": "payload migrate:create",
|
||||||
"seed": "payload run scripts/seed.ts"
|
"seed": "payload run scripts/seed.ts",
|
||||||
|
"seo:check": "payload run scripts/seo-check.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@payloadcms/db-postgres": "^3.88.0",
|
"@payloadcms/db-postgres": "^3.88.0",
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ANOUMA — automated setup.
|
# ANOUMA — production server installer.
|
||||||
#
|
#
|
||||||
# Fresh install:
|
# git clone https://git.maro.run/maro/anouma.git && cd anouma
|
||||||
# git clone https://git.maro.run/maro/anouma.git && cd anouma && ./setup.sh
|
# chmod +x setup.sh && ./setup.sh
|
||||||
#
|
#
|
||||||
# Safe to re-run on an existing installation: never overwrites secrets that
|
# Prepares a fresh Ubuntu/Debian server for ANOUMA: installs Docker if
|
||||||
# already exist in .env, never deletes volumes/networks/containers it
|
# needed, creates /opt/anouma, writes a production .env with generated
|
||||||
# doesn't own, and never touches Nginx Proxy Manager (or any other existing
|
# secrets, creates a restricted `anouma-deploy` SSH user for Gitea Actions,
|
||||||
# container) beyond optionally joining its Docker network.
|
# 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
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
REPO_DIR="$(pwd)"
|
||||||
|
PROD_DIR="/opt/anouma"
|
||||||
|
DEPLOY_USER="anouma-deploy"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Logging
|
# Logging
|
||||||
@@ -19,12 +30,8 @@ log_info() { printf "${COLOR_INFO}[INFO]${COLOR_RESET} %s\n" "$1"; }
|
|||||||
log_warn() { printf "${COLOR_WARN}[WARN]${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_error() { printf "${COLOR_ERROR}[ERROR]${COLOR_RESET} %s\n" "$1" >&2; }
|
||||||
log_success() { printf "${COLOR_OK}[SUCCESS]${COLOR_RESET} %s\n" "$1"; }
|
log_success() { printf "${COLOR_OK}[SUCCESS]${COLOR_RESET} %s\n" "$1"; }
|
||||||
|
step() { printf "\n${COLOR_INFO}== %s ==${COLOR_RESET}\n" "$1"; }
|
||||||
fail() {
|
fail() { log_error "$1"; [ -n "${2:-}" ] && log_error " → $2"; exit 1; }
|
||||||
log_error "$1"
|
|
||||||
[ -n "${2:-}" ] && log_error " → $2"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Args
|
# Args
|
||||||
@@ -42,19 +49,40 @@ for arg in "$@"; do
|
|||||||
esac
|
esac
|
||||||
done
|
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() {
|
ask() {
|
||||||
# ask "Prompt" "default" -> echoes the answer
|
local prompt="$1" default="$2" env_name="${3:-}" answer
|
||||||
local prompt="$1" default="$2" answer
|
|
||||||
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
|
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"
|
echo "$default"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
read -r -p "$prompt [$default]: " answer || true
|
if [ -n "$default" ]; then
|
||||||
|
read -r -p "$prompt [Default: $default]: " answer || true
|
||||||
|
else
|
||||||
|
read -r -p "$prompt: " answer || true
|
||||||
|
fi
|
||||||
echo "${answer:-$default}"
|
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() {
|
ask_yn() {
|
||||||
# ask_yn "Prompt" "Y|N" -> echoes true/false
|
|
||||||
local prompt="$1" default="$2" answer
|
local prompt="$1" default="$2" answer
|
||||||
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
|
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
|
||||||
[ "$default" = "Y" ] && echo true || echo false
|
[ "$default" = "Y" ] && echo true || echo false
|
||||||
@@ -75,52 +103,141 @@ echo "============"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1–4: detect tooling
|
# 3. System check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden." "Installiere Docker: https://docs.docker.com/engine/install/"
|
step "System check"
|
||||||
log_success "Docker erkannt: $(docker --version)"
|
|
||||||
|
|
||||||
docker info >/dev/null 2>&1 || fail "Der Docker-Daemon läuft nicht (oder fehlende Berechtigung)." "Starte Docker bzw. führe dieses Skript mit einem Nutzer aus, der Docker verwenden darf."
|
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=""
|
COMPOSE=""
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if $DOCKER compose version >/dev/null 2>&1; then
|
||||||
COMPOSE="docker compose"
|
COMPOSE="$DOCKER compose"
|
||||||
elif command -v docker-compose >/dev/null 2>&1; then
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
COMPOSE="docker-compose"
|
COMPOSE="docker-compose"
|
||||||
fi
|
fi
|
||||||
[ -n "$COMPOSE" ] || fail "Docker Compose wurde nicht gefunden." "Installiere das Compose-Plugin: https://docs.docker.com/compose/install/"
|
[ -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)"
|
log_success "Docker Compose erkannt: $($COMPOSE version | head -n1)"
|
||||||
|
|
||||||
if command -v git >/dev/null 2>&1; then
|
|
||||||
log_success "Git erkannt: $(git --version)"
|
|
||||||
else
|
|
||||||
log_warn "Git wurde nicht gefunden — für update.sh (Release-Erkennung) empfohlen, aber für den Betrieb selbst nicht zwingend nötig."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Node/npm are NOT required on the host — everything runs inside the app
|
|
||||||
# container. This is purely informational.
|
|
||||||
if command -v node >/dev/null 2>&1; then
|
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 in Docker)"
|
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
|
else
|
||||||
log_info "Node.js nicht auf dem Host installiert — kein Problem, ANOUMA läuft vollständig in Docker."
|
log_info "Node.js nicht auf dem Host installiert — das ist beabsichtigt, npm wird nur innerhalb von Docker (CI-Build) verwendet."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 5–6: detect existing Docker networks, look for a reverse proxy (e.g. NPM)
|
# 4. /opt/anouma
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
log_info "Prüfe vorhandene Docker-Netzwerke …"
|
step "Projektverzeichnis"
|
||||||
EXISTING_NETWORKS="$(docker network ls --format '{{.Name}}' 2>/dev/null || true)"
|
|
||||||
|
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() {
|
detect_npm_network() {
|
||||||
# 1) Look for a running container that looks like Nginx Proxy Manager and
|
|
||||||
# read the network it's actually attached to.
|
|
||||||
local npm_container
|
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)"
|
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
|
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
|
$DOCKER inspect "$npm_container" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | grep -v '^bridge$' | head -n1
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
# 2) Fall back to a name heuristic among existing networks.
|
|
||||||
echo "$EXISTING_NETWORKS" | grep -iE 'nginx-proxy-manager|^npm' | head -n1 || true
|
echo "$EXISTING_NETWORKS" | grep -iE 'nginx-proxy-manager|^npm' | head -n1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,57 +251,78 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7–10: .env — create if missing, fill in only missing keys otherwise,
|
# 6–8. .env — full generation, secrets, interactive questions
|
||||||
# generate secrets, never overwrite existing ones
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
step ".env"
|
||||||
|
|
||||||
|
ENV_FILE="$PROD_DIR/.env"
|
||||||
gen_secret() { openssl rand -hex 32; }
|
gen_secret() { openssl rand -hex 32; }
|
||||||
|
|
||||||
FRESH_ENV=false
|
env_get() { $SUDO grep -E "^$1=" "$ENV_FILE" 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
||||||
if [ ! -f .env ]; then
|
# Rewrites the file via a local temp file + `printf` (normal argument
|
||||||
FRESH_ENV=true
|
# passing, never re-interpolated into a nested shell string) so arbitrary
|
||||||
cp .env.example .env
|
# characters in $value — quotes, backslashes, anything a typed SMTP/registry
|
||||||
log_info ".env aus .env.example erstellt."
|
# password might contain — can never break quoting or be misinterpreted.
|
||||||
fi
|
|
||||||
|
|
||||||
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
|
||||||
env_set() {
|
env_set() {
|
||||||
# env_set KEY VALUE — replaces an existing (possibly empty) line, or
|
local key="$1" value="$2" tmp
|
||||||
# appends the key if it's missing entirely. Never touches other keys.
|
tmp="$(mktemp)"
|
||||||
local key="$1" value="$2"
|
if $SUDO test -f "$ENV_FILE"; then
|
||||||
if grep -qE "^$key=" .env; then
|
$SUDO grep -vE "^${key}=" "$ENV_FILE" > "$tmp" 2>/dev/null || true
|
||||||
local tmp; tmp="$(mktemp)"
|
|
||||||
awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
|
|
||||||
else
|
|
||||||
printf '%s=%s\n' "$key" "$value" >> .env
|
|
||||||
fi
|
fi
|
||||||
|
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||||
|
$SUDO cp "$tmp" "$ENV_FILE"
|
||||||
|
rm -f "$tmp"
|
||||||
}
|
}
|
||||||
# Only fills the key in if it is currently missing or blank — this is what
|
# Only fills a key in if currently missing/blank — makes generation safe to
|
||||||
# makes secret generation safe to re-run (existing secrets survive updates).
|
# re-run (existing secrets/config survive every future setup.sh run).
|
||||||
env_ensure() {
|
env_ensure() {
|
||||||
local key="$1" value="$2"
|
local key="$1" value="$2"
|
||||||
local current; current="$(env_get "$key")"
|
[ -z "$(env_get "$key")" ] || return 1
|
||||||
if [ -z "$current" ]; then
|
|
||||||
env_set "$key" "$value"
|
env_set "$key" "$value"
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$FRESH_ENV" = true ]; then
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
log_info "Domain für diese Installation (z. B. https://anouma.org):"
|
log_info "Erstelle neue $ENV_FILE …"
|
||||||
APP_URL="$(ask "Domain" "http://localhost:3000")"
|
$SUDO tee "$ENV_FILE" >/dev/null <<'EOF'
|
||||||
env_set NEXT_PUBLIC_SERVER_URL "$APP_URL"
|
# 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
|
fi
|
||||||
|
|
||||||
|
# --- secrets --------------------------------------------------------------
|
||||||
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
|
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 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."
|
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
|
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
|
||||||
log_info "POSTGRES_PASSWORD generiert."
|
log_info "POSTGRES_PASSWORD generiert."
|
||||||
# Keep DATABASE_URI in sync with the freshly generated password so the
|
|
||||||
# app can actually connect — only rewritten when we just generated the
|
|
||||||
# password ourselves, never when POSTGRES_PASSWORD already existed.
|
|
||||||
PG_USER="$(env_get POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}"
|
PG_USER="$(env_get POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}"
|
||||||
PG_DB="$(env_get POSTGRES_DB)"; PG_DB="${PG_DB:-anouma}"
|
PG_DB="$(env_get POSTGRES_DB)"; PG_DB="${PG_DB:-anouma}"
|
||||||
PG_PASSWORD="$(env_get POSTGRES_PASSWORD)"
|
PG_PASSWORD="$(env_get POSTGRES_PASSWORD)"
|
||||||
@@ -193,62 +331,211 @@ else
|
|||||||
log_info "POSTGRES_PASSWORD bereits vorhanden — unverändert."
|
log_info "POSTGRES_PASSWORD bereits vorhanden — unverändert."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$FRESH_ENV" = true ]; then
|
# --- interactive questions (only for values with no safe automatic value) -
|
||||||
echo ""
|
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")"
|
SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")"
|
||||||
if [ "$SETUP_SMTP" = true ]; then
|
if [ "$SETUP_SMTP" = true ]; then
|
||||||
env_set SMTP_HOST "$(ask "SMTP-Host" "")"
|
env_set SMTP_HOST "$(ask "SMTP Host" "" "SMTP_HOST")"
|
||||||
env_set SMTP_PORT "$(ask "SMTP-Port" "587")"
|
env_set SMTP_PORT "$(ask "SMTP Port" "587" "SMTP_PORT")"
|
||||||
env_set SMTP_USER "$(ask "SMTP-Benutzer" "")"
|
env_set SMTP_USER "$(ask "SMTP Username" "" "SMTP_USER")"
|
||||||
read -r -s -p "SMTP-Passwort: " smtp_pw || true; echo ""
|
env_set SMTP_PASSWORD "$(ask_secret "SMTP Password" "SMTP_PASSWORD")"
|
||||||
env_set SMTP_PASSWORD "$smtp_pw"
|
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 [ "$NPM_DETECTED" = true ]; then
|
|
||||||
USE_NPM="$(ask_yn "Nginx Proxy Manager verwenden (Netzwerk beitreten)?" "Y")"
|
|
||||||
if [ "$USE_NPM" = true ]; then
|
|
||||||
NPM_NETWORK_NAME="$(ask "NPM-Docker-Netzwerk" "$DETECTED_NPM_NETWORK")"
|
|
||||||
env_set NPM_NETWORK "$NPM_NETWORK_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
AUTO_UPDATE_ANSWER="$(ask_yn "Automatische Updates aktivieren?" "N")"
|
|
||||||
env_set AUTO_UPDATE "$AUTO_UPDATE_ANSWER"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chmod 600 .env || true
|
if [ -z "$(env_get NPM_NETWORK)" ] && [ "$NPM_DETECTED" = true ]; then
|
||||||
log_success ".env ist bereit."
|
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)."
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Reverse-proxy network override — generated only when configured/detected,
|
# 9. ENV summary (secrets masked)
|
||||||
# never required. See docker-compose.override.yml.example for the manual
|
|
||||||
# equivalent.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
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)"
|
NPM_NETWORK_VALUE="$(env_get NPM_NETWORK)"
|
||||||
if [ -n "$NPM_NETWORK_VALUE" ]; then
|
if [ -n "$NPM_NETWORK_VALUE" ]; then
|
||||||
if ! echo "$EXISTING_NETWORKS" | grep -qx "$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_warn "NPM_NETWORK=$NPM_NETWORK_VALUE ist in .env gesetzt, aber kein Docker-Netzwerk mit diesem Namen existiert — das Override wird trotzdem geschrieben, `docker compose up` wird aber fehlschlagen, bis das Netzwerk existiert."
|
|
||||||
fi
|
|
||||||
sed "s/\${NPM_NETWORK}/$NPM_NETWORK_VALUE/g" docker-compose.override.yml.example > docker-compose.override.yml
|
|
||||||
log_success "docker-compose.override.yml für Reverse-Proxy-Netzwerk '$NPM_NETWORK_VALUE' erstellt."
|
log_success "docker-compose.override.yml für Reverse-Proxy-Netzwerk '$NPM_NETWORK_VALUE' erstellt."
|
||||||
elif [ -f docker-compose.override.yml ]; then
|
|
||||||
log_info "NPM_NETWORK ist leer, aber docker-compose.override.yml existiert bereits — wird unverändert gelassen (nicht automatisch gelöscht)."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 9–11: build, start database, wait for healthy, migrate
|
# 10. Deploy user
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
step "Deployment-User"
|
||||||
|
|
||||||
# Waits for a compose service's container healthcheck to report "healthy".
|
if id "$DEPLOY_USER" >/dev/null 2>&1; then
|
||||||
# Uses `docker inspect` on the container ID (via `compose ps -q`) rather than
|
log_info "Benutzer '$DEPLOY_USER' existiert bereits — unverändert."
|
||||||
# `compose ps --format`, since Go-template support in `ps --format` varies
|
else
|
||||||
# across Compose versions while `docker inspect` is stable everywhere.
|
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() {
|
wait_healthy() {
|
||||||
local service="$1" timeout_iterations="$2" id status
|
local service="$1" timeout_iterations="$2" id status
|
||||||
for _ in $(seq 1 "$timeout_iterations"); do
|
for _ in $(seq 1 "$timeout_iterations"); do
|
||||||
id="$($COMPOSE ps -q "$service" 2>/dev/null || true)"
|
id="$($COMPOSE --env-file "$ENV_FILE" ps -q "$service" 2>/dev/null || true)"
|
||||||
if [ -n "$id" ]; then
|
if [ -n "$id" ]; then
|
||||||
status="$(docker inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)"
|
status="$($DOCKER inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)"
|
||||||
[ "$status" = "healthy" ] && return 0
|
[ "$status" = "healthy" ] && return 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -256,83 +543,125 @@ wait_healthy() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
log_info "Baue Docker-Images (das kann beim ersten Mal einige Minuten dauern) …"
|
log_info "Starte Postgres …"
|
||||||
$COMPOSE build
|
$COMPOSE --env-file "$ENV_FILE" up -d postgres
|
||||||
|
|
||||||
log_info "Starte Datenbank …"
|
|
||||||
$COMPOSE up -d postgres
|
|
||||||
|
|
||||||
log_info "Warte auf Datenbank-Healthcheck …"
|
|
||||||
DB_READY=false
|
DB_READY=false
|
||||||
wait_healthy postgres 60 && DB_READY=true
|
wait_healthy postgres 60 && DB_READY=true
|
||||||
[ "$DB_READY" = true ] || fail "Datenbank wurde nicht rechtzeitig healthy." "Prüfe die Logs: $COMPOSE logs postgres"
|
if [ "$DB_READY" = true ]; then
|
||||||
log_success "Datenbank ist bereit."
|
log_success "Datenbank ist bereit."
|
||||||
|
else
|
||||||
log_info "Führe Datenbank-Migrationen aus …"
|
log_warn "Datenbank wurde nicht rechtzeitig healthy — prüfe: $COMPOSE logs postgres"
|
||||||
$COMPOSE run --rm app npm run migrate || fail "Migration fehlgeschlagen." "Prüfe die Ausgabe oben. Die Datenbank wurde nicht verändert, wenn die Migration atomar fehlgeschlagen ist."
|
|
||||||
log_success "Migrationen angewendet."
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 13: seed
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
if [ "$SKIP_SEED" = false ]; then
|
|
||||||
DO_SEED="$(ask_yn "Vorhandene ANOUMA-Inhalte (Angebote, Seitentexte) jetzt einspielen?" "Y")"
|
|
||||||
if [ "$DO_SEED" = true ]; then
|
|
||||||
log_info "Spiele Seed-Daten ein …"
|
|
||||||
$COMPOSE run --rm app npm run seed || log_warn "Seed ist fehlgeschlagen — die Installation läuft trotzdem weiter, Inhalte können später im Admin-Bereich gepflegt werden."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
EXISTING_IMAGE="$(env_get ANOUMA_IMAGE)"
|
||||||
# 14–15: start app, healthcheck
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
log_info "Starte Anwendung …"
|
|
||||||
$COMPOSE up -d app
|
|
||||||
|
|
||||||
log_info "Warte auf Healthcheck der Anwendung …"
|
|
||||||
APP_READY=false
|
APP_READY=false
|
||||||
wait_healthy app 60 && APP_READY=true
|
if [ -n "$EXISTING_IMAGE" ]; then
|
||||||
|
log_info "ANOUMA_IMAGE ist bereits gesetzt ($EXISTING_IMAGE) — starte Anwendung …"
|
||||||
# ---------------------------------------------------------------------------
|
if [ -n "$(env_get REGISTRY_USERNAME)" ]; then
|
||||||
# AUTO_UPDATE cron
|
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 [ "$(env_get AUTO_UPDATE)" = "true" ] && command -v crontab >/dev/null 2>&1; then
|
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
|
||||||
CRON_MARKER="# anouma-auto-update"
|
wait_healthy app 60 && APP_READY=true
|
||||||
if ! (crontab -l 2>/dev/null | grep -qF "$CRON_MARKER"); then
|
fi
|
||||||
( crontab -l 2>/dev/null; echo "*/30 * * * * cd $(pwd) && ./update.sh --auto >> $(pwd)/update.log 2>&1 $CRON_MARKER" ) | crontab -
|
if [ "$APP_READY" = false ]; then
|
||||||
log_success "Cronjob für automatische Updates eingerichtet (alle 30 Minuten)."
|
log_warn "Anwendung konnte nicht (neu) gestartet werden — prüfe: $COMPOSE logs app"
|
||||||
else
|
fi
|
||||||
log_info "Cronjob für automatische Updates ist bereits eingerichtet."
|
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
|
||||||
fi
|
fi
|
||||||
|
cd "$REPO_DIR"
|
||||||
echo "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" > .installed-version 2>/dev/null || true
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 16: result
|
# 31. Summary
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo ""
|
echo ""
|
||||||
echo "ANOUMA installation complete"
|
echo "========================================"
|
||||||
echo ""
|
echo "ANOUMA SETUP COMPLETE"
|
||||||
echo "Version:"
|
echo "========================================"
|
||||||
echo " $(cat .installed-version 2>/dev/null || echo unknown)"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Application:"
|
echo "Application:"
|
||||||
echo " $(env_get NEXT_PUBLIC_SERVER_URL)"
|
echo " $(env_get NEXT_PUBLIC_SERVER_URL)"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "Project:"
|
||||||
|
echo " maro/anouma"
|
||||||
|
echo ""
|
||||||
echo "Docker:"
|
echo "Docker:"
|
||||||
echo " ✓ Running"
|
echo " ✓"
|
||||||
|
echo ""
|
||||||
|
echo "Docker Compose:"
|
||||||
|
echo " ✓"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Database:"
|
echo "Database:"
|
||||||
if [ "$DB_READY" = true ]; then echo " ✓ Healthy"; else echo " ✗ Not healthy — check: $COMPOSE logs postgres"; fi
|
if [ "$DB_READY" = true ]; then echo " ✓"; else echo " ✗ (siehe oben)"; fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Application healthcheck:"
|
echo "Docker Network:"
|
||||||
if [ "$APP_READY" = true ]; then echo " ✓ Running"; else echo " ✗ Not healthy yet — check: $COMPOSE logs app"; fi
|
echo " ✓ ($(env_get DOCKER_NETWORK))"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Nginx Proxy Manager:"
|
echo "Nginx Proxy Manager:"
|
||||||
if [ "$NPM_DETECTED" = true ]; then echo " ✓ Detected ($DETECTED_NPM_NETWORK)"; else echo " - Not detected"; fi
|
if [ "$NPM_DETECTED" = true ]; then echo " ✓ ($DETECTED_NPM_NETWORK)"; else echo " not detected"; fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Production User:"
|
||||||
echo " 1. Visit ${NPM_DETECTED:+your configured domain, or }http://localhost:${APP_PORT:-3000}/admin to create the first admin account."
|
echo " $DEPLOY_USER"
|
||||||
echo " 2. See DEPLOYMENT.md for reverse-proxy setup, backups, updates and troubleshooting."
|
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 ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user