Ir para o conteúdo
Q
QuoteNode

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

  1. No Coolify escolhe New Resource → Docker Compose Empty.
  2. 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:
  1. Define o teu domínio público (por exemplo https://crm.example.com) apenas no serviço frontend. Deixa backend, backup-worker, postgres e gotenberg sem 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_KEY e 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.

  1. Abre https://crm.example.com/health — a resposta é UP.
  2. Inicia sessão com [email protected] / Admin123! e define uma nova palavra-passe quando te for pedido.
  3. Cria uma proposta, gera o respetivo PDF e abre a ligação pública noutro dispositivo.
  4. 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_ORIGINS não corresponde exatamente à origem do navegador (esquema, sem barra final).
  • As ligações de preferências nos e-mails falham quando NOTIFICATIONS_PUBLIC_PREFERENCES_URL ainda 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, gotenberg nem backup-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.

Last reviewed: Recently