From 1fc39ee12b72c11bf4418361dcf958b59f0cdb33 Mon Sep 17 00:00:00 2001 From: Meik Date: Thu, 7 May 2026 19:16:17 +0200 Subject: [PATCH] Prepare Portainer stack deployment --- .env.portainer.example | 44 ++++++++++++++++++++ README.md | 2 + docker-compose.yml | 89 ++++++++++++++++++++++++----------------- docs/portainer-stack.md | 55 +++++++++++++++++++++++++ src/config.ts | 60 +++++++++++++++------------ 5 files changed, 188 insertions(+), 62 deletions(-) create mode 100644 .env.portainer.example create mode 100644 docs/portainer-stack.md diff --git a/.env.portainer.example b/.env.portainer.example new file mode 100644 index 0000000..ff576fc --- /dev/null +++ b/.env.portainer.example @@ -0,0 +1,44 @@ +# Copy the needed values into Portainer under Stack > Environment variables. +# Do not commit a filled production .env file. + +BACKEND_PORT=8112 +FRONTEND_PORT=8113 +LOG_LEVEL=info +ALERT_DAYS_BEFORE=45 +SCHEDULER_INTERVAL_MINUTES=60 +APP_LOCALE=de +AUTH_USERNAME=admin +AUTH_PASSWORD=change-this-password +AUTH_JWT_SECRET=change-this-to-a-long-random-secret +AUTH_TOKEN_EXPIRES_IN_HOURS=12 +WATCHTOWER_ENABLE=false + +# Optional image names if Portainer should tag/pull custom images. +# BACKEND_IMAGE=registry.example.com/paperless-contract-companion-api:latest +# FRONTEND_IMAGE=registry.example.com/paperless-contract-companion-ui:latest + +# Optional public app URL for generated iCal/app links. +# APP_EXTERNAL_URL=https://contracts.example.com + +# Optional paperless-ngx integration. +# PAPERLESS_BASE_URL=http://paperless:8000 +# PAPERLESS_EXTERNAL_URL=https://paperless.example.com +# PAPERLESS_TOKEN=replace-with-paperless-token + +# Optional ntfy push settings. +# NTFY_SERVER_URL=https://ntfy.example.com +# NTFY_TOPIC=contracts +# NTFY_TOKEN=replace-with-ntfy-token +# NTFY_PRIORITY=high + +# Optional mail settings. +# MAIL_SERVER=smtp.example.com +# MAIL_PORT=587 +# MAIL_USERNAME=mailer +# MAIL_PASSWORD=replace-with-mail-password +# MAIL_USE_TLS=true +# MAIL_FROM=contract-monitor@example.com +# MAIL_TO=you@example.com + +# Optional fixed iCal token. +# ICAL_SECRET=replace-with-long-random-token diff --git a/README.md b/README.md index 63b5519..d76b6e6 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,8 @@ docker compose up --build - Frontend: `http://localhost:8113` - SQLite-Datenbank: Volume `contracts-data` → `/app/data/contracts.db` +Für Portainer-Deployments ist `docker-compose.yml` als Git-basierter Stack vorbereitet. Lege die produktiven Werte in Portainer unter den Stack-Environment-Variablen an; eine Vorlage steht in `.env.portainer.example`, Details in [`docs/portainer-stack.md`](docs/portainer-stack.md). + --- ## Konfiguration diff --git a/docker-compose.yml b/docker-compose.yml index 7daf71f..a6f7fa8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,58 +1,75 @@ -version: "3.9" - services: contract-companion: - build: . - container_name: contract-companion + image: ${BACKEND_IMAGE:-paperless-contract-companion-api:latest} + build: + context: . + dockerfile: Dockerfile restart: unless-stopped environment: - PORT=8000 - - LOG_LEVEL=info + - LOG_LEVEL=${LOG_LEVEL:-info} - DATABASE_PATH=/app/data/contracts.db - - PAPERLESS_BASE_URL=http://192.168.178.33:8010 - - PAPERLESS_EXTERNAL_URL=https://paperless.srv.medeba-media.de - - PAPERLESS_TOKEN=6b45776ff3fbbc809a46fc59a80e3ce867cae09a - - ALERT_DAYS_BEFORE=45 - - SCHEDULER_INTERVAL_MINUTES=60 - - AUTH_USERNAME=admin - - AUTH_PASSWORD=change-me - - AUTH_JWT_SECRET=replace-with-random-secret - - AUTH_TOKEN_EXPIRES_IN_HOURS=12 - # Optional ntfy push settings: - # - NTFY_SERVER_URL=https://ntfy.example.com - # - NTFY_TOPIC=contracts - # - NTFY_TOKEN=secret - # - NTFY_PRIORITY=high - # Optional mail settings: - # - MAIL_SERVER=smtp.example.com - # - MAIL_PORT=587 - # - MAIL_USERNAME=mailer - # - MAIL_PASSWORD=secret - # - MAIL_USE_TLS=true - # - MAIL_FROM=contract-monitor@example.com - # - MAIL_TO=you@example.com - - APP_EXTERNAL_URL=https://vertraege.srv.medeba-media.de - - APP_LOCALE=de - # Optional: festen iCal-Token vorgeben - # - ICAL_SECRET=replace-with-secret + - ALERT_DAYS_BEFORE=${ALERT_DAYS_BEFORE:-45} + - SCHEDULER_INTERVAL_MINUTES=${SCHEDULER_INTERVAL_MINUTES:-60} + - AUTH_USERNAME=${AUTH_USERNAME:-admin} + - AUTH_PASSWORD=${AUTH_PASSWORD:?Set AUTH_PASSWORD in Portainer stack environment} + - AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?Set AUTH_JWT_SECRET in Portainer stack environment} + - AUTH_TOKEN_EXPIRES_IN_HOURS=${AUTH_TOKEN_EXPIRES_IN_HOURS:-12} + - APP_LOCALE=${APP_LOCALE:-de} + - PAPERLESS_BASE_URL + - PAPERLESS_EXTERNAL_URL + - PAPERLESS_TOKEN + - APP_EXTERNAL_URL + - NTFY_SERVER_URL + - NTFY_TOPIC + - NTFY_TOKEN + - NTFY_PRIORITY + - MAIL_SERVER + - MAIL_PORT + - MAIL_USERNAME + - MAIL_PASSWORD + - MAIL_USE_TLS + - MAIL_FROM + - MAIL_TO + - ICAL_SECRET volumes: - contracts-data:/app/data ports: - - "8112:8000" + - "${BACKEND_PORT:-8112}:8000" labels: - - "com.centurylinklabs.watchtower.enable=false" + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-false}" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/healthz >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + networks: + - contract-companion + contract-companion-ui: + image: ${FRONTEND_IMAGE:-paperless-contract-companion-ui:latest} build: context: . dockerfile: frontend/Dockerfile - container_name: contract-companion-ui restart: unless-stopped ports: - - "8113:80" + - "${FRONTEND_PORT:-8113}:80" labels: - - "com.centurylinklabs.watchtower.enable=false" + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_ENABLE:-false}" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s depends_on: - contract-companion + networks: + - contract-companion volumes: contracts-data: + +networks: + contract-companion: diff --git a/docs/portainer-stack.md b/docs/portainer-stack.md new file mode 100644 index 0000000..2247ca4 --- /dev/null +++ b/docs/portainer-stack.md @@ -0,0 +1,55 @@ +# Portainer Stack Deployment + +This repository can be deployed as a Portainer-managed Docker Compose stack. The stack builds the backend and frontend images from this Git repository, stores SQLite data in a named volume, and exposes the API and UI on configurable host ports. + +## Recommended Portainer Setup + +1. In Portainer, create a new stack from a Git repository. +2. Use this repository as the Git source and set the Compose path to `docker-compose.yml`. +3. Set the stack name in Portainer, for example `paperless-contracts`. +4. Add the required environment variables from `.env.portainer.example` under **Environment variables**. +5. Deploy the stack. + +Portainer should own the stack lifecycle after deployment. Avoid setting `container_name` values manually; Portainer and Docker Compose will namespace containers, networks, and volumes through the stack. + +## Required Variables + +Set these values in Portainer before deploying: + +| Variable | Purpose | +| --- | --- | +| `AUTH_PASSWORD` | Login password for the admin user. | +| `AUTH_JWT_SECRET` | Long random secret used to sign login tokens. | + +`AUTH_USERNAME` defaults to `admin` when it is not set. + +## Important Optional Variables + +| Variable | Default | Purpose | +| --- | --- | --- | +| `FRONTEND_PORT` | `8113` | Host port for the web UI. | +| `BACKEND_PORT` | `8112` | Host port for direct API access. | +| `APP_EXTERNAL_URL` | unset | Public app URL used in generated links and iCal entries. | +| `PAPERLESS_BASE_URL` | unset | Internal paperless-ngx API URL. | +| `PAPERLESS_EXTERNAL_URL` | unset | Public paperless-ngx URL for browser links. | +| `PAPERLESS_TOKEN` | unset | paperless-ngx API token. | +| `WATCHTOWER_ENABLE` | `false` | Enables Watchtower updates when set to `true`. | + +Leave optional variables unset when they are not used. The backend treats blank optional variables as absent, which keeps Portainer edits from breaking URL validation. + +## Data and Networking + +- Contract data is stored in the named Docker volume `contracts-data`. +- Backend and frontend communicate on the private Compose network `contract-companion`. +- The frontend proxies `/api/*` requests to the backend service name `contract-companion`. +- Both services include healthchecks so Portainer can show container health. + +## Local Validation + +Before deploying, you can validate the rendered Compose file locally: + +```bash +docker compose --env-file .env.portainer.example config +``` + +Use real secrets only in Portainer or in a local, untracked `.env` file. diff --git a/src/config.ts b/src/config.ts index 6ebd856..7bbcf75 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,33 +38,41 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean { return ["1", "true", "yes", "on"].includes(value.toLowerCase()); } +function readEnv(name: string): string | undefined { + const value = process.env[name]; + if (value === undefined) { + return undefined; + } + return value.trim().length > 0 ? value : undefined; +} + const rawConfig = { - port: process.env.PORT, - logLevel: process.env.LOG_LEVEL, - databasePath: process.env.DATABASE_PATH, - paperlessBaseUrl: process.env.PAPERLESS_BASE_URL, - paperlessToken: process.env.PAPERLESS_TOKEN, - paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL, - appExternalUrl: process.env.APP_EXTERNAL_URL, - appLocale: process.env.APP_LOCALE, - schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES, - alertDaysBefore: process.env.ALERT_DAYS_BEFORE, - mailServer: process.env.MAIL_SERVER, - mailPort: process.env.MAIL_PORT, - mailUsername: process.env.MAIL_USERNAME, - mailPassword: process.env.MAIL_PASSWORD, - mailUseTls: parseBoolean(process.env.MAIL_USE_TLS, true), - mailFrom: process.env.MAIL_FROM, - mailTo: process.env.MAIL_TO, - authUsername: process.env.AUTH_USERNAME, - authPassword: process.env.AUTH_PASSWORD, - authJwtSecret: process.env.AUTH_JWT_SECRET, - authTokenExpiresInHours: process.env.AUTH_TOKEN_EXPIRES_IN_HOURS, - ntfyServerUrl: process.env.NTFY_SERVER_URL, - ntfyTopic: process.env.NTFY_TOPIC, - ntfyToken: process.env.NTFY_TOKEN, - ntfyPriority: process.env.NTFY_PRIORITY, - icalSecret: process.env.ICAL_SECRET + port: readEnv("PORT"), + logLevel: readEnv("LOG_LEVEL"), + databasePath: readEnv("DATABASE_PATH"), + paperlessBaseUrl: readEnv("PAPERLESS_BASE_URL"), + paperlessToken: readEnv("PAPERLESS_TOKEN"), + paperlessExternalUrl: readEnv("PAPERLESS_EXTERNAL_URL"), + appExternalUrl: readEnv("APP_EXTERNAL_URL"), + appLocale: readEnv("APP_LOCALE"), + schedulerIntervalMinutes: readEnv("SCHEDULER_INTERVAL_MINUTES"), + alertDaysBefore: readEnv("ALERT_DAYS_BEFORE"), + mailServer: readEnv("MAIL_SERVER"), + mailPort: readEnv("MAIL_PORT"), + mailUsername: readEnv("MAIL_USERNAME"), + mailPassword: readEnv("MAIL_PASSWORD"), + mailUseTls: parseBoolean(readEnv("MAIL_USE_TLS"), true), + mailFrom: readEnv("MAIL_FROM"), + mailTo: readEnv("MAIL_TO"), + authUsername: readEnv("AUTH_USERNAME"), + authPassword: readEnv("AUTH_PASSWORD"), + authJwtSecret: readEnv("AUTH_JWT_SECRET"), + authTokenExpiresInHours: readEnv("AUTH_TOKEN_EXPIRES_IN_HOURS"), + ntfyServerUrl: readEnv("NTFY_SERVER_URL"), + ntfyTopic: readEnv("NTFY_TOPIC"), + ntfyToken: readEnv("NTFY_TOKEN"), + ntfyPriority: readEnv("NTFY_PRIORITY"), + icalSecret: readEnv("ICAL_SECRET") }; export type AppConfig = z.infer;