Zum Inhalt springen
Q
QuoteNode

Installation

Mit Portainer, QNAP oder NAS installieren

QuoteNode auf einem NAS oder Portainer-verwalteten Docker-Host bereitstellen mit vollständigen Einrichtungsanweisungen.

Mit Portainer, QNAP oder NAS installieren

Verwenden Sie diesen Pfad, wenn Sie Docker über Portainer, QNAP Container Station, Synology oder eine ähnliche Plattform betreiben. Die genaue Benutzeroberfläche unterscheidet sich je nach Anbieter, aber der zugrundeliegende Docker-Compose-Stack ist identisch. Die Schritte beschreiben den Web editor von Portainer; eine Plattform, die Compose aus einem Ordner startet, verwendet stattdessen das unveränderte Release-Paket (Schritt 2).

Wann Sie diesen Pfad verwenden

  • Sie betreiben bereits Docker-Stacks über eine Container-Verwaltungsoberfläche.
  • Sie verstehen, wie die Plattform Volumes und Ports mapped.
  • Sie können Backups außerhalb der NAS-Oberfläche verifizieren.

Was möglicherweise nicht funktioniert

  • Öffentliche Links funktionieren außerhalb des LANs nicht, es sei denn, NAT, DNS, Proxy und HTTPS sind konfiguriert.
  • NAS-Schlafmodi können den Dienst unterbrechen.
  • Einige NAS-Docker-Oberflächen schreiben Compose-Dateien oder Umgebungsvariablen um; verifizieren Sie den gerenderten Stack, bevor Sie sich darauf verlassen.
  • Backups, die nur auf demselben NAS gespeichert sind, schützen nicht vor NAS-Ausfall.

Voraussetzungen

  • Docker Compose v2-Unterstützung in Ihrer Plattform.
  • Persistente Volume-Unterstützung für PostgreSQL-Daten, Uploads, PDFs und Backups.
  • Mindestens 2 GB RAM verfügbar und 10 GB freier Festplattenspeicher.
  • Eine stabile URL: LAN-IP für die interne Nutzung oder eine öffentliche Domain hinter einem Reverse-Proxy für Kundenlinks.
  • Ein 64-Bit-x86-Host (linux/amd64). ARM-Geräte, auch NAS-Modelle mit ARM-Prozessor, werden nicht unterstützt.

Schritt 1 — Einstellungen vorbereiten

Bereiten Sie die folgenden Einstellungen vor. Ersetzen Sie die URL durch Ihre tatsächliche NAS-Adresse oder öffentliche Domain. In Portainer werden sie zu den Umgebungsvariablen des Stacks (Schritt 2), die Portainer als stack.env speichert; bei einer ordnerbasierten Installation bilden sie die Datei .env neben docker-compose.yml:

Für eine rein interne NAS-Bereitstellung:

# Database
DB_URL=jdbc:postgresql://postgres:5432/quotenode
DB_USERNAME=quotenode
DB_PASSWORD=change-me-to-something-random-32-chars
DB_NAME=quotenode

# ============================================================
# SECURITY SECRETS — UNIQUE to this installation.
# SAVE THIS FILE and keep a secure backup.
# ============================================================
DB_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
TIMING_TOKEN_SECRET=change-me-timing-secret-min-32-chars
PUBLIC_LINK_PASSWORD_SESSION_SECRET=change-me-session-secret-min-32

# Internal NAS URL
CORS_ALLOWED_ORIGINS=http://nas.local
DOMAIN=http://nas.local

# Client IP is auto-detected behind internal proxies and CDNs (Cloudflare included) — leave empty.
# Set only for an unusual proxy whose transport peer is a PUBLIC address to trust (IP/CIDR or hostname).
SECURITY_TRUSTED_PROXIES=

# Application
LOG_LEVEL=INFO
SPRING_PROFILES_ACTIVE=prod

Für eine öffentliche Domain hinter Ihrem eigenen Reverse Proxy (hier unter 192.168.1.5), der HTTPS terminiert:

CORS_ALLOWED_ORIGINS=https://crm.example.com
DOMAIN=:80
EDGE_TRUSTED_PROXIES=192.168.1.5

Speichern Sie sofort eine Kopie dieser Einstellungen außerhalb des NAS. Wenn Sie DB_ENCRYPTION_KEY verlieren, werden verschlüsselte Felder dauerhaft unlesbar.

