Installation
Mit Coolify installieren
Bereitstellung eines QuoteNode-Release in Coolify aus seinem Bereitstellungspaket: fertige Images per Digest festgelegt, nichts wird auf Ihrem Server gebaut.
Installation mit Coolify
Coolify bietet verwaltetes HTTPS, Integritätsprüfungen und eine Weboberfläche für Umgebungsvariablen. QuoteNode läuft in Coolify aus einem Release-Paket: einer Compose-Definition, die die getesteten Release-Images per Digest bezieht. Auf Ihrem Server wird nichts kompiliert, und jede Bereitstellung desselben Pakets startet genau dieselben Images.
Aktuelles Release: v0.9.8-alpha — Images für 64-Bit-x86-Server (linux/amd64).
Schritt 1 — Ressource anlegen
- Wählen Sie in Coolify New Resource → Docker Compose Empty.
- Fügen Sie die Coolify-Compose-Definition des Release unverändert ein. Sie steht auch als docker-compose.coolify.yml bereit:
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:
- Setzen Sie Ihre öffentliche Domain (zum Beispiel
https://crm.example.com) nur auf dem Dienst frontend.backend,backup-worker,postgresundgotenbergbleiben ohne Domain: Sie sind intern, und das Frontend ist der einzige Einstiegspunkt.
Schritt 2 — Umgebungsvariablen
Setzen Sie diese unter Environment Variables der Ressource. Es sind Laufzeitwerte: Die QuoteNode-Images sind bereits gebaut, daher muss nichts als Build-Argument übergeben werden.
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
Wenn der E-Mail-Versand aktiviert ist, setzen Sie zusätzlich:
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
Alle übrigen Einstellungen haben sichere Standardwerte in der Compose-Definition (zum Beispiel BACKUP_ENABLED=false, GEOIP_ENABLED=false). Die Erkennung der Client-IP funktioniert ohne Konfiguration: Das Frontend ist der vertrauenswürdige Proxy des Backends. Die vollständige Liste finden Sie unter Bereitstellungs-Umgebungsvariablen.
Nur Einstellungen, die die Compose-Definition deklariert, erreichen die Container; env.example ist der Katalog des Standalone-Pakets, in dem jede Einstellung die Anwendung erreicht. Für eine weitere Einstellung fügen Sie KEY: ${KEY} zum environment: des Dienstes hinzu, der sie liest (Edit Compose File; Anwendungseinstellungen: backend, Backup-Einstellungen auch backup-worker), setzen den Wert im Panel und wiederholen diese Ergänzung nach jedem Upgrade. So funktioniert eine Backup-Kopie außerhalb des Servers: BACKUP_RCLONE_REMOTE (falls die Definition es noch nicht deklariert) und die RCLONE_CONFIG_<NAME>_*-Einstellungen des Ziels auf backend und backup-worker.
Lassen Sie LICENSE_SKIP_SIGNATURE_CHECK ungesetzt oder false: In der Produktion startet die Anwendung mit dieser Option nicht. Einstellungen, die mit VITE_ beginnen, wirken bei einem Release nicht: Die Weboberfläche ist bereits gebaut, die Update-Prüfung zeigt auf quotenode.dev.
Bewahren Sie die Secrets außerhalb von Coolify auf.
DB_ENCRYPTION_KEYund die übrigen Secrets werden benötigt, um eine Sicherung auf einem neuen Server wiederherzustellen.
Schritt 3 — Wartezeit beim Stoppen
Setzen Sie in den Advanced-Einstellungen der Ressource die stop grace period auf 60 Sekunden. Coolify stoppt bei einer Bereitstellung die alten Container mit eigenem Zeitlimit (standardmäßig 30 Sekunden) und ignoriert den Wert aus der Compose-Datei. 60 Sekunden erlauben dem Backend, den E-Mail-Versand abzuschließen und Anfragen sauber zu beenden. Ältere Coolify-Versionen ohne diese Einstellung verwenden immer 30 Sekunden.
Schritt 4 — Bereitstellen und prüfen
Klicken Sie auf Deploy. Coolify lädt die Images und startet die Dienste; das Backend führt beim ersten Start die Datenbankmigrationen aus.
- Öffnen Sie
https://crm.example.com/health— die Antwort istUP. - Melden Sie sich mit
[email protected]/Admin123!an und setzen Sie ein neues Passwort, wenn Sie dazu aufgefordert werden. - Erstellen Sie ein Angebot, erzeugen Sie dessen PDF und öffnen Sie den öffentlichen Link von einem anderen Gerät.
- Wenn E-Mails aktiviert sind, senden Sie eine Testbenachrichtigung und öffnen den darin enthaltenen Einstellungslink.
Jede Bereitstellung erstellt alle Container der Ressource neu, auch die Datenbank; Ihre Daten bleiben in den Volumes. Rechnen Sie bei jeder Bereitstellung mit einer kurzen Unterbrechung.
Aktualisierung
Ersetzen Sie die Compose-Definition durch die docker-compose.coolify.yml des nächsten Release (Edit Compose File) und stellen Sie bereit. Coolify verwaltet Image-Downloads und den Container-Austausch; die Volumes behalten Ihre Daten. Prüfen Sie im Deployment-Log, ob Ihre Coolify-Version alle erforderlichen Images vor dem Stoppen der laufenden Container herunterlädt. Sichern Sie zuerst und lesen Sie QuoteNode aktualisieren.
Eine neuere Definition kann Einstellungen deklarieren, die die vorherige ignoriert hat. Entfernen Sie daher vor dem Bereitstellen Panel-Werte, die nicht wirksam werden sollen (zum Beispiel das env.example-Beispiel BACKUP_RCLONE_REMOTE=s3:mybucket/backups), und ergänzen Sie Ihre eigenen KEY: ${KEY}-Zeilen erneut.
Häufige Probleme mit Coolify
- Öffentliche Links funktionieren nicht, wenn
CORS_ALLOWED_ORIGINSnicht exakt dem Ursprung im Browser entspricht (Schema, ohne abschließenden Schrägstrich). - Einstellungslinks in E-Mails funktionieren nicht, wenn
NOTIFICATIONS_PUBLIC_PREFERENCES_URLnoch auf localhost oder eine alte Domain zeigt. - Von Coolify verwaltetes HTTPS setzt voraus, dass die Domain auf den Coolify-Server auflöst.
- Vergeben Sie keine Domain an
backend,postgres,gotenbergoderbackup-worker; ihre Freigabe umgeht das Routing des Frontends und den Schutz der Client-IP. - Eine Panel-Variable erreicht nur die Dienste, deren Compose-
environment:sie deklariert; Gotenberg behält seinen eigenen festgelegten Log-Level.