Prepare Portainer stack deployment
This commit is contained in:
44
.env.portainer.example
Normal file
44
.env.portainer.example
Normal file
@@ -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
|
||||||
@@ -73,6 +73,8 @@ docker compose up --build
|
|||||||
- Frontend: `http://localhost:8113`
|
- Frontend: `http://localhost:8113`
|
||||||
- SQLite-Datenbank: Volume `contracts-data` → `/app/data/contracts.db`
|
- 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
|
## Konfiguration
|
||||||
|
|||||||
@@ -1,58 +1,75 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
contract-companion:
|
contract-companion:
|
||||||
build: .
|
image: ${BACKEND_IMAGE:-paperless-contract-companion-api:latest}
|
||||||
container_name: contract-companion
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
- DATABASE_PATH=/app/data/contracts.db
|
- DATABASE_PATH=/app/data/contracts.db
|
||||||
- PAPERLESS_BASE_URL=http://192.168.178.33:8010
|
- ALERT_DAYS_BEFORE=${ALERT_DAYS_BEFORE:-45}
|
||||||
- PAPERLESS_EXTERNAL_URL=https://paperless.srv.medeba-media.de
|
- SCHEDULER_INTERVAL_MINUTES=${SCHEDULER_INTERVAL_MINUTES:-60}
|
||||||
- PAPERLESS_TOKEN=6b45776ff3fbbc809a46fc59a80e3ce867cae09a
|
- AUTH_USERNAME=${AUTH_USERNAME:-admin}
|
||||||
- ALERT_DAYS_BEFORE=45
|
- AUTH_PASSWORD=${AUTH_PASSWORD:?Set AUTH_PASSWORD in Portainer stack environment}
|
||||||
- SCHEDULER_INTERVAL_MINUTES=60
|
- AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?Set AUTH_JWT_SECRET in Portainer stack environment}
|
||||||
- AUTH_USERNAME=admin
|
- AUTH_TOKEN_EXPIRES_IN_HOURS=${AUTH_TOKEN_EXPIRES_IN_HOURS:-12}
|
||||||
- AUTH_PASSWORD=change-me
|
- APP_LOCALE=${APP_LOCALE:-de}
|
||||||
- AUTH_JWT_SECRET=replace-with-random-secret
|
- PAPERLESS_BASE_URL
|
||||||
- AUTH_TOKEN_EXPIRES_IN_HOURS=12
|
- PAPERLESS_EXTERNAL_URL
|
||||||
# Optional ntfy push settings:
|
- PAPERLESS_TOKEN
|
||||||
# - NTFY_SERVER_URL=https://ntfy.example.com
|
- APP_EXTERNAL_URL
|
||||||
# - NTFY_TOPIC=contracts
|
- NTFY_SERVER_URL
|
||||||
# - NTFY_TOKEN=secret
|
- NTFY_TOPIC
|
||||||
# - NTFY_PRIORITY=high
|
- NTFY_TOKEN
|
||||||
# Optional mail settings:
|
- NTFY_PRIORITY
|
||||||
# - MAIL_SERVER=smtp.example.com
|
- MAIL_SERVER
|
||||||
# - MAIL_PORT=587
|
- MAIL_PORT
|
||||||
# - MAIL_USERNAME=mailer
|
- MAIL_USERNAME
|
||||||
# - MAIL_PASSWORD=secret
|
- MAIL_PASSWORD
|
||||||
# - MAIL_USE_TLS=true
|
- MAIL_USE_TLS
|
||||||
# - MAIL_FROM=contract-monitor@example.com
|
- MAIL_FROM
|
||||||
# - MAIL_TO=you@example.com
|
- MAIL_TO
|
||||||
- APP_EXTERNAL_URL=https://vertraege.srv.medeba-media.de
|
- ICAL_SECRET
|
||||||
- APP_LOCALE=de
|
|
||||||
# Optional: festen iCal-Token vorgeben
|
|
||||||
# - ICAL_SECRET=replace-with-secret
|
|
||||||
volumes:
|
volumes:
|
||||||
- contracts-data:/app/data
|
- contracts-data:/app/data
|
||||||
ports:
|
ports:
|
||||||
- "8112:8000"
|
- "${BACKEND_PORT:-8112}:8000"
|
||||||
labels:
|
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:
|
contract-companion-ui:
|
||||||
|
image: ${FRONTEND_IMAGE:-paperless-contract-companion-ui:latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
container_name: contract-companion-ui
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8113:80"
|
- "${FRONTEND_PORT:-8113}:80"
|
||||||
labels:
|
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:
|
depends_on:
|
||||||
- contract-companion
|
- contract-companion
|
||||||
|
networks:
|
||||||
|
- contract-companion
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
contracts-data:
|
contracts-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
contract-companion:
|
||||||
|
|||||||
55
docs/portainer-stack.md
Normal file
55
docs/portainer-stack.md
Normal file
@@ -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.
|
||||||
@@ -38,33 +38,41 @@ function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
|||||||
return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
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 = {
|
const rawConfig = {
|
||||||
port: process.env.PORT,
|
port: readEnv("PORT"),
|
||||||
logLevel: process.env.LOG_LEVEL,
|
logLevel: readEnv("LOG_LEVEL"),
|
||||||
databasePath: process.env.DATABASE_PATH,
|
databasePath: readEnv("DATABASE_PATH"),
|
||||||
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL,
|
paperlessBaseUrl: readEnv("PAPERLESS_BASE_URL"),
|
||||||
paperlessToken: process.env.PAPERLESS_TOKEN,
|
paperlessToken: readEnv("PAPERLESS_TOKEN"),
|
||||||
paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL,
|
paperlessExternalUrl: readEnv("PAPERLESS_EXTERNAL_URL"),
|
||||||
appExternalUrl: process.env.APP_EXTERNAL_URL,
|
appExternalUrl: readEnv("APP_EXTERNAL_URL"),
|
||||||
appLocale: process.env.APP_LOCALE,
|
appLocale: readEnv("APP_LOCALE"),
|
||||||
schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES,
|
schedulerIntervalMinutes: readEnv("SCHEDULER_INTERVAL_MINUTES"),
|
||||||
alertDaysBefore: process.env.ALERT_DAYS_BEFORE,
|
alertDaysBefore: readEnv("ALERT_DAYS_BEFORE"),
|
||||||
mailServer: process.env.MAIL_SERVER,
|
mailServer: readEnv("MAIL_SERVER"),
|
||||||
mailPort: process.env.MAIL_PORT,
|
mailPort: readEnv("MAIL_PORT"),
|
||||||
mailUsername: process.env.MAIL_USERNAME,
|
mailUsername: readEnv("MAIL_USERNAME"),
|
||||||
mailPassword: process.env.MAIL_PASSWORD,
|
mailPassword: readEnv("MAIL_PASSWORD"),
|
||||||
mailUseTls: parseBoolean(process.env.MAIL_USE_TLS, true),
|
mailUseTls: parseBoolean(readEnv("MAIL_USE_TLS"), true),
|
||||||
mailFrom: process.env.MAIL_FROM,
|
mailFrom: readEnv("MAIL_FROM"),
|
||||||
mailTo: process.env.MAIL_TO,
|
mailTo: readEnv("MAIL_TO"),
|
||||||
authUsername: process.env.AUTH_USERNAME,
|
authUsername: readEnv("AUTH_USERNAME"),
|
||||||
authPassword: process.env.AUTH_PASSWORD,
|
authPassword: readEnv("AUTH_PASSWORD"),
|
||||||
authJwtSecret: process.env.AUTH_JWT_SECRET,
|
authJwtSecret: readEnv("AUTH_JWT_SECRET"),
|
||||||
authTokenExpiresInHours: process.env.AUTH_TOKEN_EXPIRES_IN_HOURS,
|
authTokenExpiresInHours: readEnv("AUTH_TOKEN_EXPIRES_IN_HOURS"),
|
||||||
ntfyServerUrl: process.env.NTFY_SERVER_URL,
|
ntfyServerUrl: readEnv("NTFY_SERVER_URL"),
|
||||||
ntfyTopic: process.env.NTFY_TOPIC,
|
ntfyTopic: readEnv("NTFY_TOPIC"),
|
||||||
ntfyToken: process.env.NTFY_TOKEN,
|
ntfyToken: readEnv("NTFY_TOKEN"),
|
||||||
ntfyPriority: process.env.NTFY_PRIORITY,
|
ntfyPriority: readEnv("NTFY_PRIORITY"),
|
||||||
icalSecret: process.env.ICAL_SECRET
|
icalSecret: readEnv("ICAL_SECRET")
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema>;
|
export type AppConfig = z.infer<typeof configSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user