Przejdź do treści
Q
QuoteNode

Instalacja

Instalacja za pomocą Portainer, QNAP lub NAS

Wdrożenie QuoteNode na NAS lub hoście Docker zarządzanym przez Portainer z pełnymi instrukcjami konfiguracji.

Instalacja za pomocą Portainer, QNAP lub NAS

Użyj tej ścieżki, jeśli korzystasz z Docker przez Portainer, QNAP Container Station, Synology lub podobną platformę. Dokładny interfejs użytkownika różni się w zależności od dostawcy, ale podstawowy stos Docker Compose jest identyczny. Kroki dotyczą edytora Web editor w Portainerze; na platformie uruchamiającej Compose z folderu użyj niezmienionego pakietu wydania (krok 2).

Kiedy to zastosować

  • Już zarządzasz stosami Docker za pośrednictwem interfejsu do zarządzania kontenerami.
  • Rozumiesz, jak platforma mapuje woluminy i porty.
  • Potrafisz weryfikować kopie zapasowe poza interfejsem NAS.

Co może nie działać

  • Publiczne linki nie działają poza siecią LAN, chyba że skonfigurowano NAT, DNS, proxy i HTTPS.
  • Tryby uśpienia NAS mogą przerywać działanie usługi.
  • Niektóre interfejsy Docker w NAS modyfikują pliki compose lub zmienne środowiskowe; zweryfikuj wyrenderowany stos przed poleganiem na nim.
  • Kopie zapasowe przechowywane wyłącznie na tym samym NAS nie chronią przed awarią NAS.

Wymagania wstępne

  • Obsługa Docker Compose v2 na Twojej platformie.
  • Obsługa woluminów trwałych dla danych PostgreSQL, przesłanych plików, PDF-ów i kopii zapasowych.
  • Co najmniej 2 GB RAM dostępnego i 10 GB wolnego miejsca na dysku.
  • Stabilny adres URL: adres IP sieci LAN do użytku wewnętrznego lub publiczna domena za reverse proxy dla linków klientów.
  • Host 64-bitowy x86 (linux/amd64). Urządzenia ARM, w tym serwery NAS z procesorem ARM, nie są obsługiwane.

Krok 1 — Przygotuj ustawienia

Przygotuj poniższe ustawienia. Zastąp adres URL rzeczywistym adresem NAS lub publiczną domeną. W Portainerze stają się zmiennymi środowiskowymi stosu (krok 2), które Portainer zapisuje jako stack.env; w instalacji z folderu są plikiem .env obok docker-compose.yml:

W przypadku wdrożenia tylko wewnętrznego NAS:

# 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

Dla publicznej domeny za własnym reverse proxy (tu pod adresem 192.168.1.5), które kończy HTTPS:

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

Od razu zapisz kopię tych ustawień poza NAS. Jeśli utracisz DB_ENCRYPTION_KEY, zaszyfrowane pola staną się trwale nieczytelne.

Krok 2 — Utwórz stos

W Portainerze otwórz Stacks → Add stack, nadaj stosowi nazwę (np. quotenode) i wybierz Web editor. Nazwa stosu staje się nazwą projektu Compose i prefiksem nazw woluminów, więc zachowaj ją przy późniejszych aktualizacjach stosu. Wklej poniższy blok: docker-compose.yml wydania v0.9.8-alpha (wszystkie obrazy przypięte po digeście) z jedną adaptacją dla Portainera, opisaną w pierwszych liniach komentarza — env_file czyta stack.env zamiast .env. Obrazy, usługi, porty i woluminy pozostają bez zmian.

# 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:

W sekcji Environment variables przełącz na Advanced mode i wklej ustawienia z kroku 1.

Nie wklejaj ani nie przesyłaj w Portainerze pobranego pliku. Niezmieniony plik wydania czyta .env, którego Portainer nie tworzy, więc wdrożenie zatrzymuje się na błędzie env file … .env not found.

Instalacja z folderu (SSH albo interfejs NAS uruchamiający Compose z folderu, którym zarządzasz): pobierz pakiet wydania jak w przewodniku Linux LAN, zostaw jego docker-compose.yml bez zmian (pobierz) i zapisz ustawienia jako .env w tym samym folderze.

