Installation
Installare con Coolify
Distribuire una release di QuoteNode in Coolify dal suo pacchetto di distribuzione: immagini già pronte fissate tramite digest, nulla viene compilato sul tuo server.
Installazione con Coolify
Coolify offre HTTPS gestito, controlli di stato e un’interfaccia web per le variabili d’ambiente. QuoteNode funziona in Coolify a partire da un pacchetto di release: una definizione Compose che scarica le immagini testate della release tramite digest. Sul tuo server non viene compilato nulla, e ogni distribuzione dello stesso pacchetto esegue esattamente le stesse immagini.
Release attuale: v0.9.8-alpha — immagini per server x86 a 64 bit (linux/amd64).
Passo 1 — Crea la risorsa
- In Coolify scegli New Resource → Docker Compose Empty.
- Incolla senza modifiche la definizione Compose della release per Coolify. È disponibile anche come file 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:
- Imposta il dominio pubblico (ad esempio
https://crm.example.com) solo sul servizio frontend. Lasciabackend,backup-worker,postgresegotenbergsenza dominio: restano interni e il frontend è l’unico punto di ingresso.
Passo 2 — Variabili d’ambiente
Impostale in Environment Variables della risorsa. Sono valori di esecuzione: le immagini di QuoteNode sono già costruite, quindi non va passato nulla come argomento di build.
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
Se l’invio di e-mail è attivo, imposta anche:
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
Tutte le altre impostazioni hanno valori predefiniti sicuri nella definizione Compose (ad esempio BACKUP_ENABLED=false, GEOIP_ENABLED=false). Il rilevamento dell’IP del client funziona senza configurazione: il frontend è il proxy attendibile del backend. L’elenco completo è in Variabili d’ambiente della distribuzione.
Solo le impostazioni dichiarate nella definizione Compose raggiungono i container; env.example è il catalogo del pacchetto standalone, in cui ogni impostazione raggiunge l’applicazione. Per usare un’altra impostazione, aggiungi KEY: ${KEY} all’environment: del servizio che la legge (Edit Compose File; impostazioni dell’applicazione: backend, impostazioni di backup anche backup-worker), imposta il valore nel pannello e ripeti l’aggiunta dopo ogni aggiornamento. Così funziona una copia di backup fuori dal server: BACKUP_RCLONE_REMOTE (se la definizione non lo dichiara ancora) e le impostazioni RCLONE_CONFIG_<NAME>_* della destinazione su backend e backup-worker.
Lascia LICENSE_SKIP_SIGNATURE_CHECK non impostato o false: in produzione l’applicazione non si avvia con questa opzione attiva. Le impostazioni che iniziano con VITE_ non hanno effetto su una release: l’interfaccia web è già costruita e il controllo degli aggiornamenti punta a quotenode.dev.
Conserva i segreti al di fuori di Coolify.
DB_ENCRYPTION_KEYe gli altri segreti servono per ripristinare un backup su un nuovo server.
Passo 3 — Tempo di arresto
Nelle impostazioni Advanced della risorsa imposta lo stop grace period a 60 secondi. Durante una distribuzione Coolify arresta i vecchi container con un proprio limite di tempo (30 secondi per impostazione predefinita) e ignora il valore del file Compose. 60 secondi permettono al backend di completare l’invio delle e-mail e chiudere correttamente le richieste. Le versioni di Coolify più vecchie senza questa impostazione usano sempre 30 secondi.
Passo 4 — Distribuisci e verifica
Fai clic su Deploy. Coolify scarica le immagini e avvia i servizi; al primo avvio il backend esegue le migrazioni del database.
- Apri
https://crm.example.com/health— la risposta èUP. - Accedi con
[email protected]/Admin123!e imposta una nuova password quando richiesto. - Crea un’offerta, genera il suo PDF e apri il suo link pubblico da un altro dispositivo.
- Se le e-mail sono attive, invia una notifica di prova e apri il link alle preferenze che contiene.
Ogni distribuzione ricrea tutti i container della risorsa, compreso il database; i dati restano nei volumi. Considera una breve interruzione a ogni distribuzione.
Aggiornamento
Sostituisci la definizione Compose con il docker-compose.coolify.yml della release successiva (Edit Compose File) e distribuisci. Coolify gestisce il download delle immagini e la sostituzione dei container; i volumi conservano i dati. Verifica nel log che la tua versione di Coolify scarichi tutte le immagini necessarie prima di arrestare i container in esecuzione. Esegui prima un backup e leggi Aggiornare QuoteNode.
Una definizione più recente può dichiarare impostazioni che la precedente ignorava: prima di distribuirla, rimuovi dal pannello i valori che non devono avere effetto (ad esempio l’esempio BACKUP_RCLONE_REMOTE=s3:mybucket/backups di env.example) e riaggiungi le tue righe KEY: ${KEY}.
Problemi comuni con Coolify
- I link pubblici non funzionano quando
CORS_ALLOWED_ORIGINSnon corrisponde esattamente all’origine del browser (schema, senza barra finale). - I link alle preferenze nelle e-mail non funzionano quando
NOTIFICATIONS_PUBLIC_PREFERENCES_URLpunta ancora a localhost o a un vecchio dominio. - L’HTTPS gestito da Coolify richiede che il dominio punti al server Coolify.
- Non assegnare un dominio a
backend,postgres,gotenbergobackup-worker; esporli aggira l’instradamento del frontend e la protezione dell’IP del client. - Una variabile del pannello raggiunge solo i servizi il cui
environment:Compose la dichiara; Gotenberg mantiene il proprio livello di log fissato.