Aller au contenu
Q
QuoteNode

Installation

Installer avec Coolify

Déployer une version de QuoteNode dans Coolify à partir de son paquet de déploiement : images préconstruites épinglées par digest, rien n’est construit sur votre serveur.

Installation avec Coolify

Coolify fournit un HTTPS géré, des contrôles de santé et une interface web pour les variables d’environnement. QuoteNode s’exécute dans Coolify à partir d’un paquet de version : une définition Compose qui récupère les images testées de la version par digest. Rien n’est compilé sur votre serveur, et chaque déploiement du même paquet exécute exactement les mêmes images.

Version actuelle : v0.9.8-alpha — images pour serveurs x86 64 bits (linux/amd64).

Étape 1 — Créer la ressource

  1. Dans Coolify, choisissez New Resource → Docker Compose Empty.
  2. Collez sans modification la définition Compose de la version pour Coolify. Elle est aussi disponible sous forme de fichier docker-compose.coolify.yml :
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 — Coolify deployment (Docker Compose Empty resource)
# 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
#
# Paste into a Coolify "Docker Compose Empty" resource; set the domain on the frontend service and the
# variables in the Coolify UI (runtime only, never build arguments). Coolify routes the domain to frontend.
# env.example of this package describes every setting; enter only the values you need.
# Upgrades: replace the compose definition with the next release package version and redeploy.
# ==========================================================================

