diff --git a/.env.example b/.env.example index 0a6780f..c456fe4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,52 @@ +# FEDEO Selfhosting +DOMAIN=app.example.com +CONTACT_EMAIL=admin@example.com + +DB_NAME=fedeo +DB_USER=fedeo +DB_PASSWORD=change-this-db-password +DATABASE_URL=postgres://fedeo:change-this-db-password@db:5432/fedeo + +MINIO_ROOT_USER=fedeo-minio +MINIO_ROOT_PASSWORD=change-this-minio-password +MINIO_BUCKET=fedeo + +HOST=0.0.0.0 +PORT=3100 +FEDEO_RUN_MIGRATIONS=true +COOKIE_SECRET=change-this-cookie-secret +JWT_SECRET=change-this-jwt-secret +ENCRYPTION_KEY=change-this-encryption-key + +MAILER_SMTP_HOST=smtp.example.com +MAILER_SMTP_PORT=587 +MAILER_SMTP_SSL=false +MAILER_SMTP_USER=mailer@example.com +MAILER_SMTP_PASS=change-this-mail-password +MAILER_FROM=FEDEO + +S3_ENDPOINT=http://minio:9000 +S3_REGION=eu-central-1 +S3_ACCESS_KEY=fedeo-minio +S3_SECRET_KEY=change-this-minio-password +S3_BUCKET=fedeo + +M2M_API_KEY=change-this-m2m-key +API_BASE_URL=https://app.example.com/backend +GOCARDLESS_BASE_URL=https://api.gocardless.com +GOCARDLESS_SECRET_ID=replace-this +GOCARDLESS_SECRET_KEY=replace-this + +DOKUBOX_IMAP_HOST=imap.example.com +DOKUBOX_IMAP_PORT=993 +DOKUBOX_IMAP_SECURE=true +DOKUBOX_IMAP_USER=dokubox@example.com +DOKUBOX_IMAP_PASSWORD=change-this-imap-password + +OPENAI_API_KEY=replace-this +STIRLING_API_KEY=replace-this +NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license + # FEDEO Matrix-Kommunikation # # Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix" diff --git a/README.md b/README.md index 698c298..a691a18 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Wenn du MinIO verwendest, setze zusatzlich: ## Deploy-Struktur -Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet. +Deploye den Stack direkt aus einem geklonten Checkout dieses Repositories, weil die Selfhost-Compose-Datei die lokalen Build-Kontexte `./frontend` und `./backend` verwendet. Beispiel: @@ -102,7 +102,7 @@ Die Verzeichnisstruktur sollte dann mindestens so aussehen: ```text /opt/fedeo/ - docker-compose.yml + docker-compose.selfhost.yml .env backend/ frontend/ @@ -124,6 +124,14 @@ touch /opt/fedeo/traefik/letsencrypt/acme.json chmod 600 /opt/fedeo/traefik/letsencrypt/acme.json ``` +Als Startpunkt kannst du die Beispielumgebung kopieren: + +```bash +cp .env.example .env +``` + +Ersetze anschließend alle Platzhalter und passe mindestens `DOMAIN`, `CONTACT_EMAIL`, Datenbank-, Secret-, SMTP- und S3-Werte an. + ## Beispiel `.env` Diese Datei liegt neben der `docker-compose.yml`: @@ -178,9 +186,11 @@ STIRLING_API_KEY=replace-this NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license ``` -## Vollstandiges Docker Compose mit optionaler S3-MinIO-Option +## Docker Compose mit optionaler S3-MinIO-Option -Hinweis: Der Stack unten startet MinIO standardmassig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen. +Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen. + +Das Backend führt beim Containerstart standardmäßig `npm run migrate` aus. Setze `FEDEO_RUN_MIGRATIONS=false`, wenn du Migrationen bewusst manuell ausführen möchtest. ```yaml services: @@ -372,16 +382,22 @@ Hinweis: Das Backend nutzt `forcePathStyle: true`. Das funktioniert sauber mit M Im Deploy-Verzeichnis: ```bash -docker compose build -docker compose up -d +docker compose -f docker-compose.selfhost.yml build +docker compose -f docker-compose.selfhost.yml up -d ``` Danach Status prufen: ```bash -docker compose ps -docker compose logs -f traefik -docker compose logs -f backend +docker compose -f docker-compose.selfhost.yml ps +docker compose -f docker-compose.selfhost.yml logs -f traefik +docker compose -f docker-compose.selfhost.yml logs -f backend +``` + +Wenn du Migrationen manuell ausführen möchtest: + +```bash +docker compose -f docker-compose.selfhost.yml run --rm backend npm run migrate ``` ## Funktionsprufung @@ -404,10 +420,12 @@ Bei neuen Versionen: ```bash git pull -docker compose build -docker compose up -d +docker compose -f docker-compose.selfhost.yml build +docker compose -f docker-compose.selfhost.yml up -d ``` +Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an. Bei kritischen Updates sollte vorher ein Backup von `./postgres` und `./minio` erstellt werden. + Falls du statt lokaler Builds vorgebaute Images verwenden willst, kannst du in der Compose-Datei `build:` durch passende `image:`-Eintrage ersetzen. Erst dann ist ein vorgelagertes `docker compose pull` sinnvoll. ## Backup-Empfehlung diff --git a/backend/Dockerfile b/backend/Dockerfile index 66ea34b..19607f5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -24,5 +24,5 @@ RUN npm run build # Port freigeben EXPOSE 3100 -# Start der App -CMD ["node", "dist/src/index.js"] +# Migrationen ausführen und App starten +CMD ["sh", "./docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..7f40d79 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,7 @@ +set -e + +if [ "${FEDEO_RUN_MIGRATIONS:-true}" = "true" ]; then + npm run migrate +fi + +exec node dist/src/index.js diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index 237a05e..a186f52 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -44,7 +44,75 @@ export let secrets = { MATRIX_SERVICE_USER_LOCALPART?: string } +const secretKeys = [ + "COOKIE_SECRET", + "JWT_SECRET", + "PORT", + "HOST", + "DATABASE_URL", + "S3_BUCKET", + "ENCRYPTION_KEY", + "MAILER_SMTP_HOST", + "MAILER_SMTP_PORT", + "MAILER_SMTP_SSL", + "MAILER_SMTP_USER", + "MAILER_SMTP_PASS", + "MAILER_FROM", + "S3_ENDPOINT", + "S3_REGION", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "M2M_API_KEY", + "API_BASE_URL", + "GOCARDLESS_BASE_URL", + "GOCARDLESS_SECRET_ID", + "GOCARDLESS_SECRET_KEY", + "DOKUBOX_IMAP_HOST", + "DOKUBOX_IMAP_PORT", + "DOKUBOX_IMAP_SECURE", + "DOKUBOX_IMAP_USER", + "DOKUBOX_IMAP_PASSWORD", + "OPENAI_API_KEY", + "STIRLING_API_KEY", + "MATRIX_HOMESERVER_URL", + "MATRIX_SERVER_NAME", + "MATRIX_REGISTRATION_SHARED_SECRET", + "MATRIX_SERVICE_USER_LOCALPART", +] as const + +const numberKeys = new Set(["PORT", "MAILER_SMTP_PORT", "DOKUBOX_IMAP_PORT"]) +const booleanKeys = new Set(["DOKUBOX_IMAP_SECURE"]) + +function normalizeEnvValue(key: string, value: string) { + if (numberKeys.has(key)) return Number(value) + if (booleanKeys.has(key)) return value === "true" + return value +} + +function loadSecretsFromEnv() { + let loaded = 0 + + secretKeys.forEach((key) => { + const value = process.env[key] + if (value === undefined || value === "") return + + ;(secrets as Record)[key] = normalizeEnvValue(key, value) + loaded++ + }) + + if (!secrets.HOST) secrets.HOST = "0.0.0.0" + if (!secrets.PORT) secrets.PORT = 3100 + + return loaded +} + export async function loadSecrets () { + const envSecretCount = loadSecretsFromEnv() + + if (!process.env.INFISICAL_CLIENT_ID || !process.env.INFISICAL_CLIENT_SECRET) { + console.log(`✅ Secrets aus Umgebungsvariablen geladen (${envSecretCount} Stück)`) + return + } await client.auth().universalAuth.login({ clientId: process.env.INFISICAL_CLIENT_ID, @@ -57,8 +125,9 @@ export async function loadSecrets () { }); allSecrets.secrets.forEach(secret => { - secrets[secret.secretKey] = secret.secretValue + ;(secrets as Record)[secret.secretKey] = normalizeEnvValue(secret.secretKey, secret.secretValue) }) - console.log("✅ Secrets aus Infisical geladen"); + loadSecretsFromEnv() + console.log("✅ Secrets aus Infisical und Umgebungsvariablen geladen"); console.log(Object.keys(secrets).length + " Stück") } diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml new file mode 100644 index 0000000..5a506e9 --- /dev/null +++ b/docker-compose.selfhost.yml @@ -0,0 +1,164 @@ +services: + traefik: + image: traefik:v2.11 + container_name: fedeo-traefik + restart: unless-stopped + command: + - --api.insecure=false + - --api.dashboard=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.tlschallenge=true + - --certificatesresolvers.letsencrypt.acme.email=${CONTACT_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --accesslog=true + - --accesslog.filepath=/logs/access.log + ports: + - "80:80" + - "443:443" + volumes: + - ./traefik/letsencrypt:/letsencrypt + - ./traefik/logs:/logs + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - web + + db: + image: postgres:16 + container_name: fedeo-db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - ./postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - internal + + minio: + image: minio/minio:latest + container_name: fedeo-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + volumes: + - ./minio:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - internal + + createbuckets: + image: minio/mc:latest + container_name: fedeo-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; + mc mb --ignore-existing local/${MINIO_BUCKET}; + mc anonymous set private local/${MINIO_BUCKET}; + exit 0; + " + restart: "no" + networks: + - internal + + backend: + build: + context: ./backend + container_name: fedeo-backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + minio: + condition: service_healthy + createbuckets: + condition: service_completed_successfully + environment: + NODE_ENV: production + FEDEO_RUN_MIGRATIONS: ${FEDEO_RUN_MIGRATIONS:-true} + HOST: ${HOST:-0.0.0.0} + PORT: ${PORT:-3100} + COOKIE_SECRET: ${COOKIE_SECRET} + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + DATABASE_URL: ${DATABASE_URL} + MAILER_SMTP_HOST: ${MAILER_SMTP_HOST} + MAILER_SMTP_PORT: ${MAILER_SMTP_PORT} + MAILER_SMTP_SSL: ${MAILER_SMTP_SSL} + MAILER_SMTP_USER: ${MAILER_SMTP_USER} + MAILER_SMTP_PASS: ${MAILER_SMTP_PASS} + MAILER_FROM: ${MAILER_FROM} + S3_ENDPOINT: ${S3_ENDPOINT} + S3_REGION: ${S3_REGION} + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET} + M2M_API_KEY: ${M2M_API_KEY} + API_BASE_URL: ${API_BASE_URL} + GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL} + GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID} + GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY} + DOKUBOX_IMAP_HOST: ${DOKUBOX_IMAP_HOST} + DOKUBOX_IMAP_PORT: ${DOKUBOX_IMAP_PORT} + DOKUBOX_IMAP_SECURE: ${DOKUBOX_IMAP_SECURE} + DOKUBOX_IMAP_USER: ${DOKUBOX_IMAP_USER} + DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD} + OPENAI_API_KEY: ${OPENAI_API_KEY} + STIRLING_API_KEY: ${STIRLING_API_KEY} + labels: + - traefik.enable=true + - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) + - traefik.http.routers.fedeo-backend.entrypoints=websecure + - traefik.http.routers.fedeo-backend.tls.certresolver=letsencrypt + - traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend + - traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-strip + - traefik.http.services.fedeo-backend.loadbalancer.server.port=3100 + networks: + - web + - internal + + frontend: + build: + context: ./frontend + container_name: fedeo-frontend + restart: unless-stopped + depends_on: + - backend + environment: + NODE_ENV: production + NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend + NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE} + NUXT_PUBLIC_MATRIX_ELEMENT_URL: ${NUXT_PUBLIC_MATRIX_ELEMENT_URL:-} + labels: + - traefik.enable=true + - traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`) + - traefik.http.routers.fedeo-frontend.entrypoints=websecure + - traefik.http.routers.fedeo-frontend.tls.certresolver=letsencrypt + - traefik.http.services.fedeo-frontend.loadbalancer.server.port=3000 + networks: + - web + +networks: + web: + driver: bridge + internal: + driver: bridge