Ir al contenido
Q
QuoteNode

Installation

Instalar con Portainer, QNAP o NAS

Despliega QuoteNode en un NAS o en un host Docker gestionado por Portainer con instrucciones de configuración completas.

Instalar con Portainer, QNAP o NAS

Usa esta ruta si ejecutas Docker a través de Portainer, QNAP Container Station, Synology o una plataforma similar. La interfaz exacta difiere según el fabricante, pero el stack Docker Compose subyacente es el mismo. Los pasos describen el Web editor de Portainer; una plataforma que ejecuta Compose desde una carpeta usa en su lugar el paquete de la versión sin cambios (paso 2).

Cuándo usarlo

  • Ya operas stacks Docker a través de una interfaz de gestión de contenedores.
  • Entiendes cómo la plataforma mapea volúmenes y puertos.
  • Puedes verificar backups fuera de la interfaz del NAS.

Qué puede no funcionar

  • Los enlaces públicos no funcionan fuera de la LAN a menos que NAT, DNS, proxy y HTTPS estén configurados.
  • Los modos de suspensión del NAS pueden interrumpir el servicio.
  • Algunas UIs Docker de NAS reescriben archivos compose o variables de entorno; verifica el stack renderizado antes de confiar en él.
  • Los backups almacenados solo en el mismo NAS no protegen contra un fallo del NAS.

Requisitos previos

  • Soporte de Docker Compose v2 en tu plataforma.
  • Soporte de volúmenes persistentes para datos PostgreSQL, subidas, PDFs y backups.
  • Al menos 2 GB de RAM disponibles y 10 GB de espacio libre en disco.
  • Una URL estable: IP de red local para uso interno, o dominio público detrás de un proxy inverso para enlaces de clientes.
  • Un host x86 de 64 bits (linux/amd64). Los dispositivos ARM, incluidos los NAS con procesador ARM, no son compatibles.

Paso 1 — Preparar los ajustes

Prepara los ajustes siguientes. Reemplaza la URL con tu dirección NAS real o dominio público. En Portainer se convierten en las variables de entorno del stack (paso 2), que Portainer guarda como stack.env; en una instalación basada en una carpeta son el archivo .env junto a docker-compose.yml:

Para un despliegue solo NAS interno:

# 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

Para un dominio público detrás de tu propio proxy inverso (aquí en 192.168.1.5) que termina HTTPS:

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

Guarda una copia de estos ajustes inmediatamente, fuera del NAS. Si pierdes DB_ENCRYPTION_KEY, los campos cifrados quedan permanentemente ilegibles.

Paso 2 — Crear el stack

En Portainer, abre Stacks → Add stack, ponle un nombre al stack (por ejemplo quotenode) y elige Web editor. El nombre del stack se convierte en el nombre del proyecto Compose y en el prefijo de los nombres de los volúmenes; mantenlo cuando actualices el stack más adelante. Pega el bloque siguiente: el docker-compose.yml de la versión v0.9.8-alpha (cada imagen fijada por digest) con una adaptación para Portainer, explicada en sus primeras líneas de comentario: env_file lee stack.env en lugar de .env. Imágenes, servicios, puertos y volúmenes no cambian.

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

En Environment variables, cambia a Advanced mode y pega los ajustes del paso 1.

No pegues ni subas el archivo descargado en Portainer. El archivo de la versión sin cambios lee .env, que Portainer no crea, y el despliegue se detiene con env file … .env not found.

Instalación basada en una carpeta (SSH, o una interfaz del NAS que ejecuta Compose desde una carpeta que gestionas): descarga el paquete de la versión como en la guía Linux LAN, deja su docker-compose.yml sin cambios (descargar) y guarda los ajustes como .env en la misma carpeta.

Problema conocido en v0.9.5-alpha: su env.example contiene una línea de ejemplo activa, BACKUP_RCLONE_REMOTE=s3:mybucket/backups, que hace fallar todas las copias de seguridad. Los ajustes de esta guía no la contienen; si copias ajustes de env.example, omite esa línea (detalles).

DOMAIN determina cómo el punto de entrada caddy sirve QuoteNode: un nombre de host público (crm.example.com) obtiene automáticamente un certificado HTTPS; http://<dirección> o :80 sirve HTTP simple en una red de confianza. Si los puertos 80/443 ya están en uso, define HTTP_PORT / HTTPS_PORT en los ajustes. Detrás de tu propio proxy inverso, define DOMAIN=:80 e indica la dirección del proxy en EDGE_TRUSTED_PROXIES.

Conflictos de puertos: si tu NAS ya usa el puerto 80 o 443, define HTTP_PORT=8080 (y HTTPS_PORT) en los ajustes y usa ese puerto en CORS_ALLOWED_ORIGINS (p. ej. http://nas.local:8080).

Paso 3 — Desplegar y verificar

En Portainer, haz clic en Deploy the stack y abre los registros del contenedor backend. En una instalación basada en una carpeta, ejecuta:

docker compose up -d
docker compose logs -f backend

Espera hasta ver Started QuoteNodeApplication.

Paso 4 — Abrir QuoteNode

Navega a tu URL del NAS (ej. http://nas.local o https://crm.example.com).

Credenciales de acceso por defecto:

Campo Valor
Email [email protected]
Contraseña Admin123!

Cambia la contraseña por defecto inmediatamente tras iniciar sesión.

Lista de verificación de persistencia

Verifica que estos elementos estén mapeados a volúmenes persistentes (no a almacenamiento efímero del contenedor):

Dato Por qué importa
PostgreSQL data Base de datos principal de la aplicación
Uploads Imágenes de producto, logos, adjuntos
Generated PDFs Archivos de ofertas y documentos
Backups Archivos de backup locales (si el worker de backup está habilitado)
GeoIP database Datos opcionales de control de acceso

¿Qué está ejecutándose?

Contenedor Qué hace Uso de RAM
postgres Almacena todos tus datos ~256 MB
backend Servidor API Java, lógica de negocio ~512 MB – 1,5 GB
gotenberg Convierte HTML a PDF (basado en Chromium) ~200 MB
frontend Sirve la interfaz web ~20 MB
backup-worker Ejecuta las copias de seguridad programadas (misma imagen que el backend) ~300 MB
caddy Punto de entrada: certificados HTTPS, enrutamiento, cabeceras de seguridad ~20 MB

Total: aproximadamente 1,5 – 2,5 GB de RAM.

Mantener seguros tus secretos

Secreto Qué protege Si se pierde
DB_PASSWORD Acceso a la base de datos Recuperable — reiniciar vía herramientas de administración PostgreSQL
DB_ENCRYPTION_KEY Códigos MFA, contraseña SMTP, clave API FX, campos PII Permanentemente ilegible
TIMING_TOKEN_SECRET Tokens anti-timing-attack Regenerar — los usuarios se re-autentican
PUBLIC_LINK_PASSWORD_SESSION_SECRET Sesiones de enlaces públicos Regenerar — las sesiones activas expiran

Guarda una copia de los ajustes (.env o las variables de entorno del stack) fuera del NAS. Un fallo del NAS que destruya tu única copia de DB_ENCRYPTION_KEY hace los datos cifrados irrecuperables.

Siguientes pasos recomendados

  • Configura backups en una ubicación fuera del NAS.
  • Prueba la generación de PDF después del primer despliegue.
  • Si quieres una ruta de despliegue público más sencilla, considera Coolify en un VPS.

Last reviewed: Recently