services:
  postgres:
    # Database (PostgreSQL 18); internal network only, data in the database volume.
    image: postgres:18-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
    # JIT off by default (small OLTP workload); set PG_JIT=on to re-enable.
    command: ["postgres", "-c", "jit=${PG_JIT:-off}"]
    environment:
      POSTGRES_DB: ${DB_NAME:-quotenode}
      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_prod_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME:-quotenode}"]
      interval: 10s
      timeout: 5s
      retries: 10
    restart: unless-stopped
    logging: *json-file-logging

  gotenberg:
    # PDF rendering (Chromium) for the backend; internal network only, keeps no data.
    image: gotenberg/gotenberg:8@sha256:aa552cc115db488e0f0bcb07446aac13071ad51505b642c84bb894500907b42b
    environment:
      # Gotenberg requires lowercase log levels (error/warn/info/debug).
      # Explicit override prevents Coolify-injected LOG_LEVEL (uppercase) from crashing it.
      LOG_LEVEL: info
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 5
    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
    environment:
      SPRING_PROFILES_ACTIVE: prod
      JOBS_MODE: web
      BACKUP_LOCAL_DIR: /app/data/backups
      DB_URL: ${DB_URL:-jdbc:postgresql://postgres:5432/quotenode}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME:-quotenode}
      DB_ENCRYPTION_KEY: ${DB_ENCRYPTION_KEY}
      TIMING_TOKEN_SECRET: ${TIMING_TOKEN_SECRET}
      PUBLIC_LINK_PASSWORD_SESSION_SECRET: ${PUBLIC_LINK_PASSWORD_SESSION_SECRET}
      CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
      SMTP_HOST: ${SMTP_HOST:-}
      SMTP_PORT: ${SMTP_PORT:-587}
      SMTP_USERNAME: ${SMTP_USERNAME:-}
      SMTP_PASSWORD: ${SMTP_PASSWORD:-}
      SMTP_AUTH: ${SMTP_AUTH:-true}
      SMTP_STARTTLS: ${SMTP_STARTTLS:-true}
      NOTIFICATIONS_ENABLED: ${NOTIFICATIONS_ENABLED:-true}
      NOTIFICATIONS_EMAIL_ENABLED: ${NOTIFICATIONS_EMAIL_ENABLED:-false}
      NOTIFICATIONS_PUBLIC_PREFERENCES_URL: ${NOTIFICATIONS_PUBLIC_PREFERENCES_URL:-}
      # Absolute public app base URL for links in notification emails. Blank ⇒ the origin of
      # NOTIFICATIONS_PUBLIC_PREFERENCES_URL is used; with email enabled a valid http/https base must
      # resolve or the backend fails fast so no email carries a dead relative link.
      NOTIFICATIONS_APP_BASE_URL: ${NOTIFICATIONS_APP_BASE_URL:-}
      # Offer-revisit notification scheduler kill-switch + interval. Must be declared here (not only in
      # the panel) to reach the container. Default on; stops only the periodic revisit sweep (in-app
      # notifications from other paths are unaffected). Interval must be > 0 (fail-fast at startup).
      NOTIFICATIONS_REVISIT_SCHEDULER_ENABLED: ${NOTIFICATIONS_REVISIT_SCHEDULER_ENABLED:-true}
      NOTIFICATIONS_REVISIT_CHECK_INTERVAL_SECONDS: ${NOTIFICATIONS_REVISIT_CHECK_INTERVAL_SECONDS:-7200}
      # Calendar-reminder scheduler kill-switch. Must be declared here (not only in the panel) to reach the
      # container. Default on; stops only the periodic reminder sweep (in-app notifications from other paths
      # are unaffected). The cadence is the validated calendar.reminders-job-interval-minutes (grace >=
      # interval enforced at startup); in-app only unless NOTIFICATIONS_EMAIL_ENABLED=true.
      CALENDAR_REMINDERS_SCHEDULER_ENABLED: ${CALENDAR_REMINDERS_SCHEDULER_ENABLED:-true}
      # Reminder-scheduler cadences (minutes). Two independent knobs — the calendar and CRM reminder
      # schedulers never share one; each falls back to the legacy REMINDERS_JOB_INTERVAL_MINUTES, then 5.
      # Must be declared here (not only in the panel) to reach the container.
      CALENDAR_REMINDERS_JOB_INTERVAL_MINUTES: ${CALENDAR_REMINDERS_JOB_INTERVAL_MINUTES:-${REMINDERS_JOB_INTERVAL_MINUTES:-5}}
      NOTIFICATIONS_REMINDER_CHECK_INTERVAL_MINUTES: ${NOTIFICATIONS_REMINDER_CHECK_INTERVAL_MINUTES:-${REMINDERS_JOB_INTERVAL_MINUTES:-5}}
      # CRM-reminder delivery scheduler kill-switch (Wave 4D). Must be declared here (not only in the panel)
      # to reach the container. Default on. Set false for the FIRST redeploy of an existing install so the
      # newly-activated scheduler cannot notify a legacy backlog; the V121 migration also fail-closes overdue
      # rows to FAILED, so a normal deploy will not emit historical reminders.
      NOTIFICATIONS_REMINDER_CHECK_SCHEDULER_ENABLED: ${NOTIFICATIONS_REMINDER_CHECK_SCHEDULER_ENABLED:-true}
      # Customer-asset reminder scheduler kill-switch (warranty/subscription/maintenance). Must be declared
      # here (not only in the panel) to reach the container. Default on. The reminder window's upper bound
      # keeps long-past deadlines from flooding on activation, so a normal deploy will not emit a backlog.
      ASSETS_NOTIFICATION_SCHEDULER_ENABLED: ${ASSETS_NOTIFICATION_SCHEDULER_ENABLED:-true}
      # Dedicated session-retention purge scheduler kill-switch. Default on: it only removes already-expired
      # sessions older than the retention window. Web backend only — never the backup-worker.
      SESSION_RETENTION_SCHEDULER_ENABLED: ${SESSION_RETENTION_SCHEDULER_ENABLED:-true}
      # Dedicated database-health scheduler kill-switch. Default on: the checks are read-only. Web backend only.
      DATABASE_HEALTH_SCHEDULER_ENABLED: ${DATABASE_HEALTH_SCHEDULER_ENABLED:-true}
      # Dedicated license-expiry reminder scheduler kill-switch. Must be declared here (not only in the panel) to
      # reach the container. Default on: at-most-once (V126 ledger), fires only on an exact warn-day, replays no
      # backlog. Web backend only — never the backup-worker.
      LICENSE_EXPIRY_SCHEDULER_ENABLED: ${LICENSE_EXPIRY_SCHEDULER_ENABLED:-true}
      # Dedicated license-reload scheduler kill-switch. Must be declared here (not only in the panel) to reach the
      # container. Default on: converges a running instance to the authoritative license record without a
      # restart; a read failure keeps last-known-good. Web backend only — never the backup-worker.
      LICENSE_RELOAD_SCHEDULER_ENABLED: ${LICENSE_RELOAD_SCHEDULER_ENABLED:-true}
      # Dedicated offer-expiry reminder scheduler kill-switch. Must be declared here (not only in the panel) to
      # reach the container. Default on: at-most-once (V127 ledger), fires only on an exact warn-day, bounded
      # batch sweep, no status mutation. Web backend only — never the backup-worker.
      OFFER_EXPIRY_SCHEDULER_ENABLED: ${OFFER_EXPIRY_SCHEDULER_ENABLED:-true}
      # Dedicated auto-expire scheduler kill-switch. Default OFF: the first run MUTATES existing data (transitions
      # past-due offers to EXPIRED). An operator enables it after inspecting the backlog. Web backend only.
      OFFERS_AUTO_EXPIRE_SCHEDULER_ENABLED: ${OFFERS_AUTO_EXPIRE_SCHEDULER_ENABLED:-false}
      # Customer-facing operational asset-email feature master switch (a separate, consent-gated channel to the
      # customer's own address). Must be declared here (not only in the panel) to reach the container. Default
      # OFF: nothing is enqueued and the delivery worker stays inert until an operator turns it on and a
      # customer has granted consent.
      ASSETS_CUSTOMER_EMAIL_ENABLED: ${ASSETS_CUSTOMER_EMAIL_ENABLED:-false}
      # Daily email-digest scheduler kill-switch. Must be declared here (not only in the panel) to reach the
      # container. Default on; active only when NOTIFICATIONS_EMAIL_ENABLED=true. Set false for the FIRST
      # redeploy of an existing install with SMTP on, inspect the backlog query, then enable — so a hardened
      # sweep never drains an unknown legacy PENDING backlog on activation.
      NOTIFICATIONS_DIGEST_SCHEDULER_ENABLED: ${NOTIFICATIONS_DIGEST_SCHEDULER_ENABLED:-true}
      # Digest cadence (Spring 6-field cron), operator-tunable. Default hourly on the top of the hour.
      NOTIFICATIONS_DIGEST_CRON: ${NOTIFICATIONS_DIGEST_CRON:-0 0 * * * *}
      # Max events reserved into a single digest (caps message size); the overflow rolls to an auto-scheduled
      # continuation at the user's next digest time. Operator-tunable.
      NOTIFICATIONS_DIGEST_MAX_EVENTS_PER_DIGEST: ${NOTIFICATIONS_DIGEST_MAX_EVENTS_PER_DIGEST:-100}
      # Immediate-notification email delivery worker kill-switch. Must be declared here (not only in the
      # panel) to reach the container. Default true; active only when NOTIFICATIONS_EMAIL_ENABLED=true.
      # Set false for the FIRST redeploy of an existing install so the worker cannot drain an unknown
      # stale QUEUED backlog on activation; the max-queue-age preflight also fails stale rows without SMTP.
      NOTIFICATIONS_EMAIL_WORKER_ENABLED: ${NOTIFICATIONS_EMAIL_WORKER_ENABLED:-true}
      NOTIFICATIONS_TRANSACTIONAL_EMAIL_WORKER_ENABLED: ${NOTIFICATIONS_TRANSACTIONAL_EMAIL_WORKER_ENABLED:-true}
      # Offer-email delivery worker kill-switch. Must be listed here, not only set in the Coolify
      # panel: a panel value is used for interpolation but never reaches the container unless the key
      # is declared in environment. Default true (new, unattended installs). Set false for the FIRST
      # redeploy of an existing install so the worker cannot drain an unknown stale backlog on
      # activation; audit the backlog, then set true and redeploy only this service.
      OFFERS_DELIVERY_WORKER_ENABLED: ${OFFERS_DELIVERY_WORKER_ENABLED:-true}
      # IP-whitelist lockout-safety confirmation/rollback tuning. All three keys must be declared here
      # (not only in the panel) to reach the container. The periodic deadline rollback is a web-tier
      # concern only (never the backup-only worker below), and turning it off leaves the startup
      # fail-safe intact. The check interval (seconds) must be <= the confirmation window, or startup
      # fails fast (IpWhitelistConfirmationConfig). Confirmation window = how long an enable/narrow
      # stays pending before it is rolled back if unconfirmed (default 120s).
      SECURITY_IP_WHITELIST_CONFIRMATION_WINDOW_SECONDS: ${SECURITY_IP_WHITELIST_CONFIRMATION_WINDOW_SECONDS:-120}
      SECURITY_IP_WHITELIST_ROLLBACK_SCHEDULER_ENABLED: ${SECURITY_IP_WHITELIST_ROLLBACK_SCHEDULER_ENABLED:-true}
      SECURITY_IP_WHITELIST_ROLLBACK_CHECK_INTERVAL_SECONDS: ${SECURITY_IP_WHITELIST_ROLLBACK_CHECK_INTERVAL_SECONDS:-15}
      PDF_ENABLED: ${PDF_ENABLED:-true}
      # Async PDF worker kill-switch. Must be declared here (not only in the panel) to reach the
      # container. Default true; set false only to inspect the queue during a controlled rollout or a
      # Gotenberg/worker incident — sync generation and downloads keep working while it is off.
      PDF_WORKER_ENABLED: ${PDF_WORKER_ENABLED:-true}
      # Worker tuning must be declared here to reach the container (a panel value alone is only used for
      # interpolation). Defaults match application.yml; override for a controlled backlog drain.
      PDF_WORKER_CONCURRENCY: ${PDF_WORKER_CONCURRENCY:-2}
      PDF_WORKER_POLL_INTERVAL_MS: ${PDF_WORKER_POLL_INTERVAL_MS:-2000}
      PDF_WORKER_RECOVERY_INTERVAL_MS: ${PDF_WORKER_RECOVERY_INTERVAL_MS:-60000}
      PDF_WORKER_CLAIM_TIMEOUT_SECONDS: ${PDF_WORKER_CLAIM_TIMEOUT_SECONDS:-300}
      PDF_RETRY_BACKOFF_SECONDS: ${PDF_RETRY_BACKOFF_SECONDS:-30,120,600}
      PDF_GOTENBERG_URL: http://gotenberg:3000
      LICENSE_SKIP_SIGNATURE_CHECK: ${LICENSE_SKIP_SIGNATURE_CHECK:-false}
      LOG_LEVEL: ${LOG_LEVEL:-ERROR}
      BACKUP_ENABLED: ${BACKUP_ENABLED:-false}
      BACKUP_ARCHIVE_ENCRYPTION_MODE: ${BACKUP_ARCHIVE_ENCRYPTION_MODE:-}
      BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
      BACKUP_AGE_BINARY_PATH: ${BACKUP_AGE_BINARY_PATH:-age}
      BACKUP_AGE_KEYGEN_BINARY_PATH: ${BACKUP_AGE_KEYGEN_BINARY_PATH:-age-keygen}
      BACKUP_VERIFIER_SCRIPT_PATH: ${BACKUP_VERIFIER_SCRIPT_PATH:-/app/scripts/quotenode-backup-verify.sh}
      BACKUP_POWERSHELL_VERIFIER_SCRIPT_PATH: ${BACKUP_POWERSHELL_VERIFIER_SCRIPT_PATH:-/app/scripts/quotenode-backup-verify.ps1}
      BACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP: ${BACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP:-false}
      BACKUP_RUNTIME_PROFILE: ${BACKUP_RUNTIME_PROFILE:-WORKER}
      BACKUP_DATABASE_PAYLOAD_MODE: ${BACKUP_DATABASE_PAYLOAD_MODE:-APP_ENCRYPTED}
      BACKUP_VERIFY_MODE: ${BACKUP_VERIFY_MODE:-off}
      BACKUP_DOWNLOAD_GRANT_TTL_SECONDS: ${BACKUP_DOWNLOAD_GRANT_TTL_SECONDS:-120}
      # Off-host copy (rclone remote, e.g. offsite:quotenode); empty = archives stay in the backups volume.
      # The remote's credentials are rclone settings outside this contract (see the Coolify guide).
      BACKUP_RCLONE_REMOTE: ${BACKUP_RCLONE_REMOTE:-}
      BACKUP_RETENTION_DAILY: ${BACKUP_RETENTION_DAILY:-7}
      BACKUP_RETENTION_CLEANUP_BATCH_SIZE: ${BACKUP_RETENTION_CLEANUP_BATCH_SIZE:-20}
      BACKUP_RETENTION_MAX_SCAN: ${BACKUP_RETENTION_MAX_SCAN:-5000}
      FEATURE_2FA_ENABLED: ${FEATURE_2FA_ENABLED:-true}
      # Roles that must use two-factor authentication (e.g. ADMIN); empty = the application default (none).
      SECURITY_REQUIRE_2FA_ROLES: ${SECURITY_REQUIRE_2FA_ROLES:-}
      SECURITY_BOT_DETECTION_ENABLED: ${SECURITY_BOT_DETECTION_ENABLED:-true}
      GEOIP_ENABLED: ${GEOIP_ENABLED:-false}
      # GeoIP enforcement needs a country .mmdb at this path. The backend fetches it automatically
      # (GEOIP_AUTO_UPDATE; keyless DB-IP Country Lite by default, or MaxMind GeoLite2 when MAXMIND_* are
      # set); Update in admin/security fetches on demand. Empty/missing DB => GeoIP fails OPEN.
      GEOIP_DB_PATH: ${GEOIP_DB_PATH:-/app/data/geoip/GeoLite2-Country.mmdb}
      # Database lifecycle, independent of enforcement. Declared here so a panel value actually
      # reaches the container: an interpolation-only value never does. auto-update governs every
      # automatic fetch; the scheduler switch governs only the periodic staleness check, leaving the
      # readiness-time refresh and manual updates alone.
      GEOIP_AUTO_UPDATE: ${GEOIP_AUTO_UPDATE:-true}
      GEOIP_UPDATE_SCHEDULER_ENABLED: ${GEOIP_UPDATE_SCHEDULER_ENABLED:-true}
      GEOIP_UPDATE_CRON: "${GEOIP_UPDATE_CRON:-0 15 3 * * *}"
      # GeoIP database source for the bundled updater: empty = auto (keyless DB-IP Country Lite, or MaxMind
      # GeoLite2 when both MAXMIND_* values are set). Web tier only, like the other GeoIP settings.
      GEOIP_PROVIDER: ${GEOIP_PROVIDER:-}
      MAXMIND_ACCOUNT_ID: ${MAXMIND_ACCOUNT_ID:-}
      MAXMIND_LICENSE_KEY: ${MAXMIND_LICENSE_KEY:-}
      GEOIP_EDITION_ID: ${GEOIP_EDITION_ID:-GeoLite2-Country}
      # Client-IP retention. TRACKING_IP_ANONYMIZE plus its delay were documented in the env examples
      # but never declared here, so they only ever fed interpolation and could not reach the container
      # — the privacy policy was unreachable in exactly the way the sweep itself was. The sweep is
      # irreversible, so it ships off and both retention windows ship at zero.
      TRACKING_IP_ANONYMIZE: ${TRACKING_IP_ANONYMIZE:-false}
      TRACKING_IP_ANONYMIZE_AFTER_DAYS: ${TRACKING_IP_ANONYMIZE_AFTER_DAYS:-0}
      TRACKING_IP_ANONYMIZATION_SCHEDULER_ENABLED: ${TRACKING_IP_ANONYMIZATION_SCHEDULER_ENABLED:-false}
      TRACKING_IP_ANONYMIZATION_CRON: "${TRACKING_IP_ANONYMIZATION_CRON:-0 0 3 * * *}"
      TRACKING_IP_ANONYMIZATION_BATCH_SIZE: ${TRACKING_IP_ANONYMIZATION_BATCH_SIZE:-1000}
      TRACKING_IP_ANONYMIZATION_MAX_BATCHES_PER_TABLE: ${TRACKING_IP_ANONYMIZATION_MAX_BATCHES_PER_TABLE:-50}
      SECURITY_AUTH_IP_RETENTION_DAYS: ${SECURITY_AUTH_IP_RETENTION_DAYS:-0}
      # Import cleanup. Declared here so a panel value actually reaches the container: an
      # interpolation-only value never does. The purge is irreversible, so it ships off.
      IMPORT_CLEANUP_SCHEDULER_ENABLED: ${IMPORT_CLEANUP_SCHEDULER_ENABLED:-false}
      IMPORT_CLEANUP_INTERVAL_MS: ${IMPORT_CLEANUP_INTERVAL_MS:-86400000}
      IMPORT_CLEANUP_MAX_SESSIONS_PER_RUN: ${IMPORT_CLEANUP_MAX_SESSIONS_PER_RUN:-500}
      IMPORT_CLEANUP_PURGE_AFTER_DAYS: ${IMPORT_CLEANUP_PURGE_AFTER_DAYS:-7}
      # Client-IP trust + IP-whitelist controls (audit P1-12; security finding #80). Forwarding headers
      # are honored ONLY from an explicitly trusted proxy peer — a private/loopback/CGNAT/ULA peer is
      # NOT auto-trusted. The frontend Caddy reverse-proxies /api to this backend, so it is the trusted
      # peer: it is declared by its Compose service name below and works with NO manual configuration
      # (resolved to the container IP outside the request path, re-resolved on a schedule). Override
      # only for a custom proxy network (extra CIDR/hostname); a malformed IP/CIDR fails startup.
      # SECURITY_IP_WHITELIST_FORCE_DISABLE=true + restart is the break-glass recovery for a lockout.
      SECURITY_TRUSTED_PROXIES: ${SECURITY_TRUSTED_PROXIES:-frontend}
      SECURITY_IP_WHITELIST: ${SECURITY_IP_WHITELIST:-}
      SECURITY_IP_WHITELIST_FORCE_DISABLE: ${SECURITY_IP_WHITELIST_FORCE_DISABLE:-false}
      SECURITY_GEOIP_ALLOWED_COUNTRIES: ${SECURITY_GEOIP_ALLOWED_COUNTRIES:-}
    volumes:
      - backend_prod_uploads:/app/data/uploads
      - backend_prod_pdfs:/app/data/pdfs
      - backend_prod_backups:/app/data/backups
      - backend_prod_geoip:/app/data/geoip
    # No published port (security finding #80): the frontend Caddy reaches the backend over the
    # internal Compose network (backend:8080), so publishing it would only expose a docker-NAT peer
    # that could be used to reach the app directly. Internal-only keeps the frontend Caddy the sole
    # trusted ingress for client-IP resolution.
    expose:
      - "8080"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 45s
    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
    environment:
      SPRING_PROFILES_ACTIVE: prod
      JOBS_MODE: backup-only
      BACKUP_LOCAL_DIR: /app/data/backups
      DB_URL: ${DB_URL:-jdbc:postgresql://postgres:5432/quotenode}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
      DB_NAME: ${DB_NAME:-quotenode}
      DB_ENCRYPTION_KEY: ${DB_ENCRYPTION_KEY}
      TIMING_TOKEN_SECRET: ${TIMING_TOKEN_SECRET}
      PUBLIC_LINK_PASSWORD_SESSION_SECRET: ${PUBLIC_LINK_PASSWORD_SESSION_SECRET}
      CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
      BACKUP_ENABLED: ${BACKUP_ENABLED:-false}
      BACKUP_CRON: ${BACKUP_CRON:-0 0 2 * * *}
      # Dedicated backup-scheduler kill-switch. Default OFF and separate from BACKUP_ENABLED. Declared ONLY on the
      # backup-only worker — the web backend (JOBS_MODE=web) must never run the periodic backup.
      BACKUP_SCHEDULER_ENABLED: ${BACKUP_SCHEDULER_ENABLED:-false}
      BACKUP_ARCHIVE_ENCRYPTION_MODE: ${BACKUP_ARCHIVE_ENCRYPTION_MODE:-}
      BACKUP_AGE_RECIPIENT: ${BACKUP_AGE_RECIPIENT:-}
      BACKUP_AGE_BINARY_PATH: ${BACKUP_AGE_BINARY_PATH:-age}
      BACKUP_AGE_KEYGEN_BINARY_PATH: ${BACKUP_AGE_KEYGEN_BINARY_PATH:-age-keygen}
      BACKUP_VERIFIER_SCRIPT_PATH: ${BACKUP_VERIFIER_SCRIPT_PATH:-/app/scripts/quotenode-backup-verify.sh}
      BACKUP_POWERSHELL_VERIFIER_SCRIPT_PATH: ${BACKUP_POWERSHELL_VERIFIER_SCRIPT_PATH:-/app/scripts/quotenode-backup-verify.ps1}
      BACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP: ${BACKUP_ALLOW_UNENCRYPTED_LOCAL_BACKUP:-false}
      BACKUP_RUNTIME_PROFILE: ${BACKUP_RUNTIME_PROFILE:-WORKER}
      BACKUP_DATABASE_PAYLOAD_MODE: ${BACKUP_DATABASE_PAYLOAD_MODE:-APP_ENCRYPTED}
      BACKUP_VERIFY_MODE: ${BACKUP_VERIFY_MODE:-off}
      # Off-host copy (rclone remote, e.g. offsite:quotenode); empty = archives stay in the backups volume.
      # The remote's credentials are rclone settings outside this contract (see the Coolify guide).
      BACKUP_RCLONE_REMOTE: ${BACKUP_RCLONE_REMOTE:-}
      BACKUP_RETENTION_DAILY: ${BACKUP_RETENTION_DAILY:-7}
      BACKUP_RETENTION_CLEANUP_BATCH_SIZE: ${BACKUP_RETENTION_CLEANUP_BATCH_SIZE:-20}
      BACKUP_RETENTION_MAX_SCAN: ${BACKUP_RETENTION_MAX_SCAN:-5000}
      LICENSE_SKIP_SIGNATURE_CHECK: ${LICENSE_SKIP_SIGNATURE_CHECK:-false}
      LOG_LEVEL: ${LOG_LEVEL:-ERROR}
    volumes:
      - backend_prod_uploads:/app/data/uploads
      - backend_prod_pdfs:/app/data/pdfs
      - backend_prod_backups:/app/data/backups
      - backend_prod_geoip:/app/data/geoip
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 15s
      timeout: 10s
      retries: 5
      start_period: 45s
    restart: unless-stopped
    logging: *json-file-logging

  frontend:
    # Web UI and entry point: Coolify routes the resource domain here; it proxies the API to the backend.
    image: ghcr.io/lesisty7/quotenode/quotenode-frontend@sha256:40a1cdc4fca5cebd9f43d34daef4ba059a88000ff08af821e199566f07260606
    depends_on:
      backend:
        condition: service_healthy
    # No published host port (security finding #80): Coolify's own proxy is the sole ingress and reaches
    # this frontend Caddy over the internal Coolify network. Publishing a host port would expose the
    # Caddy directly via a docker-NAT peer, letting a client spoof the client IP through it. Both backend
    # and frontend stay internal-only; the frontend Caddy uses trusted_proxies_strict so even the
    # legitimate Coolify edge cannot be tricked by a forged left-most X-Forwarded-For.
    expose:
      - "80"
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:80/"]
      interval: 15s
      timeout: 5s
      retries: 3
    restart: unless-stopped
    logging: *json-file-logging

# Persistent data: database, uploads, generated PDFs, local backup archives and the GeoIP database.
# Coolify prefixes each name with the resource UUID, so keep the same resource across upgrades.
volumes:
  postgres_prod_data:
  backend_prod_uploads:
  backend_prod_pdfs:
  backend_prod_backups:
  backend_prod_geoip:
  1. Définissez votre domaine public (par exemple https://crm.example.com) uniquement sur le service frontend. Laissez backend, backup-worker, postgres et gotenberg sans domaine : ils restent internes, et le frontend est le seul point d’entrée.

Étape 2 — Variables d’environnement

Définissez-les dans Environment Variables de la ressource. Ce sont des valeurs d’exécution : les images QuoteNode sont déjà construites, rien ne doit donc être transmis comme argument de construction.

DB_NAME=quotenode
DB_USERNAME=quotenode
DB_PASSWORD=<random-32+-characters>
DB_URL=jdbc:postgresql://postgres:5432/quotenode
DB_ENCRYPTION_KEY=<64-hex-characters>
TIMING_TOKEN_SECRET=<random-32+-characters>
PUBLIC_LINK_PASSWORD_SESSION_SECRET=<random-32+-characters>
CORS_ALLOWED_ORIGINS=https://crm.example.com
NOTIFICATIONS_PUBLIC_PREFERENCES_URL=https://crm.example.com/notifications/preferences
LOG_LEVEL=INFO

Si l’envoi d’e-mails est activé, définissez aussi :

NOTIFICATIONS_EMAIL_ENABLED=true
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME[email protected]
SMTP_PASSWORD=<smtp-password>
SMTP_AUTH=true
SMTP_STARTTLS=true

Tous les autres réglages ont des valeurs par défaut sûres dans la définition Compose (par exemple BACKUP_ENABLED=false, GEOIP_ENABLED=false). La détection de l’IP du client fonctionne sans configuration : le frontend est le proxy de confiance du backend. La liste complète figure dans Variables d’environnement de déploiement.

Seuls les réglages déclarés dans la définition Compose atteignent les conteneurs ; env.example est le catalogue du paquet autonome, où chaque réglage atteint l’application. Pour utiliser un autre réglage, ajoutez KEY: ${KEY} à l’environment: du service qui le lit (Edit Compose File ; réglages de l’application : backend, réglages de sauvegarde aussi backup-worker), définissez la valeur dans le panneau et refaites cet ajout après chaque mise à jour. Une copie de sauvegarde hors du serveur fonctionne ainsi : BACKUP_RCLONE_REMOTE (si la définition ne le déclare pas encore) et les réglages RCLONE_CONFIG_<NAME>_* de la destination sur backend et backup-worker.

Laissez LICENSE_SKIP_SIGNATURE_CHECK non défini ou à false : en production, l’application refuse de démarrer avec cette option activée. Les réglages commençant par VITE_ n’ont aucun effet sur une version : l’interface web est déjà construite et la vérification des mises à jour pointe vers quotenode.dev.

Conservez les secrets en dehors de Coolify. DB_ENCRYPTION_KEY et les autres secrets sont nécessaires pour restaurer une sauvegarde sur un nouveau serveur.

Étape 3 — Délai d’arrêt

Dans les réglages Advanced de la ressource, définissez le stop grace period sur 60 secondes. Lors d’un déploiement, Coolify arrête les anciens conteneurs avec son propre délai (30 secondes par défaut) et ignore la valeur du fichier Compose. 60 secondes permettent au backend de terminer l’envoi des e-mails et de clore proprement les requêtes. Les versions plus anciennes de Coolify sans ce réglage utilisent toujours 30 secondes.

Étape 4 — Déployer et vérifier

Cliquez sur Deploy. Coolify récupère les images et démarre les services ; le backend exécute les migrations de la base de données à son premier démarrage.

  1. Ouvrez https://crm.example.com/health — la réponse est UP.
  2. Connectez-vous avec [email protected] / Admin123! et définissez un nouveau mot de passe lorsque cela vous est demandé.
  3. Créez une offre, générez son PDF et ouvrez son lien public depuis un autre appareil.
  4. Si les e-mails sont activés, envoyez une notification de test et ouvrez le lien de préférences qu’elle contient.

Chaque déploiement recrée tous les conteneurs de la ressource, y compris la base de données ; vos données restent dans les volumes. Prévoyez une courte interruption à chaque déploiement.

Mise à jour

Remplacez la définition Compose par le fichier docker-compose.coolify.yml de la version suivante (Edit Compose File) et déployez. Coolify gère le téléchargement des images et le remplacement des conteneurs ; les volumes conservent vos données. Vérifiez dans le journal que votre version de Coolify télécharge toutes les images nécessaires avant d’arrêter les conteneurs en cours. Faites d’abord une sauvegarde et lisez Mettre à jour QuoteNode.

Une définition plus récente peut déclarer des réglages que la précédente ignorait : avant de la déployer, supprimez les valeurs du panneau qui ne doivent pas prendre effet (par exemple l’exemple BACKUP_RCLONE_REMOTE=s3:mybucket/backups de env.example) et refaites vos propres ajouts KEY: ${KEY}.

Problèmes courants avec Coolify

  • Les liens publics échouent lorsque CORS_ALLOWED_ORIGINS ne correspond pas exactement à l’origine du navigateur (schéma, sans barre oblique finale).
  • Les liens de préférences dans les e-mails échouent lorsque NOTIFICATIONS_PUBLIC_PREFERENCES_URL pointe encore vers localhost ou un ancien domaine.
  • Le HTTPS géré par Coolify exige que le domaine pointe vers le serveur Coolify.
  • N’attribuez pas de domaine à backend, postgres, gotenberg ou backup-worker ; les exposer contourne le routage du frontend et la protection de l’IP du client.
  • Une variable du panneau n’atteint que les services dont l’environment: Compose la déclare ; Gotenberg garde son propre niveau de journalisation fixé.

Last reviewed: Recently