From 87bee9b72afc8edbab42f608c256c75ddded65f3 Mon Sep 17 00:00:00 2001 From: moneyfy Date: Wed, 9 Sep 2026 17:14:44 +0200 Subject: [PATCH] build: Docker-Images, Compose-Setup und Gitea-Workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mehrstufige Dockerfiles je Dienst mit Stufen für Entwicklung und Betrieb, beide Images laufen als unprivilegierter Benutzer - Backend-Entrypoint wartet auf die Datenbank, migriert und seedt nur bei leerem Kategoriebaum (neues Flag --if-empty) - docker-compose.yml mit Netz-Trennung, Healthchecks und benannten Volumes; die Datenbank ist ausschließlich im internen Netz erreichbar - nginx liefert das Bundle aus und reicht /api weiter; index.html ungecacht, gehashte Assets ein Jahr - Gitea-Workflows: ci.yml für Lint, Tests und Bundle-Bau, build.yml für die Images nach linux/amd64 ohne Deploy-Schritt - docs/runner.md und docs/deployment.md, .dockerignore je Dienst Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH --- .env.example | 10 +- .gitea/workflows/README.md | 36 ++++ .gitea/workflows/build.yml | 82 +++++++++ .gitea/workflows/ci.yml | 89 +++++++++ CHANGELOG.md | 15 ++ README.md | 45 +++++ backend/.dockerignore | 11 ++ backend/Dockerfile | 79 ++++++++ backend/app/scripts/seed.py | 28 ++- backend/docker/entrypoint.sh | 49 +++++ docker-compose.override.yml.example | 17 +- docker-compose.yml | 115 ++++++++++++ docs/deployment.md | 212 ++++++++++++++++++++++ docs/runner.md | 270 ++++++++++++++++++++++++++++ frontend/.dockerignore | 7 + frontend/Dockerfile | 47 +++++ frontend/docker/nginx.conf | 82 +++++++++ 17 files changed, 1184 insertions(+), 10 deletions(-) create mode 100644 .gitea/workflows/README.md create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitea/workflows/ci.yml create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100755 backend/docker/entrypoint.sh create mode 100644 docker-compose.yml create mode 100644 docs/deployment.md create mode 100644 docs/runner.md create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/docker/nginx.conf diff --git a/.env.example b/.env.example index a0d0155..ea8ffd0 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,9 @@ TAG=latest POSTGRES_DB=moneyfy POSTGRES_USER=moneyfy POSTGRES_PASSWORD=bitte-aendern -# Vom Backend genutzte DSN. `postgresql://` wird automatisch auf asyncpg umgestellt. +# Wird beim Betrieb mit Compose aus den drei Werten darüber zusammengesetzt und +# muss dort nicht gesetzt werden. Nur für den Betrieb ohne Compose nötig. +# `postgresql://` wird automatisch auf asyncpg umgestellt. DATABASE_URL=postgresql+asyncpg://moneyfy:bitte-aendern@moneyfy-db:5432/moneyfy # --- Sicherheit -------------------------------------------------------------- @@ -42,9 +44,9 @@ OIDC_CLIENT_SECRET= OIDC_SCOPES=openid profile email # --- Logo-Service ------------------------------------------------------------ -# Verzeichnis für den lokalen Logo-Cache (im Container auf das Volume gemountet). -# Logos werden ausschließlich von hier ausgeliefert – im Seitenaufruf entsteht -# kein Zugriff auf fremde Dienste. +# Verzeichnis für den lokalen Logo-Cache. Beim Betrieb mit Compose zeigt es fest +# auf das Volume und muss hier nicht gesetzt werden. Logos werden ausschließlich +# von dort ausgeliefert – im Seitenaufruf entsteht kein Zugriff nach außen. LOGO_STORAGE_DIR=/data/logos # Der lokale simple-icons-Katalog deckt die meisten Marken ohne Netzzugriff ab. diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..9f400e5 --- /dev/null +++ b/.gitea/workflows/README.md @@ -0,0 +1,36 @@ +# Gitea Actions + +Zwei Workflows: + +| Datei | Auslöser | Was passiert | +|---|---|---| +| `ci.yml` | jeder Push, jeder Pull Request | Backend: `ruff check`, `ruff format --check`, `pytest` gegen einen PostgreSQL-Dienst. Frontend: `npm ci`, `tsc --noEmit`, `eslint`, `vitest`, Bundle-Bau. | +| `build.yml` | Push auf `main`, Tags `v*` | Baut beide Images für `linux/amd64` und lädt sie in die Gitea-Registry. **Kein Deploy** – das Ausrollen erfolgt von Hand. | + +## Benötigte Repo-Secrets + +| Name | Zweck | Woher | +|---|---|---| +| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token** mit dem Recht `write:package` | + +Anlegen unter *Repository → Settings → Actions → Secrets → Add Secret*. + +Als Benutzername verwendet der Workflow `${{ github.actor }}`, also den Auslöser +des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums +`menzeljonas`. + +## Erzeugte Tags + +| Auslöser | Tags | +|---|---| +| Push auf `main` | `:main`, `:sha-` | +| Tag `v1.2.3` | `:1.2.3`, `:1.2`, `:latest` | + +Zusätzlich legt jeder Lauf ein `:buildcache`-Manifest ab. Es dient +ausschließlich dem Schichten-Cache und ist nicht zum Ausrollen gedacht. + +## Voraussetzungen an den Runner + +Der Runner muss Docker erreichen können (gemounteter Socket) und das Label +`ubuntu-latest` anbieten. Die vollständige Einrichtung steht in +[`../../docs/runner.md`](../../docs/runner.md). diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..605d097 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,82 @@ +name: Images bauen + +# Bei Push auf main und bei Versions-Tags. Ausgerollt wird nicht automatisch – +# das Aktualisieren auf docker-srv001 passiert von Hand, siehe docs/deployment.md. +on: + push: + branches: + - main + tags: + - "v*" + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: false + +env: + REGISTRY: git.menzel.center + NAMESPACE: menzeljonas + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + component: + - backend + - frontend + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: An der Registry anmelden + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Tags und Beschriftungen ermitteln + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }} + # `latest` wird nur für Versions-Tags gesetzt, nicht für jeden main-Push. + flavor: | + latest=false + tags: | + type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }} + type=sha,prefix=sha-,format=short,enable=${{ github.ref == 'refs/heads/main' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + labels: | + org.opencontainers.image.title=moneyfy-${{ matrix.component }} + org.opencontainers.image.description=moneyfy – Planung monatlicher Kosten und Einkünfte + org.opencontainers.image.licenses=MIT + + - name: Bauen und hochladen + uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.component }} + target: production + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # Registry-Cache statt type=gha: der Gitea-Runner bringt keinen + # Actions-Cache-Dienst mit. + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max + provenance: false + + - name: Ergebnis zusammenfassen + run: | + echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..cccb309 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +# Läuft bei jedem Push und jedem Pull Request, unabhängig vom Branch. +on: + push: + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: moneyfy_test + POSTGRES_USER: moneyfy + POSTGRES_PASSWORD: moneyfy + options: >- + --health-cmd "pg_isready -U moneyfy -d moneyfy_test" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + # Die Tests lesen DATABASE_URL; der Dienst ist unter seinem Namen erreichbar. + DATABASE_URL: postgresql+asyncpg://moneyfy:moneyfy@postgres:5432/moneyfy_test + SECRET_KEY: ci-secret-key-mindestens-32-zeichen-lang + ENVIRONMENT: test + + steps: + - uses: actions/checkout@v4 + + - name: uv installieren + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Abhängigkeiten installieren + # uv lädt bei Bedarf auch den passenden Python-Interpreter. + run: | + uv venv --python 3.12 .venv + uv pip install -e ".[dev]" + + - name: ruff check + run: .venv/bin/ruff check . + + - name: ruff format + run: .venv/bin/ruff format --check . + + - name: pytest + run: .venv/bin/python -m pytest -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Abhängigkeiten installieren + run: npm ci --no-audit --no-fund + + - name: Typen prüfen + run: npm run typecheck + + - name: eslint + run: npm run lint + + - name: vitest + run: npm run test + + - name: Bundle bauen + run: npx vite build diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e22b1..3738b86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,21 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). - Benachrichtigungen sind in den Einstellungen pflegbar, samt Einrichtungsstand der Kanäle und Versandprotokoll. +- Mehrstufige Dockerfiles für Backend und Frontend, beide mit einer Stufe für + die Entwicklung und einer für den Betrieb; beide Images laufen unprivilegiert. +- Entrypoint des Backends wartet auf die Datenbank, spielt die Migrationen ein + und legt die Stammdaten nur beim leeren Kategoriebaum an. +- `docker-compose.yml` mit Netz-Trennung (die Datenbank ist ausschließlich + intern erreichbar), Healthchecks und benannten Volumes für Datenbank und + Logo-Cache. +- nginx liefert das Bundle aus und reicht `/api` weiter; `index.html` bleibt + ungecacht, die gehashten Assets ein Jahr. +- Gitea-Workflows `ci.yml` (Lint, Tests, Bundle-Bau) und `build.yml` (Images für + linux/amd64 in die Gitea-Registry, ohne Deploy-Schritt). +- `docs/runner.md` zur Einrichtung des act_runners samt Fehlersuche und + `docs/deployment.md` zu NPMplus, Aktualisierung und Sicherung. +- `python -m app.scripts.seed --if-empty` seedt nur bei leerem Kategoriebaum. + ### Geändert - `SECRET_KEY` muss mindestens 32 Zeichen lang sein (Vorgabe von HS256); in diff --git a/README.md b/README.md index 7339139..262e280 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,28 @@ _Platzhalter – werden ergänzt, sobald die Oberfläche steht._ | Charts | Recharts | | Tests | pytest + httpx, vitest + testing-library | +## Schnellstart (Betrieb) + +Voraussetzungen: Docker mit Compose-Plugin und ein externes Netz `proxy`. + +```bash +mkdir -p /opt/moneyfy && cd /opt/moneyfy +curl -fsSLO https://git.menzel.center/menzeljonas/moneyfy/raw/branch/main/docker-compose.yml +curl -fsSLO https://git.menzel.center/menzeljonas/moneyfy/raw/branch/main/.env.example +cp .env.example .env + +# Mindestens setzen: POSTGRES_PASSWORD, SECRET_KEY, MONEYFY_ADMIN_PASSWORD +$EDITOR .env + +docker login git.menzel.center +docker compose up -d +``` + +Das Backend wartet auf die Datenbank, spielt die Migrationen ein und legt beim +allerersten Start den Kategoriebaum an. Danach läuft die Oberfläche auf +`http://127.0.0.1:8087`. Die vollständige Einrichtung samt Reverse Proxy und +Sicherung steht in [docs/deployment.md](docs/deployment.md). + ## Schnellstart (lokale Entwicklung) Voraussetzungen: Python 3.12, [uv](https://docs.astral.sh/uv/), Node 20+, @@ -146,6 +168,29 @@ docs/ Betriebs- und Runner-Dokumentation .gitea/ Gitea-Actions-Workflows ``` +Alternativ lässt sich alles aus dem Quellcode bauen: + +```bash +cp docker-compose.override.yml.example docker-compose.override.yml +docker compose up -d --build +``` + +Die Override-Datei baut beide Images lokal, bindet den Quellcode ein und öffnet +die Ports für Backend (8000) und Vite (5173). + +## Bauen und Ausrollen + +Zwei Gitea-Workflows unter `.gitea/workflows/`: + +- **`ci.yml`** läuft bei jedem Push und Pull Request: `ruff`, `pytest`, + `tsc --noEmit`, `eslint`, `vitest` und der Bundle-Bau. +- **`build.yml`** läuft auf `main` und bei Tags `v*` und lädt beide Images für + `linux/amd64` in die Gitea-Registry. Push auf `main` erzeugt `:main` und + `:sha-`, ein Tag `v1.2.3` zusätzlich `:1.2.3`, `:1.2` und `:latest`. + +Ausgerollt wird **nicht** automatisch. Die Einrichtung des Runners beschreibt +[docs/runner.md](docs/runner.md), das Ausrollen [docs/deployment.md](docs/deployment.md). + ## Lizenz [MIT](LICENSE) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..83191e8 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +.env +.data/ + +# Tests und Skripte bleiben verfügbar: das Entwicklungs-Image kopiert den ganzen +# Baum, das Produktions-Image nur app/, alembic/ und den Entrypoint. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..476b96e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,79 @@ +# syntax=docker/dockerfile:1.7 +# +# Backend-Image für moneyfy. Mehrstufig, damit im Endbild weder Build-Werkzeuge +# noch Entwicklungsabhängigkeiten landen. + +# --- Abhängigkeiten ----------------------------------------------------------- +FROM python:3.12-slim-bookworm AS deps + +# uv installiert die Abhängigkeiten deutlich schneller als pip. +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /bin/uv + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +# Nur die Abhängigkeitsdeklaration kopieren – die Schicht bleibt gültig, +# solange sich pyproject.toml nicht ändert. +COPY pyproject.toml ./ +RUN /bin/uv pip install --system --no-cache -r pyproject.toml + +# --- Laufzeitbasis ------------------------------------------------------------ +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + TZ=Europe/Berlin \ + LOGO_STORAGE_DIR=/data/logos + +# tzdata wird für Europe/Berlin gebraucht, curl für den Healthcheck. +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 moneyfy \ + && useradd --system --uid 10001 --gid moneyfy --create-home --home-dir /home/moneyfy moneyfy + +COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=deps /usr/local/bin /usr/local/bin + +WORKDIR /app + +# --- Entwicklung -------------------------------------------------------------- +# Wird von docker-compose.override.yml genutzt; der Quellcode kommt per Volume. +FROM runtime AS development + +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv +COPY pyproject.toml ./ +RUN uv pip install --system --no-cache -r pyproject.toml --extra dev + +COPY --chown=moneyfy:moneyfy . . +RUN mkdir -p /data/logos && chown -R moneyfy:moneyfy /data + +USER moneyfy +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + +# --- Produktion --------------------------------------------------------------- +FROM runtime AS production + +COPY --chown=moneyfy:moneyfy alembic.ini pyproject.toml ./ +COPY --chown=moneyfy:moneyfy alembic ./alembic +COPY --chown=moneyfy:moneyfy app ./app +COPY --chown=moneyfy:moneyfy docker/entrypoint.sh /usr/local/bin/entrypoint.sh + +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && mkdir -p /data/logos \ + && chown -R moneyfy:moneyfy /data + +USER moneyfy +EXPOSE 8000 +VOLUME ["/data"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/api/health || exit 1 + +ENTRYPOINT ["entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/backend/app/scripts/seed.py b/backend/app/scripts/seed.py index affdd31..f4f169e 100644 --- a/backend/app/scripts/seed.py +++ b/backend/app/scripts/seed.py @@ -1,14 +1,28 @@ -"""CLI: Stammdaten seeden – `python -m app.scripts.seed`.""" +"""CLI: Stammdaten seeden – `python -m app.scripts.seed [--if-empty]`.""" +import argparse import asyncio +from sqlalchemy import func, select + from app.db.session import SessionLocal, engine +from app.models import Category from app.services.seed import seed_all -async def main() -> None: +async def main(only_if_empty: bool) -> None: async with SessionLocal() as session: + if only_if_empty: + vorhanden = ( + await session.execute(select(func.count()).select_from(Category)) + ).scalar_one() + if vorhanden: + print(f"Seed übersprungen: es gibt bereits {vorhanden} Kategorien.") + await engine.dispose() + return + result = await seed_all(session) + await engine.dispose() print( f"Seed fertig: {result['categories']} Kategorien, " @@ -17,4 +31,12 @@ async def main() -> None: if __name__ == "__main__": - asyncio.run(main()) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--if-empty", + action="store_true", + help="Nur seeden, wenn noch keine Kategorie existiert. Für den Containerstart gedacht, " + "damit bewusst gelöschte Kategorien nicht wiederkehren.", + ) + args = parser.parse_args() + asyncio.run(main(args.if_empty)) diff --git a/backend/docker/entrypoint.sh b/backend/docker/entrypoint.sh new file mode 100755 index 0000000..f47577e --- /dev/null +++ b/backend/docker/entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Startskript des Backend-Containers. +# +# Wartet auf die Datenbank, bringt das Schema auf den aktuellen Stand und legt +# beim allerersten Start die Stammdaten an. Erst danach startet die Anwendung. +set -eu + +WARTEZEIT_SEKUNDEN="${DB_WAIT_SECONDS:-60}" + +warte_auf_datenbank() { + i=0 + while [ "$i" -lt "$WARTEZEIT_SEKUNDEN" ]; do + if python -c " +import asyncio, sys +from sqlalchemy import text +from app.db.session import engine + +async def pruefen(): + async with engine.connect() as verbindung: + await verbindung.execute(text('SELECT 1')) + await engine.dispose() + +try: + asyncio.run(pruefen()) +except Exception: + sys.exit(1) +" 2>/dev/null; then + return 0 + fi + i=$((i + 1)) + [ "$i" -eq 1 ] && echo "Warte auf die Datenbank …" + sleep 1 + done + + echo "Die Datenbank war nach ${WARTEZEIT_SEKUNDEN} Sekunden nicht erreichbar." >&2 + return 1 +} + +warte_auf_datenbank + +echo "Migrationen werden angewendet …" +alembic upgrade head + +# Idempotent und nur beim leeren Kategoriebaum – bewusst gelöschte Kategorien +# kehren dadurch nicht bei jedem Neustart zurück. +python -m app.scripts.seed --if-empty + +echo "moneyfy-Backend startet." +exec "$@" diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 5195ad6..af37b60 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -1,8 +1,11 @@ -# Lokale Entwicklung: Hot Reload und offene Ports. +# Lokale Entwicklung: aus dem Quellcode bauen, Hot Reload, offene Ports. # Kopieren nach `docker-compose.override.yml` – die Datei wird nicht eingecheckt. +# +# Compose liest sie automatisch zusätzlich zu docker-compose.yml. + services: moneyfy-db: - # Datenbank direkt erreichbar, z. B. für psql oder einen SQL-Client. + # Datenbank direkt erreichbar, etwa für psql oder einen SQL-Client. ports: - "127.0.0.1:5432:5432" @@ -16,7 +19,9 @@ services: ENVIRONMENT: development DEBUG: "true" COOKIE_SECURE: "false" + # Im Entwicklungsbetrieb sollen keine Mails hinausgehen. SCHEDULER_ENABLED: "false" + # Der Vite-Server läuft auf einem anderen Port und braucht daher CORS. CORS_ORIGINS: http://localhost:5173 volumes: # Quellcode einblenden, damit --reload greift. @@ -32,10 +37,16 @@ services: image: moneyfy-frontend:dev command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] environment: - VITE_API_BASE_URL: http://localhost:8000 + # Vite reicht /api an das Backend weiter, siehe vite.config.ts. + VITE_API_PROXY: http://moneyfy-backend:8000 volumes: - ./frontend/src:/app/src - ./frontend/public:/app/public - ./frontend/index.html:/app/index.html:ro + - ./frontend/vite.config.ts:/app/vite.config.ts:ro + - ./frontend/tailwind.config.js:/app/tailwind.config.js:ro ports: - "127.0.0.1:5173:5173" + # Der Entwicklungsserver kennt /healthz nicht. + healthcheck: + disable: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e3fb49d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,115 @@ +# Betrieb von moneyfy mit Docker Compose. +# +# Vor dem ersten Start: `cp .env.example .env` und die Datei ausfüllen. +# Anschließend genügt `docker compose up -d`; das Backend legt beim Start das +# Schema an und seedt die Stammdaten. + +name: moneyfy + +services: + moneyfy-db: + image: postgres:17-alpine + container_name: moneyfy-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-moneyfy} + POSTGRES_USER: ${POSTGRES_USER:-moneyfy} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss in .env gesetzt sein} + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + TZ: ${TIMEZONE:-Europe/Berlin} + volumes: + - moneyfy-pgdata:/var/lib/postgresql/data + # Die Datenbank ist ausschließlich im internen Netz erreichbar. + networks: + - moneyfy + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-moneyfy} -d ${POSTGRES_DB:-moneyfy}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + moneyfy-backend: + image: git.menzel.center/menzeljonas/moneyfy-backend:${TAG:-latest} + container_name: moneyfy-backend + restart: unless-stopped + depends_on: + moneyfy-db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-moneyfy}:${POSTGRES_PASSWORD}@moneyfy-db:5432/${POSTGRES_DB:-moneyfy} + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY muss in .env gesetzt sein, mindestens 32 Zeichen} + ENVIRONMENT: ${ENVIRONMENT:-production} + DEBUG: ${DEBUG:-false} + TIMEZONE: ${TIMEZONE:-Europe/Berlin} + HOLIDAY_REGION: ${HOLIDAY_REGION:-DE-NW} + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8087} + + MONEYFY_ADMIN_USER: ${MONEYFY_ADMIN_USER:-admin} + MONEYFY_ADMIN_PASSWORD: ${MONEYFY_ADMIN_PASSWORD:-} + ACCESS_TOKEN_TTL_MINUTES: ${ACCESS_TOKEN_TTL_MINUTES:-30} + REFRESH_TOKEN_TTL_DAYS: ${REFRESH_TOKEN_TTL_DAYS:-14} + COOKIE_SECURE: ${COOKIE_SECURE:-true} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + + LOGO_STORAGE_DIR: /data/logos + LOGODEV_API_KEY: ${LOGODEV_API_KEY:-} + BRANDFETCH_API_KEY: ${BRANDFETCH_API_KEY:-} + LOGO_AUTO_RESOLVE: ${LOGO_AUTO_RESOLVE:-true} + + NOTIFICATIONS_ENABLED: ${NOTIFICATIONS_ENABLED:-true} + SCHEDULER_ENABLED: ${SCHEDULER_ENABLED:-true} + NOTIFICATION_HOUR: ${NOTIFICATION_HOUR:-7} + NOTIFICATION_MINUTE: ${NOTIFICATION_MINUTE:-0} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} + SMTP_USE_TLS: ${SMTP_USE_TLS:-true} + SMTP_USE_SSL: ${SMTP_USE_SSL:-false} + APPRISE_URLS: ${APPRISE_URLS:-} + volumes: + # Logo-Cache; die Dateien überleben ein Update des Images. + - moneyfy-data:/data + networks: + - moneyfy + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + + moneyfy-frontend: + image: git.menzel.center/menzeljonas/moneyfy-frontend:${TAG:-latest} + container_name: moneyfy-frontend + restart: unless-stopped + depends_on: + moneyfy-backend: + condition: service_healthy + ports: + # Nur lokal gebunden – von außen kommt man ausschließlich über NPMplus. + - "127.0.0.1:8087:8080" + networks: + # `moneyfy` für den Weg zum Backend, `proxy` für NPMplus. + - moneyfy + - proxy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +networks: + moneyfy: + driver: bridge + proxy: + external: true + +volumes: + moneyfy-pgdata: + name: moneyfy-pgdata + moneyfy-data: + name: moneyfy-data diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..d249abc --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,212 @@ +# Betrieb auf docker-srv001 + +## Erstinstallation + +```bash +mkdir -p /opt/moneyfy && cd /opt/moneyfy + +# docker-compose.yml und .env.example aus dem Repository holen +curl -fsSLO https://git.menzel.center/menzeljonas/moneyfy/raw/branch/main/docker-compose.yml +curl -fsSLO https://git.menzel.center/menzeljonas/moneyfy/raw/branch/main/.env.example +cp .env.example .env +``` + +In `.env` mindestens setzen: + +```bash +POSTGRES_PASSWORD=$(openssl rand -hex 24) +SECRET_KEY=$(openssl rand -hex 32) +MONEYFY_ADMIN_PASSWORD= +PUBLIC_BASE_URL=https://moneyfy.menzel.center +TAG=latest +``` + +Das externe Proxy-Netz muss existieren – NPMplus bringt es üblicherweise mit: + +```bash +docker network inspect proxy >/dev/null 2>&1 || docker network create proxy +``` + +An der Registry anmelden und starten: + +```bash +docker login git.menzel.center +docker compose up -d +docker compose logs -f moneyfy-backend +``` + +Das Backend wartet auf die Datenbank, spielt die Migrationen ein und legt beim +allerersten Start den Kategoriebaum an. Danach ist die Oberfläche unter +`http://127.0.0.1:8087` erreichbar. + +Beim ersten Anmelden gelten `MONEYFY_ADMIN_USER` und `MONEYFY_ADMIN_PASSWORD`; +die Anwendung verlangt sofort ein neues Passwort. Danach kann +`MONEYFY_ADMIN_PASSWORD` aus der `.env` entfernt werden. + +## NPMplus konfigurieren + +`moneyfy.menzel.center` als **Proxy Host** anlegen: + +| Feld | Wert | +|---|---| +| Domain Names | `moneyfy.menzel.center` | +| Scheme | `http` | +| Forward Hostname / IP | `moneyfy-frontend` | +| Forward Port | `8080` | +| Cache Assets | aus – der Container setzt die Cache-Kopfzeilen selbst | +| Block Common Exploits | an | +| Websockets Support | aus – die Anwendung braucht keine | + +Unter *SSL*: Zertifikat über Let's Encrypt anfordern, **Force SSL** und **HTTP/2** +einschalten, **HSTS** aktivieren. + +Damit NPMplus den Container über seinen Namen erreicht, müssen beide im Netz +`proxy` liegen. Das erledigt `docker-compose.yml` bereits; prüfen mit: + +```bash +docker network inspect proxy --format '{{range .Containers}}{{.Name}} {{end}}' +``` + +Unter *Advanced* diese Zusatzkonfiguration eintragen. Sie sorgt dafür, dass die +Anwendung das richtige Schema sieht und Exporte nicht in einen Timeout laufen: + +```nginx +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +client_max_body_size 4m; +proxy_read_timeout 120s; +``` + +> Die Anmeldung setzt `Secure`-Cookies. Ohne HTTPS – also ohne Zugriff über +> NPMplus – kommt keine Sitzung zustande. Für einen Test über +> `http://127.0.0.1:8087` muss `COOKIE_SECURE=false` gesetzt werden. + +## Aktualisieren + +Das Ausrollen passiert bewusst von Hand; die CI baut nur die Images. + +```bash +cd /opt/moneyfy + +# Bei fester Version: TAG in .env auf die neue Version setzen. +docker compose pull +docker compose up -d +docker compose logs -f moneyfy-backend +``` + +Die Migrationen laufen beim Start des Backends automatisch. Ein Blick in die +Ausgabe zeigt, welche Revision eingespielt wurde. + +Zurückrollen: `TAG` auf die vorherige Version setzen und erneut +`docker compose up -d`. **Achtung:** Migrationen werden dabei nicht rückgängig +gemacht. Vor einem Update mit Schemaänderung immer erst sichern. + +## Sicherung + +Zwei Dinge müssen gesichert werden: die Datenbank und das Volume mit den Logos. + +### Datenbank + +```bash +docker compose exec -T moneyfy-db \ + pg_dump -U moneyfy -d moneyfy --format=custom \ + > /var/backups/moneyfy/db-$(date +%F).dump +``` + +Wiederherstellen: + +```bash +docker compose stop moneyfy-backend +docker compose exec -T moneyfy-db \ + pg_restore -U moneyfy -d moneyfy --clean --if-exists < /var/backups/moneyfy/db-2026-03-01.dump +docker compose start moneyfy-backend +``` + +### Logo-Volume + +```bash +docker run --rm \ + -v moneyfy-data:/data:ro \ + -v /var/backups/moneyfy:/backup \ + alpine tar czf /backup/logos-$(date +%F).tar.gz -C /data . +``` + +Wiederherstellen: + +```bash +docker run --rm \ + -v moneyfy-data:/data \ + -v /var/backups/moneyfy:/backup \ + alpine sh -c "rm -rf /data/* && tar xzf /backup/logos-2026-03-01.tar.gz -C /data" +``` + +Die Logos lassen sich zur Not auch neu beschaffen – die Datenbank nicht. + +### Tägliche Sicherung per Cron + +`/opt/moneyfy/backup.sh`: + +```bash +#!/bin/sh +set -eu + +ZIEL=/var/backups/moneyfy +TAG=$(date +%F) +mkdir -p "$ZIEL" + +cd /opt/moneyfy +docker compose exec -T moneyfy-db \ + pg_dump -U moneyfy -d moneyfy --format=custom > "$ZIEL/db-$TAG.dump" + +docker run --rm \ + -v moneyfy-data:/data:ro \ + -v "$ZIEL:/backup" \ + alpine tar czf "/backup/logos-$TAG.tar.gz" -C /data . + +# Älteres als 30 Tage entfernen. +find "$ZIEL" -name 'db-*.dump' -mtime +30 -delete +find "$ZIEL" -name 'logos-*.tar.gz' -mtime +30 -delete +``` + +```cron +30 3 * * * /opt/moneyfy/backup.sh >> /var/log/moneyfy-backup.log 2>&1 +``` + +Die Sicherungen anschließend auf ein anderes System spiegeln – eine Kopie auf +demselben Host hilft bei einem Plattenausfall nicht. + +## Fehlersuche + +**Backend startet nicht, Log endet bei „Warte auf die Datenbank“** +Die Datenbank ist nicht gesund. `docker compose ps` und +`docker compose logs moneyfy-db` prüfen; meist stimmt `POSTGRES_PASSWORD` in der +`.env` nicht mit dem im Volume hinterlegten Passwort überein. Ein bereits +angelegtes Volume übernimmt kein neues Passwort. + +**`SECRET_KEY ist nicht gesetzt`** +Die Anwendung verweigert in Produktion den Start mit dem Platzhalterwert. +`openssl rand -hex 32` erzeugt einen passenden Schlüssel. Wird er später +geändert, sind alle Sitzungen ungültig – das ist beabsichtigt. + +**Anmeldung schlägt fehl, obwohl das Passwort stimmt** +Meist fehlt HTTPS: Die Cookies sind `Secure` gesetzt und erreichen den Server +über eine reine HTTP-Verbindung nicht. Entweder über NPMplus zugreifen oder +`COOKIE_SECURE=false` setzen. + +**Firmen bekommen kein Logo** +Ohne Netzzugang findet nur der lokale Markenkatalog etwas; unbekannte Firmen +erhalten einen erzeugten Avatar. Das ist so gewollt. Mit +`docker compose exec moneyfy-backend python -c "from app.services.logos import simple_icon_index; print(simple_icon_index().version)"` +lässt sich prüfen, ob der Katalog im Image liegt. + +**Keine Benachrichtigungen** +`SCHEDULER_ENABLED` und `NOTIFICATIONS_ENABLED` prüfen, dann in der Oberfläche +unter *Einstellungen → Benachrichtigungen* den Einrichtungsstand ansehen und +eine Testnachricht schicken. Das Versandprotokoll steht auf derselben Seite. + +**Speicherplatz** +```bash +docker system df +docker compose exec moneyfy-db psql -U moneyfy -d moneyfy -c "\l+" +docker run --rm -v moneyfy-data:/data:ro alpine du -sh /data +``` diff --git a/docs/runner.md b/docs/runner.md new file mode 100644 index 0000000..302b39a --- /dev/null +++ b/docs/runner.md @@ -0,0 +1,270 @@ +# Gitea-Actions-Runner einrichten + +Die Workflows unter `.gitea/workflows/` brauchen einen `act_runner`, der Docker +erreichen kann. Diese Anleitung richtet ihn als Container auf `docker-srv001` +ein. Sie ist vollständig – von der Tokenbeschaffung bis zur Fehlersuche. + +## 1. Registrierungstoken holen + +Es gibt zwei Möglichkeiten: + +**Nur für dieses Repository** (empfohlen, wenn der Runner ausschließlich moneyfy +bauen soll): + +1. In Gitea das Repository `menzeljonas/moneyfy` öffnen. +2. *Settings → Actions → Runners* aufrufen. +3. **Create new Runner** anklicken. Gitea zeigt das Registrierungstoken an – eine + Zeichenkette aus etwa 40 Zeichen. +4. Token kopieren. Es lässt sich nur einmal verwenden. + +**Instanzweit** (der Runner steht dann allen Repositories zur Verfügung): + +1. Als Administrator anmelden. +2. *Site Administration → Actions → Runners* aufrufen. +3. Ebenfalls **Create new Runner**, Token kopieren. + +Falls Actions in der Instanz noch abgeschaltet sind, in der `app.ini` des +Gitea-Servers ergänzen und Gitea neu starten: + +```ini +[actions] +ENABLED = true +``` + +## 2. Runner als Container betreiben + +Auf `docker-srv001` ein eigenes Verzeichnis anlegen: + +```bash +mkdir -p /opt/gitea-runner/data +cd /opt/gitea-runner +``` + +`docker-compose.yml`: + +```yaml +name: gitea-runner + +services: + act-runner: + image: gitea/act_runner:0.2.11 + container_name: gitea-act-runner + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: https://git.menzel.center + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN} + GITEA_RUNNER_NAME: docker-srv001 + # Muss zu den Labels in config.yaml passen. + GITEA_RUNNER_LABELS: ubuntu-latest,ubuntu-22.04 + CONFIG_FILE: /config.yaml + volumes: + # Der Runner startet die Job-Container über den Docker-Socket des Hosts. + - /var/run/docker.sock:/var/run/docker.sock + - ./config.yaml:/config.yaml:ro + - ./data:/data +``` + +`.env` daneben: + +```bash +GITEA_RUNNER_REGISTRATION_TOKEN= +``` + +> Der gemountete Docker-Socket gibt dem Runner faktisch Root-Rechte auf dem Host. +> Das ist für einen Runner, der eigene Images baut, üblich – betreibe ihn +> deshalb nur mit vertrauenswürdigen Repositories. + +## 3. `config.yaml` + +Im selben Verzeichnis anlegen: + +```yaml +log: + level: info + +runner: + file: /data/.runner + capacity: 2 + timeout: 3h + # Die linke Seite ist das Label aus `runs-on`, rechts das Image, in dem der Job läuft. + labels: + - "ubuntu-latest:docker://node:20-bookworm" + - "ubuntu-22.04:docker://node:20-bookworm" + +cache: + enabled: true + dir: /data/cache + # Adresse, unter der die Job-Container den Cache-Server des Runners erreichen. + # Leer lassen heißt: automatisch ermitteln. Bei mehreren Netzen hier die IP + # des Hosts im Runner-Netz eintragen. + host: "" + port: 0 + +container: + # Job-Container in dasselbe Netz hängen wie der Runner. + network: bridge + privileged: false + # Nötig, damit `actions/cache` und `docker/build-push-action` Pfade einblenden dürfen. + valid_volumes: + - /data/cache + docker_host: "-" + # Bei selbstsigniertem Zertifikat auf true setzen, siehe Abschnitt 6. + force_pull: false +``` + +`docker_host: "-"` bedeutet: den Socket des Runners an die Job-Container +weiterreichen. Genau das braucht `build.yml`, um Images zu bauen. + +Starten: + +```bash +docker compose up -d +docker compose logs -f +``` + +Beim ersten Start registriert sich der Runner und legt `/data/.runner` an. Ab +dann wird das Registrierungstoken nicht mehr gebraucht. + +## 4. Repo-Secret `REGISTRY_TOKEN` anlegen + +Der Build-Workflow lädt Images in die Gitea-Registry und braucht dafür ein +Token mit Schreibrecht auf Pakete: + +1. In Gitea auf *Benutzereinstellungen → Applications → Generate New Token*. +2. Namen vergeben, etwa `moneyfy-registry`. +3. Unter *Select permissions* bei **package** auf `Read and Write` stellen. +4. **Generate Token**, Wert kopieren – er wird nur einmal angezeigt. +5. Im Repository *Settings → Actions → Secrets → Add Secret*: + - Name: `REGISTRY_TOKEN` + - Wert: das kopierte Token + +Der Workflow meldet sich mit `${{ github.actor }}` an, also mit dem Benutzer, +der den Lauf ausgelöst hat. Dieser Benutzer muss Schreibrecht auf die Pakete des +Namensraums `menzeljonas` haben. + +## 5. Verifikation + +1. **Runner erscheint als „idle“:** in Gitea unter *Settings → Actions → Runners*. + Dort stehen Name (`docker-srv001`), Labels und Status. +2. **Testworkflow läuft:** einen Commit auf einen beliebigen Branch schieben. + Unter *Actions* muss `CI` starten und beide Jobs grün abschließen. +3. **Image landet in der Registry:** auf `main` pushen und danach + `https://git.menzel.center/menzeljonas/-/packages` öffnen. Dort müssen + `moneyfy-backend` und `moneyfy-frontend` mit den Tags `main` und + `sha-` auftauchen. +4. **Versions-Tag prüfen:** + + ```bash + git tag -a v0.1.0 -m "Erste Version" + git push origin v0.1.0 + ``` + + Danach müssen zusätzlich `0.1.0`, `0.1` und `latest` vorhanden sein. + +5. **Image von Hand ziehen** (prüft nebenbei die Registry-Anmeldung): + + ```bash + docker login git.menzel.center + docker pull git.menzel.center/menzeljonas/moneyfy-backend:latest + ``` + +## 6. Fehlersuche + +### `docker: command not found` im Job + +Das Job-Image bringt keinen Docker-Client mit. Zwei Wege: + +- In `config.yaml` sicherstellen, dass `docker_host: "-"` gesetzt ist – der + Runner reicht dann seinen Socket weiter. +- Für Jobs, die Docker brauchen, ein Image mit Client verwenden, etwa das Label + `ubuntu-latest:docker://catthehacker/ubuntu:act-22.04`. Die Actions + `docker/setup-buildx-action` und `docker/build-push-action` bringen den Rest mit. + +Prüfen lässt sich das mit einem Wegwerf-Schritt: + +```yaml +- run: docker version +``` + +### Registry-Anmeldung schlägt mit selbstsigniertem Zertifikat fehl + +Meldung: `x509: certificate signed by unknown authority`. + +Auf dem Host das CA-Zertifikat hinterlegen und den Docker-Daemon neu starten: + +```bash +mkdir -p /etc/docker/certs.d/git.menzel.center +cp menzel-center-ca.crt /etc/docker/certs.d/git.menzel.center/ca.crt +systemctl restart docker +``` + +Zusätzlich muss der Runner-Container die CA kennen, weil buildx im Container +läuft. Im Compose des Runners einblenden: + +```yaml + volumes: + - /usr/local/share/ca-certificates/menzel-center-ca.crt:/usr/local/share/ca-certificates/menzel-center-ca.crt:ro +``` + +und im Runner einmalig `update-ca-certificates` ausführen. Ein gültiges +Let's-Encrypt-Zertifikat auf dem Gitea-Host erspart diesen ganzen Abschnitt. + +### `DOCKER_HOST` im DinD-Betrieb + +Wer statt des Host-Sockets einen Docker-in-Docker-Dienst betreibt, ergänzt +diesen als zweiten Service und setzt im Runner: + +```yaml + environment: + DOCKER_HOST: tcp://docker-in-docker:2376 + DOCKER_CERT_PATH: /certs/client + DOCKER_TLS_VERIFY: "1" + volumes: + - ./certs:/certs +``` + +In `config.yaml` dann `docker_host: tcp://docker-in-docker:2376`. DinD ist +langsamer und der Schichten-Cache geht bei jedem Neustart verloren – der +gemountete Host-Socket ist für ein Homelab die pragmatischere Wahl. + +### Der Cache des Runners frisst Speicherplatz + +`/opt/gitea-runner/data/cache` und die Build-Schichten wachsen mit jedem Lauf. +Belegung ansehen: + +```bash +du -sh /opt/gitea-runner/data/cache +docker system df +``` + +Aufräumen: + +```bash +# Nicht mehr referenzierte Schichten und Build-Cache älter als eine Woche +docker buildx prune --filter "until=168h" -f +docker image prune -f + +# Cache-Verzeichnis des Runners leeren +docker compose -f /opt/gitea-runner/docker-compose.yml down +rm -rf /opt/gitea-runner/data/cache/* +docker compose -f /opt/gitea-runner/docker-compose.yml up -d +``` + +Regelmäßig per Cron: + +```cron +0 4 * * 0 docker buildx prune --filter "until=168h" -f >/dev/null 2>&1 +``` + +### Job bleibt in „waiting“ hängen + +Die Labels stimmen nicht überein. `runs-on: ubuntu-latest` im Workflow braucht +ein Label `ubuntu-latest` am Runner. Nach einer Änderung in `config.yaml` muss +der Runner neu registriert werden: + +```bash +docker compose down +rm data/.runner +# neues Registrierungstoken in .env eintragen +docker compose up -d +``` diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..12b04fb --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.vite/ +coverage/ +*.tsbuildinfo +.env +.env.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..bb6ab7e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.7 +# +# Frontend-Image für moneyfy: Vite-Bundle bauen, mit nginx ausliefern. + +# --- Abhängigkeiten ----------------------------------------------------------- +FROM node:22-bookworm-slim AS deps + +WORKDIR /app + +# Nur die Sperrdatei kopieren – die Schicht bleibt gültig, solange sich die +# Abhängigkeiten nicht ändern. +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +# --- Entwicklung -------------------------------------------------------------- +# Wird von docker-compose.override.yml genutzt; der Quellcode kommt per Volume. +FROM deps AS development + +ENV NODE_ENV=development +COPY . . + +# Vite legt seinen Cache unter node_modules/.vite ab – der Benutzer braucht Schreibrecht. +RUN chown -R node:node /app + +USER node +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + +# --- Bau ---------------------------------------------------------------------- +FROM deps AS build + +COPY . . + +# `npm run build` prüft zuerst die Typen und bricht bei Fehlern ab. +RUN npm run build + +# --- Auslieferung ------------------------------------------------------------- +# Das unprivilegierte Image läuft als Benutzer nginx und lauscht auf 8080. +FROM nginxinc/nginx-unprivileged:1.27-alpine AS production + +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf new file mode 100644 index 0000000..d92e5c4 --- /dev/null +++ b/frontend/docker/nginx.conf @@ -0,0 +1,82 @@ +# nginx-Konfiguration des Frontend-Containers. +# +# Liefert das gebaute Vite-Bundle aus und reicht /api an das Backend weiter. +# Das Image läuft unprivilegiert, deshalb Port 8080 statt 80. + +server { + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Hinter dem Reverse Proxy kommen die echten Adressen aus den Kopfzeilen. + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + client_max_body_size 4m; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css application/javascript application/json + image/svg+xml application/xml; + + # Die Anwendung wird ausschließlich hinter einem Reverse Proxy betrieben; + # TLS und HSTS übernimmt dieser. + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + # --- API ----------------------------------------------------------------- + location /api/ { + proxy_pass http://moneyfy-backend:8000; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Connection ""; + + # Exporte können bei vielen Buchungen etwas dauern. + proxy_connect_timeout 5s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + + # API-Antworten enthalten Kontostände – nichts davon zwischenspeichern. + proxy_buffering off; + add_header Cache-Control "no-store" always; + } + + # --- Statische Dateien --------------------------------------------------- + # Vite hängt einen Inhaltshash an die Dateinamen; sie sind unveränderlich. + location /assets/ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable" always; + access_log off; + try_files $uri =404; + } + + location = /favicon.svg { + expires 7d; + access_log off; + } + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Alles Übrige beantwortet die Single-Page-Anwendung. + location / { + # index.html darf nie im Cache hängen bleiben, sonst zeigt ein Browser + # nach dem Update weiter auf die alten Bundle-Namen. + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /index.html; + } +}