Schritt 2 — Stack erstellen

Öffnen Sie in Portainer Stacks → Add stack, benennen Sie den Stack (zum Beispiel quotenode) und wählen Sie Web editor. Der Stack-Name wird zum Compose-Projektnamen und zum Präfix der Volume-Namen; behalten Sie ihn bei späteren Aktualisierungen des Stacks bei. Fügen Sie den folgenden Block ein: die docker-compose.yml des Release v0.9.8-alpha (alle Images per Digest festgelegt) mit einer Portainer-Anpassung, die in den ersten Kommentarzeilen erklärt ist — env_file liest stack.env statt .env. Images, Dienste, Ports und Volumes bleiben unverändert.

# Portainer Web editor adaptation of the release docker-compose.yml: env_file reads stack.env (the stack's
# environment variables) instead of .env in postgres, backend and backup-worker. Images, services, ports and
# volumes are unchanged. For a folder-based installation use the unchanged file of the release package.
x-json-file-logging: &json-file-logging
  driver: json-file
  options:
    max-size: "${DOCKER_LOG_MAX_SIZE:-10m}"
    max-file: "${DOCKER_LOG_MAX_FILE:-5}"

# ==========================================================================
# QuoteNode — standalone deployment (docker compose)
# GENERATED by scripts/render-deployment.mjs — do not edit by hand.
# Release v0.9.8-alpha (commit ff74453ca68f3c40bd86a32f0b0b813aaffc7aea, build 28); platforms: linux/amd64.
# API image:      ghcr.io/lesisty7/quotenode/quotenode-api@sha256:0ed701f952c27ea0e1db168a00de75facb231f7c6d5f6b4f0f3b77ef98d4f140
# Frontend image: ghcr.io/lesisty7/quotenode/quotenode-frontend@sha256:40a1cdc4fca5cebd9f43d34daef4ba059a88000ff08af821e199566f07260606
#
# Settings: the .env file in this directory (start from env.example, the commented catalogue of every
# setting; postgres, backend and backup-worker read it). Start: docker compose up -d
# DOMAIN: public host name for automatic HTTPS, or :80 / http://<address> for plain HTTP on a trusted LAN.
# Only the caddy entry point publishes ports (HTTP_PORT/HTTPS_PORT, default 80/443).
# Upgrades: replace this file with the next release package version; volumes keep the data.
# ==========================================================================

