Installation
Installare su un server pubblico Ubuntu
Distribuisci QuoteNode su un server Ubuntu pubblico con dominio, HTTPS, SMTP, backup e firewall di base.
Installare su un server pubblico Ubuntu
Usa questo percorso per un deployment in produzione raggiungibile dai clienti tramite un dominio pubblico.
Quando usarlo
- I clienti esterni devono aprire i link pubblici delle offerte.
- I messaggi email dovrebbero contenere link funzionanti.
- Vuoi il controllo diretto sul server invece di un layer PaaS.
Cosa non funzionerà senza la corretta configurazione
- I link pubblici falliscono se il DNS non punta al server.
- I link pubblici falliscono se l’URL del browser differisce dalla configurazione CORS.
- L’emissione del certificato HTTPS fallisce se le porte 80/443 sono bloccate.
- I link email falliscono se SMTP manca o se i messaggi sono rifiutati dal provider di posta del destinatario.
- I deployment con IP domestico dinamico necessitano DDNS e port forwarding del router; un VPS è solitamente più semplice.
Prerequisiti
- Server Ubuntu LTS o VPS.
- Record DNS pubblico, ad esempio
crm.example.com, che punta al server. - Porte 80 e 443 aperte da internet.
- Docker Engine 24.0+ e Docker Compose v2.
- Account SMTP per la consegna email.
- Almeno 4 GB di RAM e 20 GB di spazio libero su disco (raccomandati per la produzione).
- Destinazione backup e una procedura di restore.
- Un host x86 a 64 bit (linux/amd64). I dispositivi ARM, compresi i NAS con processore ARM, non sono supportati.
Controlla Docker:
docker --version # Should show 24.0+
docker compose version # Should show v2.x
Passo 1 — Creare una directory di progetto
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
Così scarichi il pacchetto di distribuzione della release v0.9.8-alpha: docker-compose.yml con ogni immagine fissata tramite digest, env.example, release.json (l’identità della release) e operator/ (strumenti di backup e ripristino).
Problema noto in v0.9.5-alpha: il suo
env.examplecontiene una riga di esempio attiva,BACKUP_RCLONE_REMOTE=s3:mybucket/backups, che fa fallire ogni backup. Le impostazioni di questa guida non la contengono; se copi impostazioni daenv.example, ometti quella riga (dettagli).
Passo 2 — Creare il file di configurazione
Crea un file chiamato .env. Sostituisci crm.example.com con il tuo dominio reale:
# 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=
Esegui il backup del tuo file
.envprima del primo uso in produzione. Conservalo in un password manager o in una cassaforte fisica. IlDB_ENCRYPTION_KEYè critico — perderlo significa che i dati cifrati sono persi per sempre.
Alternativa SMTP: Puoi omettere le variabili SMTP_* da .env e configurare l’email in seguito in Impostazioni > SMTP email dentro QuoteNode. Il pannello admin conserva la password SMTP cifrata con DB_ENCRYPTION_KEY, mantenendola fuori dal file .env.
Passo 3 — Verifica il file Docker Compose
Il docker-compose.yml del pacchetto esegue la release v0.9.8-alpha con ogni immagine fissata tramite digest. Non modificarlo: tutte le impostazioni vanno in .env, e un aggiornamento sostituisce questo file con quello della release successiva. Per riferimento:
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 stabilisce come il punto di ingresso caddy serve QuoteNode: un nome host pubblico (crm.example.com) ottiene automaticamente un certificato HTTPS; http://<indirizzo> o :80 serve HTTP semplice in una rete fidata. Se le porte 80/443 sono già occupate, imposta HTTP_PORT / HTTPS_PORT in .env. Dietro il tuo reverse proxy, imposta DOMAIN=:80 e indica l’indirizzo del proxy in EDGE_TRUSTED_PROXIES.
Passo 4 — Configurare il firewall
Apri solo ciò che serve:
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Non esporre PostgreSQL pubblicamente. Resta sulla rete Docker.
Passo 5 — Avviare QuoteNode
docker compose up -d
Monitora l’avvio:
docker compose logs -f backend
Attendi il messaggio Started QuoteNodeApplication. Il container del punto di ingresso (caddy) ottiene automaticamente un certificato Let’s Encrypt per il nome host in DOMAIN.
Passo 6 — Aprire QuoteNode
Naviga su https://crm.example.com nel tuo browser.
Credenziali di accesso predefinite:
| Campo | Valore |
|---|---|
[email protected] |
|
| Password | Admin123! |
Cambia la password predefinita immediatamente dopo aver effettuato l’accesso.
Verifiche post-deployment
- Accedere e cambiare la password admin.
- Creare un’offerta di test.
- Generare un PDF.
- Creare e aprire un link pubblico da un altro dispositivo.
- Se SMTP è configurato, inviare una notifica di test e verificare che i link email funzionino.
Cosa è in esecuzione?
| Container | Cosa fa | Utilizzo RAM |
|---|---|---|
postgres |
Archivia tutti i tuoi dati | ~256 MB |
backend |
Server API Java, logica di business | ~512 MB – 1,5 GB |
gotenberg |
Converte HTML in PDF (basato su Chromium) | ~200 MB |
frontend |
Serve l’interfaccia web | ~20 MB |
backup-worker |
Esegue i backup pianificati (stessa immagine del backend) | ~300 MB |
caddy |
Punto di ingresso: certificati HTTPS, instradamento, header di sicurezza | ~20 MB |
Totale: circa 1,5 – 2,5 GB di RAM.
Comandi comuni
# Check service status
docker compose ps
# View logs
docker compose logs -f backend
# Stop QuoteNode
docker compose down
# Aggiornamento: sostituisci docker-compose.yml con quello del pacchetto della release successiva (vedi la guida all'aggiornamento)
Checklist di aggiornamento
Prima di ogni aggiornamento:
- Conferma la versione app attuale nell’area admin.
- Esegui il backup di
.env. - Esegui o verifica un backup fresco del database.
- Esegui il pull delle nuove immagini e riavvia.
- Controlla health backend, login, generazione PDF e un link offerta pubblico.
Mantenere al sicuro i tuoi segreti
| Segreto | Cosa protegge | Se perso |
|---|---|---|
DB_PASSWORD |
Accesso al database | Recuperabile — reimposta tramite strumenti di amministrazione PostgreSQL |
DB_ENCRYPTION_KEY |
Codici MFA, password SMTP, chiave API FX, campi PII | Permanentemente illeggibile |
TIMING_TOKEN_SECRET |
Token anti-timing-attack | Rigenera — gli utenti si ri-autenticano |
PUBLIC_LINK_PASSWORD_SESSION_SECRET |
Sessioni dei link pubblici | Rigenera — le sessioni attive scadono |
Conserva
.envin un password manager (1Password, Bitwarden). Per massima sicurezza, stampa i segreti e conserva la stampa in una cassaforte fisica. Non committare mai.envnel controllo versione.
Ripristino se .env è perso
Se perdi il tuo file .env ma hai un backup del database non cifrato (il modo predefinito):
- Crea un nuovo
.envcon nuovi segreti. - Ripristina il database dal tuo backup.
- Tutti i dati di business saranno accessibili.
- Persi: codici MFA (gli utenti si re-iscrivono), password SMTP (reinserire), chiave API FX (reinserire). Se
ENCRYPT_PII=trueera abilitato, anche i dati cliente cifrati vengono persi.