Installation
Install with Portainer, QNAP, or NAS
Deploy QuoteNode on a NAS or Portainer-managed Docker host with full setup instructions.
Install with Portainer, QNAP, or NAS
Use this path if you run Docker through Portainer, QNAP Container Station, Synology, or a similar platform. The exact UI differs by vendor, but the underlying Docker Compose stack is the same. The steps use Portainer’s Web editor; a platform that runs Compose from a folder uses the unchanged release package instead (Step 2).
When to use it
- You already operate Docker stacks through a container management UI.
- You understand how the platform maps volumes and ports.
- You can verify backups outside the NAS UI.
What may not work
- Public links do not work outside the LAN unless NAT, DNS, proxy, and HTTPS are configured.
- NAS sleep modes can interrupt service.
- Some NAS Docker UIs rewrite compose files or environment variables — verify the rendered stack before relying on it.
- Backups stored only on the same NAS do not protect against NAS failure.
Prerequisites
- Docker Compose v2 support in your platform.
- Persistent volume support for PostgreSQL data, uploads, PDFs, and backups.
- At least 2 GB RAM available and 10 GB free disk space.
- A stable URL: LAN IP for internal use, or a public domain behind a reverse proxy for customer links.
- A 64-bit x86 host (linux/amd64). ARM devices, including ARM-based NAS models, are not supported.
Step 1 — Prepare the settings
Prepare the settings below. Replace the URL with your actual NAS address or public domain. The secret values below are filled with fresh random values in your browser on each page load — use the ↻ New secrets button on the block to regenerate them, then copy the result. (Prefer to generate them yourself? See Deployment environment variables.) In Portainer they become the stack’s environment variables (Step 2), which Portainer stores as stack.env; in a folder-based installation they are the .env file next to docker-compose.yml.
For an internal NAS-only deployment:
# Database
DB_URL=jdbc:postgresql://postgres:5432/quotenode
DB_USERNAME=quotenode
DB_PASSWORD=change-me-to-something-random-32-chars
DB_NAME=quotenode
# ============================================================
# SECURITY SECRETS — UNIQUE to this installation.
# SAVE THIS FILE and keep a secure backup.
# ============================================================
DB_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
TIMING_TOKEN_SECRET=change-me-timing-secret-min-32-chars
PUBLIC_LINK_PASSWORD_SESSION_SECRET=change-me-session-secret-min-32
# Internal NAS URL
CORS_ALLOWED_ORIGINS=http://nas.local
DOMAIN=http://nas.local
# Client IP is auto-detected behind internal proxies and CDNs (Cloudflare included) — leave empty.
# Set only for an unusual proxy whose transport peer is a PUBLIC address to trust (IP/CIDR or hostname).
SECURITY_TRUSTED_PROXIES=
# Application
LOG_LEVEL=INFO
SPRING_PROFILES_ACTIVE=prod
For a public domain behind your own reverse proxy (here at 192.168.1.5) that terminates HTTPS:
CORS_ALLOWED_ORIGINS=https://crm.example.com
DOMAIN=:80
EDGE_TRUSTED_PROXIES=192.168.1.5
Save a copy of these settings immediately, outside the NAS. If you lose
DB_ENCRYPTION_KEY, encrypted fields become permanently unreadable.
Step 2 — Create the stack
In Portainer, open Stacks → Add stack, name the stack (for example quotenode) and choose Web editor. The stack name becomes the Compose project name and prefixes the volume names, so keep it when you update the stack later. Paste the block below: the docker-compose.yml of release v0.9.8-alpha (every image pinned by digest) with one Portainer adaptation, explained in its first comment lines — env_file reads stack.env instead of .env. Images, services, ports and volumes are unchanged.
# Portainer Web editor adaptation of the release docker-compose.yml: env_file reads stack.env (the stack's
# environment variables) instead of .env in postgres, backend and backup-worker. Images, services, ports and
# volumes are unchanged. For a folder-based installation use the unchanged file of the release package.
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 — standalone deployment (docker compose)
# 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
#
# Settings: the .env file in this directory (start from env.example, the commented catalogue of every
# setting; postgres, backend and backup-worker read it). Start: docker compose up -d
# DOMAIN: public host name for automatic HTTPS, or :80 / http://<address> for plain HTTP on a trusted LAN.
# Only the caddy entry point publishes ports (HTTP_PORT/HTTPS_PORT, default 80/443).
# Upgrades: replace this file with the next release package version; volumes keep the data.
# ==========================================================================
services:
postgres:
# Database (PostgreSQL 18); internal network only, data in the database volume.
image: postgres:18-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873
# JIT is off by default: for this OLTP workload most queries fall below jit_above_cost,
# so JIT compilation rarely helps and can add latency. Set PG_JIT=on to re-enable.
command: ["postgres", "-c", "jit=${PG_JIT:-off}"]
env_file: stack.env
environment:
POSTGRES_DB: ${DB_NAME}
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_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
# CPU ceilings are opt-in (*_CPUS): 0 = no ceiling, all host cores. Docker refuses a ceiling above the
# host's core count, so a fixed default would stop the stack on smaller hosts (minimum: 2 cores).
cpus: ${POSTGRES_CPUS:-0}
# Ceiling, not a reservation: PG sits at tens of MB until working set/connections grow.
# Default 512m fits the 4 GB-VM profile; raise POSTGRES_MEM_LIMIT for larger tiers.
mem_limit: ${POSTGRES_MEM_LIMIT:-512m}
restart: unless-stopped
logging: *json-file-logging
# No published host port: the database is reachable only on the internal network.
gotenberg:
# PDF rendering (Chromium) for the backend; internal network only, keeps no data.
image: gotenberg/gotenberg:8@sha256:aa552cc115db488e0f0bcb07446aac13071ad51505b642c84bb894500907b42b
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
cpus: ${GOTENBERG_CPUS:-0}
# Chromium PDF rendering: measured ~188 MiB peak rendering a real offer template, so
# 448m keeps ~2.4x headroom. Freed budget goes to backup-worker (closer to its limit).
mem_limit: ${GOTENBERG_MEM_LIMIT:-448m}
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
env_file: stack.env
environment:
SPRING_PROFILES_ACTIVE: prod
JOBS_MODE: web
# The caddy edge below is the backend's only HTTP peer, so it is the trusted proxy by default (security
# finding #80); without it every request would appear to come from the edge container. Override only
# for a custom proxy chain.
SECURITY_TRUSTED_PROXIES: ${SECURITY_TRUSTED_PROXIES:-caddy}
BACKUP_LOCAL_DIR: /app/data/backups
volumes:
- backend_uploads:/app/data/uploads
- backend_pdfs:/app/data/pdfs
- backend_backups:/app/data/backups
- backend_geoip:/app/data/geoip
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 15s
timeout: 10s
retries: 5
start_period: 45s
cpus: ${BACKEND_CPUS:-0}
# Heap auto-scales to 70% of this limit (MaxRAMPercentage); default 1536m → ~1 GB heap,
# fits the 4 GB-VM profile. Raise BACKEND_MEM_LIMIT for heavier load (see resource docs).
mem_limit: ${BACKEND_MEM_LIMIT:-1536m}
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
env_file: stack.env
environment:
SPRING_PROFILES_ACTIVE: prod
JOBS_MODE: backup-only
BACKUP_LOCAL_DIR: /app/data/backups
# Worker runs pg_dump in a shell (out-of-JVM) and never does web/import heap work,
# so it needs little heap. On this small 768m container the ~350 MB fixed non-heap
# cost makes the global MaxRAMPercentage=70 (538 MB heap → 888 MB total) unsafe.
# Cap heap lower so heap_max + non-heap stays well under the limit.
JAVA_OPTS: "-XX:MaxRAMPercentage=45.0 -XX:+UseG1GC -XX:G1PeriodicGCInterval=300000"
volumes:
- backend_uploads:/app/data/uploads
- backend_pdfs:/app/data/pdfs
- backend_backups:/app/data/backups
- backend_geoip:/app/data/geoip
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 15s
timeout: 10s
retries: 5
# JVM boots a large Spring context (CPU-bound); the grace window must outlast
# cold start to avoid a false-unhealthy restart loop under load.
start_period: 300s
# CPU is a ceiling (idle cost ~0); give the worker enough to boot fast and to run
# pg_dump + age-encryption/compression bursts without throttling. Idles near zero CPU.
cpus: ${WORKER_CPUS:-0}
# Default 832m: measured backup peak (JVM idle + pg_dump + tar + gzip) is ~607 MiB, so
# this keeps a ~27% margin. Do NOT set WORKER_MEM_LIMIT below ~768m or backups risk OOM.
mem_limit: ${WORKER_MEM_LIMIT:-832m}
restart: unless-stopped
logging: *json-file-logging
frontend:
# Web UI: the static build served by Caddy, reached through the caddy entry point.
image: ghcr.io/lesisty7/quotenode/quotenode-frontend@sha256:40a1cdc4fca5cebd9f43d34daef4ba059a88000ff08af821e199566f07260606
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:80/"]
interval: 15s
timeout: 5s
retries: 3
cpus: ${FRONTEND_CPUS:-0}
# Prod frontend is Caddy serving the static build (measured ~46 MiB peak under load); 96m is ample.
# CPU is a free ceiling, so headroom for concurrent connections costs nothing at idle.
mem_limit: ${FRONTEND_MEM_LIMIT:-96m}
restart: unless-stopped
logging: *json-file-logging
caddy:
# Entry point: the only published ports (HTTP_PORT/HTTPS_PORT, default 80/443), TLS for DOMAIN, routing, security headers.
image: ghcr.io/lesisty7/quotenode/quotenode-frontend@sha256:40a1cdc4fca5cebd9f43d34daef4ba059a88000ff08af821e199566f07260606
# The released frontend image carries the edge config (frontend/caddy/Caddyfile.edge) and the Cloudflare
# ranges, so the edge needs no host files. Same image digest as the frontend service.
command: ["caddy", "run", "--config", "/etc/caddy/Caddyfile.edge", "--adapter", "caddyfile"]
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
environment:
# Site address for frontend/caddy/Caddyfile.edge: a public host name enables automatic HTTPS; ":80" or
# "http://host" serves plain HTTP on a trusted LAN. Required: an empty site address is not a valid config.
DOMAIN: ${DOMAIN:?set DOMAIN to the public host name, or :80 for plain HTTP on a LAN}
# Optional: address(es) of an operator reverse proxy in front of this edge (default: trust none).
EDGE_TRUSTED_PROXIES: ${EDGE_TRUSTED_PROXIES:-}
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
- "${HTTPS_PORT:-443}:443/udp" # HTTP/3 (QUIC)
volumes:
- caddy_data:/data
- caddy_config:/config
# caddy is the single ingress (TLS, HTTP/3/QUIC, proxying all traffic); give it
# cores for handshake/encryption bursts under load. CPU idle cost ~0.
cpus: ${CADDY_CPUS:-0}
# 96m fits the 4 GB / light-traffic profile. Under heavy TLS load (many concurrent
# handshakes, large QUIC buffers) raise CADDY_MEM_LIMIT — this ingress is untested at scale.
mem_limit: ${CADDY_MEM_LIMIT:-96m}
restart: unless-stopped
logging: *json-file-logging
# Persistent data: database, uploads, generated PDFs, local backup archives, GeoIP database and the entry
# point's certificates/config. Docker prefixes each name with the Compose project name (by default the
# directory name), so keep the project name across upgrades.
volumes:
postgres_data:
backend_uploads:
backend_pdfs:
backend_backups:
backend_geoip:
caddy_data:
caddy_config:
Under Environment variables, switch to Advanced mode and paste the settings from Step 1.
Do not paste or upload the downloaded file in Portainer. The unchanged release file reads
.env, which Portainer does not create, so the deployment stops withenv file … .env not found.
Folder-based installation (SSH, or a NAS UI that runs Compose from a folder you control): download the release package as in the Linux LAN guide, keep its docker-compose.yml unchanged (download) and save the settings as .env in the same folder.
Known issue in v0.9.5-alpha: its
env.examplecontains an active sample line,BACKUP_RCLONE_REMOTE=s3:mybucket/backups, that makes every backup fail. The settings in this guide do not contain it; if you copy settings fromenv.example, leave that line out (details).
DOMAIN selects how the caddy entry point serves QuoteNode: a public host name (crm.example.com) gets an automatic HTTPS certificate; http://<address> or :80 serves plain HTTP on a trusted network. If ports 80/443 are already in use, set HTTP_PORT / HTTPS_PORT in the settings. Behind your own reverse proxy, set DOMAIN=:80 and list the proxy address in EDGE_TRUSTED_PROXIES.
Port conflicts: If your NAS already uses port 80 or 443, set
HTTP_PORT=8080(andHTTPS_PORT) in the settings and use that port inCORS_ALLOWED_ORIGINS(e.g.http://nas.local:8080).
Step 3 — Deploy and verify
In Portainer, click Deploy the stack, then open the logs of the backend container. In a folder-based installation run:
docker compose up -d
docker compose logs -f backend
Wait until you see Started QuoteNodeApplication.
Step 4 — Open QuoteNode
Navigate to your NAS URL (e.g. http://nas.local or https://crm.example.com).
Default login credentials:
| Field | Value |
|---|---|
[email protected] |
|
| Password | Admin123! |
Change the default password immediately after logging in.
Persistence checklist
Verify these are mapped to persistent volumes (not ephemeral container storage):
| Data | Why it matters |
|---|---|
| PostgreSQL data | Main application database |
| Uploads | Product images, logos, attachments |
| Generated PDFs | Offer and document files |
| Backups | Local backup archives (if backup worker is enabled) |
| GeoIP database | Optional access-control data |
What is running?
| Container | What it does | RAM usage |
|---|---|---|
postgres |
Stores all your data | ~256 MB |
backend |
Java API server, business logic | ~512 MB – 1.5 GB |
gotenberg |
Converts HTML to PDF (Chromium-based) | ~200 MB |
frontend |
Serves the web interface | ~20 MB |
backup-worker |
Runs scheduled backups (same image as the backend) | ~300 MB |
caddy |
Entry point: HTTPS certificates, routing, security headers | ~20 MB |
Total: approximately 1.5 – 2.5 GB RAM.
Keeping your secrets safe
| Secret | What it protects | If lost |
|---|---|---|
DB_PASSWORD |
Database access | Recoverable — reset via PostgreSQL admin tools |
DB_ENCRYPTION_KEY |
MFA codes, SMTP password, FX API key, PII fields | Permanently unreadable |
TIMING_TOKEN_SECRET |
Anti-timing-attack tokens | Regenerate — users re-authenticate |
PUBLIC_LINK_PASSWORD_SESSION_SECRET |
Public link sessions | Regenerate — active sessions expire |
Keep a copy of the settings (
.envor the stack’s environment variables) outside the NAS. A NAS failure that destroys your only copy ofDB_ENCRYPTION_KEYmakes encrypted data unrecoverable.
Recommended next steps
- Configure backups to a location outside the NAS.
- Test PDF generation after first deployment.
- If you want a smoother public deployment path, consider Coolify on a VPS.