services:
  postgres:
    # Database (PostgreSQL 18); internal network only, data in the database volume.
    image: postgres:18-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
    # JIT is off by default: for this OLTP workload most queries fall below jit_above_cost,
    # so JIT compilation rarely helps and can add latency. Set PG_JIT=on to re-enable.
    command: ["postgres", "-c", "jit=${PG_JIT:-off}"]
    env_file: stack.env
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_INITDB_ARGS: "--data-checksums"
      # postgres:18 refuses to start on a bare /var/lib/postgresql/data mount (new default layout).
      # Pin PGDATA to the legacy path so the existing volume mount keeps working; major upgrades go
      # through a logical dump/restore, never an in-place upgrade of the data directory.
      PGDATA: /var/lib/postgresql/data
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
      interval: 10s
      timeout: 5s
      retries: 10
    # CPU ceilings are opt-in (*_CPUS): 0 = no ceiling, all host cores. Docker refuses a ceiling above the
    # host's core count, so a fixed default would stop the stack on smaller hosts (minimum: 2 cores).
    cpus: ${POSTGRES_CPUS:-0}
    # Ceiling, not a reservation: PG sits at tens of MB until working set/connections grow.
    # Default 512m fits the 4 GB-VM profile; raise POSTGRES_MEM_LIMIT for larger tiers.
    mem_limit: ${POSTGRES_MEM_LIMIT:-512m}
    restart: unless-stopped
    logging: *json-file-logging
    # No published host port: the database is reachable only on the internal network.

  gotenberg:
    # PDF rendering (Chromium) for the backend; internal network only, keeps no data.
    image: gotenberg/gotenberg:8@sha256:aa552cc115db488e0f0bcb07446aac13071ad51505b642c84bb894500907b42b
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
    cpus: ${GOTENBERG_CPUS:-0}
    # Chromium PDF rendering: measured ~188 MiB peak rendering a real offer template, so
    # 448m keeps ~2.4x headroom. Freed budget goes to backup-worker (closer to its limit).
    mem_limit: ${GOTENBERG_MEM_LIMIT:-448m}
    restart: unless-stopped
    logging: *json-file-logging

  backend:
    # API server (Java): business logic and web jobs; uploads, PDFs and the GeoIP database live in volumes.
    image: ghcr.io/lesisty7/quotenode/quotenode-api@sha256:0ed701f952c27ea0e1db168a00de75facb231f7c6d5f6b4f0f3b77ef98d4f140
    # Time Docker gives the backend between SIGTERM and SIGKILL. 60 s = the SMTP handoff hard limit
    # (mail.handoff.deadline-ms + end-of-data-allowance-ms = 25 s) + 5 s to record its outcome + the HTTP
    # graceful drain phase (30 s). A ceiling, not a delay: an idle backend exits in seconds.
    stop_grace_period: 60s
    depends_on:
      postgres:
        condition: service_healthy
      gotenberg:
        condition: service_healthy
    env_file: stack.env
    environment:
      SPRING_PROFILES_ACTIVE: prod
      JOBS_MODE: web
      # The caddy edge below is the backend's only HTTP peer, so it is the trusted proxy by default (security
      # finding #80); without it every request would appear to come from the edge container. Override only
      # for a custom proxy chain.
      SECURITY_TRUSTED_PROXIES: ${SECURITY_TRUSTED_PROXIES:-caddy}
      BACKUP_LOCAL_DIR: /app/data/backups
    volumes:
      - backend_uploads:/app/data/uploads
      - backend_pdfs:/app/data/pdfs
      - backend_backups:/app/data/backups
      - backend_geoip:/app/data/geoip
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 45s
    cpus: ${BACKEND_CPUS:-0}
    # Heap auto-scales to 70% of this limit (MaxRAMPercentage); default 1536m → ~1 GB heap,
    # fits the 4 GB-VM profile. Raise BACKEND_MEM_LIMIT for heavier load (see resource docs).
    mem_limit: ${BACKEND_MEM_LIMIT:-1536m}
    restart: unless-stopped
    logging: *json-file-logging

  backup-worker:
    # Scheduled backups: same image as the backend, JOBS_MODE=backup-only; archives go to the backups volume.
    image: ghcr.io/lesisty7/quotenode/quotenode-api@sha256:0ed701f952c27ea0e1db168a00de75facb231f7c6d5f6b4f0f3b77ef98d4f140
    depends_on:
      postgres:
        condition: service_healthy
      backend:
        condition: service_healthy
    env_file: stack.env
    environment:
      SPRING_PROFILES_ACTIVE: prod
      JOBS_MODE: backup-only
      BACKUP_LOCAL_DIR: /app/data/backups
      # Worker runs pg_dump in a shell (out-of-JVM) and never does web/import heap work,
      # so it needs little heap. On this small 768m container the ~350 MB fixed non-heap
      # cost makes the global MaxRAMPercentage=70 (538 MB heap → 888 MB total) unsafe.
      # Cap heap lower so heap_max + non-heap stays well under the limit.
      JAVA_OPTS: "-XX:MaxRAMPercentage=45.0 -XX:+UseG1GC -XX:G1PeriodicGCInterval=300000"
    volumes:
      - backend_uploads:/app/data/uploads
      - backend_pdfs:/app/data/pdfs
      - backend_backups:/app/data/backups
      - backend_geoip:/app/data/geoip
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 15s
      timeout: 10s
      retries: 5
      # JVM boots a large Spring context (CPU-bound); the grace window must outlast
      # cold start to avoid a false-unhealthy restart loop under load.
      start_period: 300s
    # CPU is a ceiling (idle cost ~0); give the worker enough to boot fast and to run
    # pg_dump + age-encryption/compression bursts without throttling. Idles near zero CPU.
    cpus: ${WORKER_CPUS:-0}
    # Default 832m: measured backup peak (JVM idle + pg_dump + tar + gzip) is ~607 MiB, so
    # this keeps a ~27% margin. Do NOT set WORKER_MEM_LIMIT below ~768m or backups risk OOM.
    mem_limit: ${WORKER_MEM_LIMIT:-832m}
    restart: unless-stopped
    logging: *json-file-logging

  frontend:
    # Web UI: the static build served by Caddy, reached through the caddy entry point.
    image: ghcr.io/lesisty7/quotenode/quotenode-frontend@sha256:40a1cdc4fca5cebd9f43d34daef4ba059a88000ff08af821e199566f07260606
    depends_on:
      backend:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:80/"]
      interval: 15s
      timeout: 5s
      retries: 3
    cpus: ${FRONTEND_CPUS:-0}
    # Prod frontend is Caddy serving the static build (measured ~46 MiB peak under load); 96m is ample.
    # CPU is a free ceiling, so headroom for concurrent connections costs nothing at idle.
    mem_limit: ${FRONTEND_MEM_LIMIT:-96m}
    restart: unless-stopped
    logging: *json-file-logging

  caddy:
    # Entry point: the only published ports (HTTP_PORT/HTTPS_PORT, default 80/443), TLS for DOMAIN, routing, security headers.
    image: ghcr.io/lesisty7/quotenode/quotenode-frontend@sha256:40a1cdc4fca5cebd9f43d34daef4ba059a88000ff08af821e199566f07260606
    # The released frontend image carries the edge config (frontend/caddy/Caddyfile.edge) and the Cloudflare
    # ranges, so the edge needs no host files. Same image digest as the frontend service.
    command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile.edge", "--adapter", "caddyfile"]
    depends_on:
      backend:
        condition: service_healthy
      frontend:
        condition: service_healthy
    environment:
      # Site address for frontend/caddy/Caddyfile.edge: a public host name enables automatic HTTPS; ":80" or
      # "http://host" serves plain HTTP on a trusted LAN. Required: an empty site address is not a valid config.
      DOMAIN: ${DOMAIN:?set DOMAIN to the public host name, or :80 for plain HTTP on a LAN}
      # Optional: address(es) of an operator reverse proxy in front of this edge (default: trust none).
      EDGE_TRUSTED_PROXIES: ${EDGE_TRUSTED_PROXIES:-}
    ports:
      - "${HTTP_PORT:-80}:80"
      - "${HTTPS_PORT:-443}:443"
      - "${HTTPS_PORT:-443}:443/udp"      # HTTP/3 (QUIC)
    volumes:
      - caddy_data:/data
      - caddy_config:/config
    # caddy is the single ingress (TLS, HTTP/3/QUIC, proxying all traffic); give it
    # cores for handshake/encryption bursts under load. CPU idle cost ~0.
    cpus: ${CADDY_CPUS:-0}
    # 96m fits the 4 GB / light-traffic profile. Under heavy TLS load (many concurrent
    # handshakes, large QUIC buffers) raise CADDY_MEM_LIMIT — this ingress is untested at scale.
    mem_limit: ${CADDY_MEM_LIMIT:-96m}
    restart: unless-stopped
    logging: *json-file-logging

