Installation
Installer sur un serveur public Ubuntu
Déployer QuoteNode sur un serveur Ubuntu public avec un domaine, HTTPS, SMTP, sauvegardes et bases de pare-feu.
Installer sur un serveur public Ubuntu
Utilisez ce chemin pour un déploiement en production accessible par les clients via un domaine public.
Quand l’utiliser
- Les clients externes doivent ouvrir les liens publics d’offre.
- Les messages email doivent contenir des liens fonctionnels.
- Vous voulez un contrôle direct du serveur plutôt qu’une couche PaaS.
Ce qui ne fonctionnera pas sans configuration correcte
- Les liens publics échouent si le DNS ne pointe pas vers le serveur.
- Les liens publics échouent si l’URL du navigateur diffère de la configuration CORS.
- L’émission du certificat HTTPS échoue si les ports 80/443 sont bloqués.
- Les liens email échouent si SMTP est manquant ou si les messages sont rejetés par le fournisseur mail du destinataire.
- Les déploiements avec IP domestique dynamique ont besoin de DDNS et de redirection de ports routeur ; un VPS est généralement plus simple.
Prérequis
- Serveur Ubuntu LTS ou VPS.
- Enregistrement DNS public, par exemple
crm.example.com, pointant vers le serveur. - Ports 80 et 443 ouverts depuis internet.
- Docker Engine 24.0+ et Docker Compose v2.
- Compte SMTP pour la livraison d’emails.
- Au moins 4 Go de RAM et 20 Go d’espace disque libre (recommandés en production).
- Destination de sauvegarde et une procédure de restauration.
- Un hôte x86 64 bits (linux/amd64). Les appareils ARM, y compris les NAS à processeur ARM, ne sont pas pris en charge.
Vérifiez Docker :
docker --version # Should show 24.0+
docker compose version # Should show v2.x
Étape 1 — Créer un répertoire de projet
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
Vous téléchargez ainsi le paquet de déploiement de la version v0.9.8-alpha : docker-compose.yml avec chaque image épinglée par digest, env.example, release.json (l’identité de la version) et operator/ (outils de sauvegarde et de restauration).
Problème connu dans v0.9.5-alpha : son
env.examplecontient une ligne d’exemple active,BACKUP_RCLONE_REMOTE=s3:mybucket/backups, qui fait échouer chaque sauvegarde. Les réglages de ce guide ne la contiennent pas ; si vous copiez des réglages depuisenv.example, omettez cette ligne (détails).
Étape 2 — Créer le fichier de configuration
Créez un fichier nommé .env. Remplacez crm.example.com par votre vrai domaine :
# 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=
Sauvegardez votre fichier
.envavant le premier usage en production. Conservez-le dans un gestionnaire de mots de passe ou un coffre-fort physique. LeDB_ENCRYPTION_KEYest critique — sa perte signifie que les données chiffrées sont perdues à jamais.
Alternative SMTP : Vous pouvez omettre les variables SMTP_* de .env et configurer l’email plus tard dans Paramètres > SMTP email dans QuoteNode. Le panneau admin stocke le mot de passe SMTP chiffré avec DB_ENCRYPTION_KEY, le tenant hors de votre fichier .env.
Étape 3 — Vérifier le fichier Docker Compose
Le fichier docker-compose.yml du paquet exécute la version v0.9.8-alpha avec chaque image épinglée par digest. Ne le modifiez pas : tous les réglages vont dans .env, et une mise à jour remplace ce fichier par celui de la version suivante. Pour référence :
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 détermine comment le point d’entrée caddy sert QuoteNode : un nom d’hôte public (crm.example.com) obtient automatiquement un certificat HTTPS ; http://<adresse> ou :80 sert du HTTP simple sur un réseau de confiance. Si les ports 80/443 sont déjà utilisés, définissez HTTP_PORT / HTTPS_PORT dans .env. Derrière votre propre reverse proxy, définissez DOMAIN=:80 et indiquez l’adresse du proxy dans EDGE_TRUSTED_PROXIES.
Étape 4 — Configurer le pare-feu
Ouvrez uniquement ce qui est nécessaire :
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
N’exposez pas PostgreSQL publiquement. Il reste sur le réseau Docker.
Étape 5 — Démarrer QuoteNode
docker compose up -d
Suivez le démarrage :
docker compose logs -f backend
Attendez le message Started QuoteNodeApplication. Le conteneur du point d’entrée (caddy) obtient automatiquement un certificat Let’s Encrypt pour le nom d’hôte de DOMAIN.
Étape 6 — Ouvrir QuoteNode
Naviguez vers https://crm.example.com dans votre navigateur.
Identifiants de connexion par défaut :
| Champ | Valeur |
|---|---|
[email protected] |
|
| Mot de passe | Admin123! |
Changez le mot de passe par défaut immédiatement après la connexion.
Vérifications post-déploiement
- Se connecter et changer le mot de passe admin.
- Créer une offre de test.
- Générer un PDF.
- Créer et ouvrir un lien public depuis un autre appareil.
- Si SMTP est configuré, envoyer une notification de test et vérifier que les liens email fonctionnent.
Qu’est-ce qui tourne ?
| Conteneur | Rôle | Utilisation RAM |
|---|---|---|
postgres |
Stocke toutes vos données | ~256 Mo |
backend |
Serveur API Java, logique métier | ~512 Mo – 1,5 Go |
gotenberg |
Convertit HTML en PDF (basé sur Chromium) | ~200 Mo |
frontend |
Sert l’interface web | ~20 Mo |
backup-worker |
Exécute les sauvegardes planifiées (même image que le backend) | ~300 MB |
caddy |
Point d’entrée : certificats HTTPS, routage, en-têtes de sécurité | ~20 MB |
Total : environ 1,5 – 2,5 Go de RAM.
Commandes courantes
# Check service status
docker compose ps
# View logs
docker compose logs -f backend
# Stop QuoteNode
docker compose down
# Mise à jour : remplacer docker-compose.yml par celui du paquet de la version suivante (voir le guide de mise à jour)
Checklist de mise à niveau
Avant chaque mise à niveau :
- Confirmer la version d’application courante dans la zone admin.
- Sauvegarder
.env. - Exécuter ou vérifier une sauvegarde fraîche de la base de données.
- Tirer les nouvelles images et redémarrer.
- Vérifier le health backend, la connexion, la génération PDF et un lien public d’offre.
Sécuriser vos secrets
| Secret | Ce qu’il protège | En cas de perte |
|---|---|---|
DB_PASSWORD |
Accès à la base de données | Récupérable — réinitialiser via les outils admin PostgreSQL |
DB_ENCRYPTION_KEY |
Codes MFA, mot de passe SMTP, clé API FX, champs PII | Définitivement illisible |
TIMING_TOKEN_SECRET |
Tokens anti-timing-attack | Régénérer — les utilisateurs se ré-authentifient |
PUBLIC_LINK_PASSWORD_SESSION_SECRET |
Sessions de liens publics | Régénérer — les sessions actives expirent |
Stockez
.envdans un gestionnaire de mots de passe (1Password, Bitwarden). Pour une sécurité maximale, imprimez les secrets et conservez l’impression dans un coffre-fort physique. Ne commitez jamais.envdans le contrôle de version.
Récupération si .env est perdu
Si vous perdez votre fichier .env mais avez une sauvegarde de base de données non chiffrée (le mode par défaut) :
- Créez un nouveau
.envavec de nouveaux secrets. - Restaurez la base de données depuis votre sauvegarde.
- Toutes les données métier seront accessibles.
- Perdus : codes MFA (les utilisateurs se ré-inscrivent), mot de passe SMTP (à resaisir), clé API FX (à resaisir). Si
ENCRYPT_PII=trueétait activé, les données clients chiffrées sont également perdues.