Installation
Instalar en un servidor público Ubuntu
Despliega QuoteNode en un servidor Ubuntu público con dominio, HTTPS, SMTP, backups y firewall básico.
Instalar en un servidor público Ubuntu
Usa esta ruta para un despliegue en producción accesible por los clientes a través de un dominio público.
Cuándo usarlo
- Los clientes externos deben abrir enlaces públicos de oferta.
- Los mensajes de correo deben contener enlaces funcionales.
- Quieres control directo sobre el servidor en lugar de una capa PaaS.
Qué no funcionará sin la configuración correcta
- Los enlaces públicos fallan si DNS no apunta al servidor.
- Los enlaces públicos fallan si la URL del navegador difiere de la configuración CORS.
- La emisión del certificado HTTPS falla si los puertos 80/443 están bloqueados.
- Los enlaces de correo fallan si SMTP falta o si los mensajes son rechazados por el proveedor de correo del destinatario.
- Los despliegues con IP doméstica dinámica necesitan DDNS y reenvío de puertos del router; un VPS suele ser más sencillo.
Requisitos previos
- Servidor Ubuntu LTS o VPS.
- Registro DNS público, por ejemplo
crm.example.com, apuntando al servidor. - Puertos 80 y 443 abiertos desde internet.
- Docker Engine 24.0+ y Docker Compose v2.
- Cuenta SMTP para la entrega de correo.
- Al menos 4 GB de RAM y 20 GB de espacio libre en disco (recomendados para producción).
- Destino de backup y un procedimiento de restauración.
- Un host x86 de 64 bits (linux/amd64). Los dispositivos ARM, incluidos los NAS con procesador ARM, no son compatibles.
Verifica Docker:
docker --version # Should show 24.0+
docker compose version # Should show v2.x
Paso 1 — Crear un directorio de proyecto
mkdir quotenode && cd quotenode
curl -fsSLO https://quotenode.dev/releases/v0.9.8-alpha/quotenode-deploy-v0.9.8-alpha.tar.gz
tar -xzf quotenode-deploy-v0.9.8-alpha.tar.gz --strip-components=1
Así descargas el paquete de despliegue de la versión v0.9.8-alpha: docker-compose.yml con cada imagen fijada por digest, env.example, release.json (la identidad de la versión) y operator/ (herramientas de copia de seguridad y restauración).
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).
Paso 2 — Crear el archivo de configuración
Crea un archivo llamado .env. Reemplaza crm.example.com con tu dominio real:
# 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
# Public domain — replace with your real domain
CORS_ALLOWED_ORIGINS=https://crm.example.com
DOMAIN=crm.example.com
# Email delivery
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME[email protected]
SMTP_PASSWORD=your-smtp-password
SMTP_AUTH=true
SMTP_STARTTLS=true
# Notification preference links (must match your public domain)
NOTIFICATIONS_PUBLIC_PREFERENCES_URL=https://crm.example.com/notifications/preferences
# Application
LOG_LEVEL=INFO
SPRING_PROFILES_ACTIVE=prod
# 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=
Haz backup de tu archivo
.envantes del primer uso en producción. Guárdalo en un gestor de contraseñas o en una caja fuerte física. ElDB_ENCRYPTION_KEYes crítico — perderlo significa que los datos cifrados desaparecen para siempre.
Alternativa SMTP: Puedes omitir las variables SMTP_* del .env y configurar el correo más tarde en Configuración > SMTP de correo dentro de QuoteNode. El panel de administración almacena la contraseña SMTP cifrada con DB_ENCRYPTION_KEY, manteniéndola fuera de tu archivo .env.
Paso 3 — Revisar el archivo Docker Compose
El docker-compose.yml del paquete ejecuta la versión v0.9.8-alpha con cada imagen fijada por digest. No lo edites: todos los ajustes van en .env, y una actualización sustituye este archivo por el de la versión siguiente. Como referencia:
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: .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: .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: .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:
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 .env. Detrás de tu propio proxy inverso, define DOMAIN=:80 e indica la dirección del proxy en EDGE_TRUSTED_PROXIES.
Paso 4 — Configurar el firewall
Abre solo lo necesario:
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
No expongas PostgreSQL públicamente. Se queda en la red Docker.
Paso 5 — Iniciar QuoteNode
docker compose up -d
Observa el inicio:
docker compose logs -f backend
Espera a ver Started QuoteNodeApplication. El contenedor del punto de entrada (caddy) obtiene automáticamente un certificado de Let’s Encrypt para el nombre de host de DOMAIN.
Paso 6 — Abrir QuoteNode
Navega a https://crm.example.com en tu navegador.
Credenciales de acceso por defecto:
| Campo | Valor |
|---|---|
[email protected] |
|
| Contraseña | Admin123! |
Cambia la contraseña por defecto inmediatamente tras iniciar sesión.
Comprobaciones post-despliegue
- Iniciar sesión y cambiar la contraseña admin.
- Crear una oferta de prueba.
- Generar un PDF.
- Crear y abrir un enlace público desde otro dispositivo.
- Si SMTP está configurado, enviar una notificación de prueba y verificar que los enlaces del correo funcionan.
¿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.
Comandos habituales
# Check service status
docker compose ps
# View logs
docker compose logs -f backend
# Stop QuoteNode
docker compose down
# Actualización: sustituye docker-compose.yml por el del siguiente paquete de versión (consulta la guía de actualización)
Lista de verificación de actualización
Antes de cada actualización:
- Confirmar la versión actual de la aplicación en el área de administración.
- Hacer backup de
.env. - Ejecutar o verificar un backup fresco de la base de datos.
- Descargar las nuevas imágenes y reiniciar.
- Comprobar la salud del backend, inicio de sesión, generación de PDF y un enlace público de oferta.
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
.enven un gestor de contraseñas (1Password, Bitwarden). Para máxima seguridad, imprime los secretos y guarda la copia impresa en una caja fuerte física. Nunca hagas commit de.enven el control de versiones.
Recuperación si se pierde .env
Si pierdes tu archivo .env pero tienes una copia de seguridad de base de datos no cifrada (el modo por defecto):
- Crea un nuevo
.envcon nuevos secretos. - Restaura la base de datos desde tu copia de seguridad.
- Todos los datos de negocio serán accesibles.
- Perdidos: códigos MFA (los usuarios se vuelven a registrar), contraseña SMTP (volver a introducir), clave API FX (volver a introducir). Si
ENCRYPT_PII=trueestaba habilitado, los datos de clientes cifrados también se pierden.