Skip to content
Q
QuoteNode

Installation

Install with Coolify

Deploy a QuoteNode release in Coolify from its deployment package: prebuilt images pinned by digest, nothing built on your server.

Install with Coolify

Coolify gives you managed HTTPS, health checks and a web UI for environment variables. QuoteNode runs in Coolify from a release package: a Compose definition that pulls the tested release images by digest. Nothing is compiled on your server, and every deployment of the same package runs exactly the same images.

Current release: v0.9.8-alpha — images for 64-bit x86 servers (linux/amd64).

Step 1 — Create the resource

  1. In Coolify choose New Resource → Docker Compose Empty.
  2. Paste the release’s Coolify Compose definition unchanged. It is also available as 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. Set your public domain (for example https://crm.example.com) on the frontend service only. Leave backend, backup-worker, postgres and gotenberg without domains: they stay internal, and the frontend is the only entry point.

Step 2 — Environment variables

Set these in the resource’s Environment Variables. They are runtime values: QuoteNode images are already built, so nothing has to be passed as a build argument.

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

If email delivery is enabled, also set:

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

Every other setting has a safe default in the Compose definition (for example BACKUP_ENABLED=false, GEOIP_ENABLED=false). Client IP detection works without configuration: the frontend is the backend’s trusted proxy. See Deployment environment variables for the full list.

Only settings declared in the Compose definition reach the containers; env.example is the catalogue of the standalone package, where every setting reaches the application. To use another setting, add KEY: ${KEY} to the environment: of the service that reads it (Edit Compose File; application settings: backend, backup settings also backup-worker), set the value in the panel and repeat the addition after every upgrade. An off-host backup copy works this way: BACKUP_RCLONE_REMOTE (if the definition does not declare it yet) and the remote’s RCLONE_CONFIG_<NAME>_* settings on backend and backup-worker.

Keep LICENSE_SKIP_SIGNATURE_CHECK unset or false: production refuses to start with it enabled. Settings that start with VITE_ have no effect on a release: the web interface is already built, with the update check pointing at quotenode.dev.

Save the secrets outside Coolify. DB_ENCRYPTION_KEY and the other secrets are needed to restore a backup on a new server.

Step 3 — Stop grace period

In the resource’s Advanced settings set the stop grace period to 60 seconds. Coolify stops the old containers with its own timeout when it deploys (30 seconds by default) and ignores the value in the Compose file. 60 seconds lets the backend finish sending email and close requests cleanly. Older Coolify versions without this setting always use 30 seconds.

Step 4 — Deploy and verify

Click Deploy. Coolify pulls the images and starts the services; the backend runs the database migrations on its first start.

  1. Open https://crm.example.com/health — it returns UP.
  2. Log in with [email protected] / Admin123! and set a new password when asked.
  3. Create an offer, generate its PDF, and open its public link from another device.
  4. If email is enabled, send a test notification and open the preferences link in it.

Every deployment recreates all containers of the resource, including the database; your data stays in the volumes. Expect a short interruption during each deployment.

Upgrading

Replace the Compose definition with the next release’s docker-compose.coolify.yml (Edit Compose File) and deploy. Coolify manages image downloads and container replacement; the volumes keep your data. Verify in the deployment log that your Coolify version downloads all required images before stopping the running containers. Back up first and read Upgrading QuoteNode.

A newer definition can declare settings that the previous one ignored, so remove panel values you do not want to take effect before you deploy it (for example the env.example sample BACKUP_RCLONE_REMOTE=s3:mybucket/backups), and re-apply your own KEY: ${KEY} additions.

Common Coolify problems

  • Public links fail when CORS_ALLOWED_ORIGINS does not match the browser origin exactly (scheme, no trailing slash).
  • Email preference links fail when NOTIFICATIONS_PUBLIC_PREFERENCES_URL still points to localhost or an old domain.
  • Coolify-managed HTTPS requires the domain to resolve to the Coolify server.
  • Do not give backend, postgres, gotenberg or backup-worker a domain; exposing them bypasses the frontend’s routing and client-IP protection.
  • A panel variable reaches only the services whose Compose environment: declares it; Gotenberg keeps its own pinned log level.

Last reviewed: Recently