# Persistent data: database, uploads, generated PDFs, local backup archives, GeoIP database and the entry
# point's certificates/config. Docker prefixes each name with the Compose project name (by default the
# directory name), so keep the project name across upgrades.
volumes:
  postgres_data:
  backend_uploads:
  backend_pdfs:
  backend_backups:
  backend_geoip:
  caddy_data:
  caddy_config:

Wechseln Sie unter Environment variables in den Advanced mode und fügen Sie die Einstellungen aus Schritt 1 ein.

Fügen Sie die heruntergeladene Datei in Portainer weder ein, noch laden Sie sie hoch. Die unveränderte Release-Datei liest .env, die Portainer nicht anlegt; die Bereitstellung bricht mit env file … .env not found ab.

Ordnerbasierte Installation (SSH oder eine NAS-Oberfläche, die Compose aus einem von Ihnen verwalteten Ordner startet): Laden Sie das Release-Paket wie in der Linux-LAN-Anleitung herunter, lassen Sie dessen docker-compose.yml unverändert (Download) und speichern Sie die Einstellungen als .env im selben Ordner.

Bekanntes Problem in v0.9.5-alpha: Ihre env.example enthält eine aktive Beispielzeile, BACKUP_RCLONE_REMOTE=s3:mybucket/backups, durch die jedes Backup fehlschlägt. Die Einstellungen dieser Anleitung enthalten sie nicht; übernehmen Sie Einstellungen aus env.example, lassen Sie diese Zeile weg (Details).

