Instalacja
Instalacja z Coolify
Wdrożenie wydania QuoteNode w Coolify z pakietu wdrożeniowego: gotowe obrazy przypięte po digeście, bez budowania na serwerze.
Instalacja w Coolify
Coolify zapewnia zarządzane HTTPS, kontrole stanu i interfejs WWW do zmiennych środowiskowych. QuoteNode działa w Coolify z pakietu wydania: definicji Compose, która pobiera przetestowane obrazy wydania po digeście. Na Twoim serwerze nic nie jest kompilowane, a każde wdrożenie tego samego pakietu uruchamia dokładnie te same obrazy.
Bieżące wydanie: v0.9.8-alpha — obrazy dla serwerów 64-bitowych x86 (linux/amd64).
Krok 1 — Utwórz zasób
- W Coolify wybierz New Resource → Docker Compose Empty.
- Wklej bez zmian definicję Compose wydania dla Coolify. Jest też dostępna jako plik 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:
- Ustaw domenę publiczną (na przykład
https://crm.example.com) tylko na usłudze frontend. Usługibackend,backup-worker,postgresigotenbergzostaw bez domen: pozostają wewnętrzne, a frontend jest jedynym punktem wejścia.
Krok 2 — Zmienne środowiskowe
Ustaw je w zakładce Environment Variables zasobu. To wartości wykonawcze (runtime): obrazy QuoteNode są już zbudowane, więc nic nie trzeba przekazywać jako argumentu budowania.
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
Jeśli włączasz wysyłkę e-maili, ustaw także:
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
Pozostałe ustawienia mają bezpieczne wartości domyślne w definicji Compose (na przykład BACKUP_ENABLED=false, GEOIP_ENABLED=false). Wykrywanie adresu IP klienta działa bez konfiguracji: frontend jest zaufanym proxy backendu. Pełną listę znajdziesz w Zmiennych środowiskowych wdrożenia.
Do kontenerów trafiają tylko ustawienia zadeklarowane w definicji Compose; env.example to katalog pakietu samodzielnego, w którym każde ustawienie trafia do aplikacji. Aby użyć innego ustawienia, dodaj KEY: ${KEY} do sekcji environment: usługi, która je czyta (Edit Compose File; ustawienia aplikacji: backend, ustawienia kopii zapasowych także backup-worker), ustaw wartość w panelu i powtarzaj ten dodatek po każdej aktualizacji. Tak działa kopia zapasowa poza serwerem: BACKUP_RCLONE_REMOTE (jeśli definicja jeszcze go nie deklaruje) oraz ustawienia zdalnego celu RCLONE_CONFIG_<NAME>_* w backend i backup-worker.
LICENSE_SKIP_SIGNATURE_CHECK zostaw nieustawione albo false: w produkcji aplikacja nie uruchomi się z włączoną tą opcją. Ustawienia zaczynające się od VITE_ nie działają w wydaniu: interfejs WWW jest już zbudowany, a sprawdzanie aktualizacji wskazuje quotenode.dev.
Zachowaj sekrety poza Coolify.
DB_ENCRYPTION_KEYi pozostałe sekrety są potrzebne do odtworzenia kopii zapasowej na nowym serwerze.
Krok 3 — Czas na zatrzymanie
W ustawieniach Advanced zasobu ustaw stop grace period na 60 sekund. Podczas wdrożenia Coolify zatrzymuje stare kontenery z własnym limitem czasu (domyślnie 30 sekund) i ignoruje wartość z pliku Compose. 60 sekund pozwala backendowi dokończyć wysyłkę e-maili i poprawnie zamknąć żądania. Starsze wersje Coolify bez tego ustawienia zawsze używają 30 sekund.
Krok 4 — Wdróż i sprawdź
Kliknij Deploy. Coolify pobiera obrazy i uruchamia usługi; backend przy pierwszym starcie wykonuje migracje bazy danych.
- Otwórz
https://crm.example.com/health— odpowiedź toUP. - Zaloguj się jako
[email protected]/Admin123!i ustaw nowe hasło, gdy aplikacja o to poprosi. - Utwórz ofertę, wygeneruj jej PDF i otwórz link publiczny z innego urządzenia.
- Jeśli e-maile są włączone, wyślij testowe powiadomienie i otwórz zawarty w nim link do preferencji.
Każde wdrożenie odtwarza wszystkie kontenery zasobu, także bazę danych; dane zostają w wolumenach. Licz się z krótką przerwą przy każdym wdrożeniu.
Aktualizacja
Zastąp definicję Compose plikiem docker-compose.coolify.yml z kolejnego wydania (Edit Compose File) i wdróż. Coolify zarządza pobieraniem obrazów i wymianą kontenerów; wolumeny zachowują dane. Sprawdź w logu wdrożenia, czy Twoja wersja Coolify pobiera wszystkie wymagane obrazy przed zatrzymaniem działających kontenerów. Najpierw zrób kopię zapasową i przeczytaj Aktualizację QuoteNode.
Nowsza definicja może deklarować ustawienia, które poprzednia ignorowała, dlatego przed wdrożeniem usuń z panelu wartości, które nie powinny zacząć działać (na przykład przykładowe BACKUP_RCLONE_REMOTE=s3:mybucket/backups z env.example), i ponownie dodaj własne wpisy KEY: ${KEY}.
Typowe problemy w Coolify
- Linki publiczne nie działają, gdy
CORS_ALLOWED_ORIGINSnie zgadza się dokładnie z adresem w przeglądarce (schemat, bez końcowego ukośnika). - Linki do preferencji w e-mailach nie działają, gdy
NOTIFICATIONS_PUBLIC_PREFERENCES_URLwciąż wskazuje localhost albo starą domenę. - HTTPS zarządzane przez Coolify wymaga, aby domena wskazywała na serwer Coolify.
- Nie przypisuj domen usługom
backend,postgres,gotenberganibackup-worker; ich wystawienie omija routing frontendu i ochronę adresu IP klienta. - Zmienna z panelu trafia tylko do usług, których sekcja
environment:w Compose ją deklaruje; Gotenberg zachowuje własny, ustalony poziom logów.