Installation
Instalar con Coolify
Desplegar una versión de QuoteNode en Coolify desde su paquete de despliegue: imágenes precompiladas fijadas por digest, nada se compila en tu servidor.
Instalación con Coolify
Coolify ofrece HTTPS gestionado, comprobaciones de estado y una interfaz web para las variables de entorno. QuoteNode se ejecuta en Coolify a partir de un paquete de versión: una definición Compose que descarga las imágenes probadas de la versión por digest. No se compila nada en tu servidor, y cada despliegue del mismo paquete ejecuta exactamente las mismas imágenes.
Versión actual: v0.9.8-alpha — imágenes para servidores x86 de 64 bits (linux/amd64).
Paso 1 — Crear el recurso
- En Coolify elige New Resource → Docker Compose Empty.
- Pega sin cambios la definición Compose de la versión para Coolify. También está disponible como archivo 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:
- Configura tu dominio público (por ejemplo
https://crm.example.com) solo en el servicio frontend. Dejabackend,backup-worker,postgresygotenbergsin dominio: siguen siendo internos y el frontend es el único punto de entrada.
Paso 2 — Variables de entorno
Defínelas en Environment Variables del recurso. Son valores de ejecución: las imágenes de QuoteNode ya están construidas, así que no hay que pasar nada como argumento de compilación.
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 activas el envío de correos, define también:
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
El resto de ajustes tiene valores predeterminados seguros en la definición Compose (por ejemplo BACKUP_ENABLED=false, GEOIP_ENABLED=false). La detección de la IP del cliente funciona sin configuración: el frontend es el proxy de confianza del backend. La lista completa está en Variables de entorno del despliegue.
Solo los ajustes declarados en la definición Compose llegan a los contenedores; env.example es el catálogo del paquete independiente, donde cada ajuste llega a la aplicación. Para usar otro ajuste, añade KEY: ${KEY} al environment: del servicio que lo lee (Edit Compose File; ajustes de la aplicación: backend, ajustes de copias también backup-worker), define el valor en el panel y repite esa adición tras cada actualización. Así funciona una copia de seguridad fuera del servidor: BACKUP_RCLONE_REMOTE (si la definición aún no lo declara) y los ajustes RCLONE_CONFIG_<NAME>_* del destino en backend y backup-worker.
Deja LICENSE_SKIP_SIGNATURE_CHECK sin definir o en false: en producción la aplicación no arranca con esa opción activada. Los ajustes que empiezan por VITE_ no tienen efecto en una versión: la interfaz web ya está construida y la comprobación de actualizaciones apunta a quotenode.dev.
Guarda los secretos fuera de Coolify.
DB_ENCRYPTION_KEYy los demás secretos son necesarios para restaurar una copia de seguridad en un servidor nuevo.
Paso 3 — Tiempo de parada
En los ajustes Advanced del recurso, establece el stop grace period en 60 segundos. Al desplegar, Coolify detiene los contenedores antiguos con su propio límite de tiempo (30 segundos por defecto) e ignora el valor del archivo Compose. 60 segundos permiten al backend terminar de enviar correos y cerrar las peticiones correctamente. Las versiones antiguas de Coolify sin este ajuste usan siempre 30 segundos.
Paso 4 — Desplegar y comprobar
Haz clic en Deploy. Coolify descarga las imágenes e inicia los servicios; el backend ejecuta las migraciones de la base de datos en su primer arranque.
- Abre
https://crm.example.com/health— la respuesta esUP. - Inicia sesión con
[email protected]/Admin123!y define una contraseña nueva cuando se te pida. - Crea una oferta, genera su PDF y abre su enlace público desde otro dispositivo.
- Si los correos están activados, envía una notificación de prueba y abre el enlace de preferencias que contiene.
Cada despliegue recrea todos los contenedores del recurso, incluida la base de datos; tus datos permanecen en los volúmenes. Cuenta con una breve interrupción en cada despliegue.
Actualización
Sustituye la definición Compose por el docker-compose.coolify.yml de la versión siguiente (Edit Compose File) y despliega. Coolify gestiona la descarga de imágenes y la sustitución de contenedores; los volúmenes conservan tus datos. Comprueba en el registro que tu versión de Coolify descarga todas las imágenes necesarias antes de detener los contenedores en marcha. Haz antes una copia de seguridad y lee Actualizar QuoteNode.
Una definición más nueva puede declarar ajustes que la anterior ignoraba, así que antes de desplegarla elimina del panel los valores que no deban surtir efecto (por ejemplo el ejemplo BACKUP_RCLONE_REMOTE=s3:mybucket/backups de env.example) y vuelve a añadir tus propias líneas KEY: ${KEY}.
Problemas habituales con Coolify
- Los enlaces públicos fallan cuando
CORS_ALLOWED_ORIGINSno coincide exactamente con el origen del navegador (esquema, sin barra final). - Los enlaces de preferencias en los correos fallan cuando
NOTIFICATIONS_PUBLIC_PREFERENCES_URLsigue apuntando a localhost o a un dominio antiguo. - El HTTPS gestionado por Coolify requiere que el dominio apunte al servidor de Coolify.
- No asignes dominio a
backend,postgres,gotenbergnibackup-worker; exponerlos evita el enrutamiento del frontend y la protección de la IP del cliente. - Una variable del panel solo llega a los servicios cuyo
environment:de Compose la declara; Gotenberg mantiene su propio nivel de registro fijado.