DOMAIN legt fest, wie der Einstiegspunkt caddy QuoteNode ausliefert: Ein öffentlicher Hostname (crm.example.com) erhält automatisch ein HTTPS-Zertifikat; http://<Adresse> oder :80 liefert einfaches HTTP in einem vertrauenswürdigen Netz aus. Sind die Ports 80/443 belegt, setzen Sie HTTP_PORT / HTTPS_PORT in den Einstellungen. Hinter Ihrem eigenen Reverse Proxy setzen Sie DOMAIN=:80 und tragen die Proxy-Adresse in EDGE_TRUSTED_PROXIES ein.

Portkonflikte: Nutzt Ihr NAS bereits Port 80 oder 443, setzen Sie in den Einstellungen HTTP_PORT=8080 (und HTTPS_PORT) und verwenden diesen Port in CORS_ALLOWED_ORIGINS (z. B. http://nas.local:8080).

Schritt 3 — Bereitstellen und verifizieren

Klicken Sie in Portainer auf Deploy the stack und öffnen Sie dann die Logs des Containers backend. Bei einer ordnerbasierten Installation führen Sie aus:

docker compose up -d
docker compose logs -f backend

Warten Sie, bis Sie Started QuoteNodeApplication sehen.

Schritt 4 — QuoteNode öffnen

Navigieren Sie zu Ihrer NAS-URL (z. B. http://nas.local oder https://crm.example.com).

Standard-Anmeldedaten:

Feld Wert
Email [email protected]
Passwort Admin123!

Ändern Sie das Standardpasswort sofort nach dem Einloggen.

Persistenz-Checkliste

Stellen Sie sicher, dass diese Daten auf persistenten Volumes gemapped sind (nicht auf ephemeren Container-Speicher):

Daten Warum sie wichtig sind
PostgreSQL data Hauptanwendungsdatenbank
Uploads Produktbilder, Logos, Anhänge
Generated PDFs Angebots- und Dokumentdateien
Backups Lokale Backup-Archive (wenn Backup-Worker aktiviert ist)
GeoIP database Optionale Zugriffskontrolldaten

Was läuft gerade?

Container Aufgabe RAM-Verbrauch
postgres Speichert alle Ihre Daten ~256 MB
backend Java-API-Server, Geschäftslogik ~512 MB – 1,5 GB
gotenberg Konvertiert HTML zu PDF (Chromium-basiert) ~200 MB
frontend Liefert die Weboberfläche aus ~20 MB
backup-worker Führt geplante Sicherungen aus (gleiches Image wie das Backend) ~300 MB
caddy Einstiegspunkt: HTTPS-Zertifikate, Routing, Sicherheits-Header ~20 MB

Gesamt: ungefähr 1,5 – 2,5 GB RAM.

Secrets sicher aufbewahren

Secret Was es schützt Bei Verlust
DB_PASSWORD Datenbankzugang Wiederherstellbar — über PostgreSQL-Admintool zurücksetzen
DB_ENCRYPTION_KEY MFA-Codes, SMTP-Passwort, FX-API-Schlüssel, PII-Felder Dauerhaft unlesbar
TIMING_TOKEN_SECRET Anti-Timing-Angriff-Tokens Neu generieren — Benutzer authentifizieren sich neu
PUBLIC_LINK_PASSWORD_SESSION_SECRET Sitzungen für öffentliche Links Neu generieren — aktive Sitzungen laufen ab

Verwahren Sie eine Kopie der Einstellungen (.env oder die Umgebungsvariablen des Stacks) außerhalb des NAS. Ein NAS-Ausfall, der Ihre einzige Kopie von DB_ENCRYPTION_KEY zerstört, macht verschlüsselte Daten unwiederbringlich.

Empfohlene nächste Schritte

  • Konfigurieren Sie Backups an einem Ort außerhalb des NAS.
  • Testen Sie die PDF-Generierung nach der ersten Bereitstellung.
  • Wenn Sie einen reibungsloseren öffentlichen Bereitstellungspfad möchten, ziehen Sie Coolify auf einem VPS in Betracht.

Last reviewed: Recently