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 conenv 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.examplecontiene 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 deenv.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(yHTTPS_PORT) en los ajustes y usa ese puerto enCORS_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 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 (
.envo las variables de entorno del stack) fuera del NAS. Un fallo del NAS que destruya tu única copia deDB_ENCRYPTION_KEYhace 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.