Znany problem w v0.9.5-alpha: jego env.example zawiera aktywną przykładową linię BACKUP_RCLONE_REMOTE=s3:mybucket/backups, przez którą każda kopia zapasowa kończy się błędem. Ustawienia z tego przewodnika jej nie zawierają; jeśli kopiujesz ustawienia z env.example, pomiń tę linię (szczegóły).

DOMAIN określa, jak punkt wejścia caddy udostępnia QuoteNode: publiczna nazwa hosta (crm.example.com) dostaje automatyczny certyfikat HTTPS; http://<adres> albo :80 udostępnia zwykłe HTTP w zaufanej sieci. Jeśli porty 80/443 są zajęte, ustaw HTTP_PORT / HTTPS_PORT w ustawieniach. Za własnym reverse proxy ustaw DOMAIN=:80 i wpisz adres proxy w EDGE_TRUSTED_PROXIES.

Konflikt portów: Jeśli NAS używa już portu 80 lub 443, dodaj do ustawień HTTP_PORT=8080 (i HTTPS_PORT), a w CORS_ALLOWED_ORIGINS podaj ten port (np. http://nas.local:8080).

Krok 3 — Wdróż i zweryfikuj

W Portainerze kliknij Deploy the stack, a następnie otwórz logi kontenera backend. W instalacji z folderu uruchom:

docker compose up -d
docker compose logs -f backend

Poczekaj, aż zobaczysz Started QuoteNodeApplication.

Krok 4 — Otwórz QuoteNode

Przejdź do adresu URL Twojego NAS (np. http://nas.local lub https://crm.example.com).

Domyślne dane logowania:

Pole Wartość
Email [email protected]
Hasło Admin123!

Zmień domyślne hasło natychmiast po zalogowaniu.

Lista kontrolna trwałości danych

Sprawdź, czy poniższe dane są mapowane na woluminy trwałe (nie na tymczasowy storage kontenera):

Dane Dlaczego to ważne
PostgreSQL data Główna baza danych aplikacji
Uploads Zdjęcia produktów, loga, załączniki
Generated PDFs Pliki ofert i dokumentów
Backups Lokalne archiwa kopii zapasowych (jeśli worker kopii zapasowych jest włączony)
GeoIP database Opcjonalne dane kontroli dostępu

Co działa w tle?

Kontener Co robi Zużycie RAM
postgres Przechowuje wszystkie dane ~256 MB
backend Serwer API Java, logika biznesowa ~512 MB – 1,5 GB
gotenberg Konwertuje HTML do PDF (oparty na Chromium) ~200 MB
frontend Udostępnia interfejs WWW ~20 MB
backup-worker Wykonuje zaplanowane kopie zapasowe (ten sam obraz co backend) ~300 MB
caddy Punkt wejścia: certyfikaty HTTPS, routing, nagłówki bezpieczeństwa ~20 MB

Łącznie: około 1,5 – 2,5 GB RAM.

Bezpieczeństwo sekretów

Sekret Co chroni W przypadku utraty
DB_PASSWORD Dostęp do bazy danych Możliwy do odzyskania — zresetuj przez narzędzia admin PostgreSQL
DB_ENCRYPTION_KEY Kody MFA, hasło SMTP, klucz FX API, pola PII Trwale nieczytelne
TIMING_TOKEN_SECRET Tokeny anty-timing-attack Zregeneruj — użytkownicy ponownie się uwierzytelniają
PUBLIC_LINK_PASSWORD_SESSION_SECRET Sesje publicznych linków Zregeneruj — aktywne sesje wygasają

Przechowuj kopię ustawień (.env lub zmiennych środowiskowych stosu) poza NAS. Awaria NAS niszcząca jedyną kopię DB_ENCRYPTION_KEY powoduje trwałą utratę zaszyfrowanych danych.

Zalecane kolejne kroki

  • Skonfiguruj kopie zapasowe w lokalizacji poza NAS.
  • Przetestuj generowanie PDF po pierwszym wdrożeniu.
  • Jeśli chcesz płynniejszą ścieżkę wdrożenia publicznego, rozważ Coolify na VPS.

Last reviewed: Recently