Installation
Instalar com Coolify
Implantar uma versão do QuoteNode no Coolify a partir do seu pacote de implantação: imagens prontas fixadas por digest, nada é compilado no seu servidor.
Instalação com o Coolify
O Coolify oferece HTTPS gerido, verificações de estado e uma interface web para as variáveis de ambiente. O QuoteNode corre no Coolify a partir de um pacote de versão: uma definição Compose que obtém as imagens testadas da versão por digest. Nada é compilado no teu servidor, e cada implementação do mesmo pacote executa exatamente as mesmas imagens.
Versão atual: v0.9.8-alpha — imagens para servidores x86 de 64 bits (linux/amd64).
Passo 1 — Criar o recurso
- No Coolify escolhe New Resource → Docker Compose Empty.
- Cola sem alterações a definição Compose da versão para o Coolify. Também está disponível como ficheiro 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:
- Define o teu domínio público (por exemplo
https://crm.example.com) apenas no serviço frontend. Deixabackend,backup-worker,postgresegotenbergsem domínio: continuam internos e o frontend é o único ponto de entrada.
Passo 2 — Variáveis de ambiente
Define-as em Environment Variables do recurso. São valores de execução: as imagens do QuoteNode já estão construídas, por isso nada tem de ser passado como argumento de compilação.
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 ativares o envio de e-mails, define também:
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
As restantes definições têm valores predefinidos seguros na definição Compose (por exemplo BACKUP_ENABLED=false, GEOIP_ENABLED=false). A deteção do IP do cliente funciona sem configuração: o frontend é o proxy de confiança do backend. A lista completa está em Variáveis de ambiente da implementação.
Só as definições declaradas na definição Compose chegam aos contentores; o env.example é o catálogo do pacote autónomo, em que cada definição chega à aplicação. Para usar outra definição, acrescenta KEY: ${KEY} ao environment: do serviço que a lê (Edit Compose File; definições da aplicação: backend, definições de cópias também backup-worker), define o valor no painel e repete esse acréscimo após cada atualização. É assim que funciona uma cópia de segurança fora do servidor: BACKUP_RCLONE_REMOTE (se a definição ainda não o declarar) e as definições RCLONE_CONFIG_<NAME>_* do destino em backend e backup-worker.
Deixa LICENSE_SKIP_SIGNATURE_CHECK por definir ou em false: em produção a aplicação não arranca com essa opção ativa. As definições que começam por VITE_ não têm efeito numa versão: a interface web já está construída e a verificação de atualizações aponta para quotenode.dev.
Guarda os segredos fora do Coolify.
DB_ENCRYPTION_KEYe os restantes segredos são necessários para restaurar uma cópia de segurança num servidor novo.
Passo 3 — Tempo de paragem
Nas definições Advanced do recurso, define o stop grace period para 60 segundos. Numa implementação, o Coolify para os contentores antigos com o seu próprio limite de tempo (30 segundos por predefinição) e ignora o valor do ficheiro Compose. 60 segundos permitem ao backend terminar o envio de e-mails e fechar os pedidos corretamente. Versões mais antigas do Coolify sem esta definição usam sempre 30 segundos.
Passo 4 — Implementar e verificar
Clica em Deploy. O Coolify obtém as imagens e inicia os serviços; no primeiro arranque, o backend executa as migrações da base de dados.
- Abre
https://crm.example.com/health— a resposta éUP. - Inicia sessão com
[email protected]/Admin123!e define uma nova palavra-passe quando te for pedido. - Cria uma proposta, gera o respetivo PDF e abre a ligação pública noutro dispositivo.
- Se os e-mails estiverem ativos, envia uma notificação de teste e abre a ligação de preferências que contém.
Cada implementação recria todos os contentores do recurso, incluindo a base de dados; os teus dados ficam nos volumes. Conta com uma breve interrupção em cada implementação.
Atualização
Substitui a definição Compose pelo docker-compose.coolify.yml da versão seguinte (Edit Compose File) e implementa. O Coolify gere a transferência das imagens e a substituição dos contentores; os volumes mantêm os dados. Verifica no registo se a tua versão do Coolify transfere todas as imagens necessárias antes de parar os contentores em execução. Faz primeiro uma cópia de segurança e lê Atualizar o QuoteNode.
Uma definição mais recente pode declarar definições que a anterior ignorava, por isso, antes de a implementar, remove do painel os valores que não devem produzir efeito (por exemplo o exemplo BACKUP_RCLONE_REMOTE=s3:mybucket/backups do env.example) e volta a acrescentar as tuas próprias linhas KEY: ${KEY}.
Problemas comuns com o Coolify
- As ligações públicas falham quando
CORS_ALLOWED_ORIGINSnão corresponde exatamente à origem do navegador (esquema, sem barra final). - As ligações de preferências nos e-mails falham quando
NOTIFICATIONS_PUBLIC_PREFERENCES_URLainda aponta para localhost ou para um domínio antigo. - O HTTPS gerido pelo Coolify exige que o domínio aponte para o servidor do Coolify.
- Não atribuas domínio a
backend,postgres,gotenbergnembackup-worker; expô-los contorna o encaminhamento do frontend e a proteção do IP do cliente. - Uma variável do painel só chega aos serviços cujo
environment:do Compose a declara; o Gotenberg mantém o seu próprio nível de registo fixado.