Skip to content
Q
QuoteNode

Installation

Upgrading QuoteNode

How to check for new versions, upgrade to a release package, and roll back safely.

Upgrading QuoteNode

QuoteNode checks for new releases automatically. When a newer version is available, the admin panel shows a notification with the release label and a link to release notes.

Every release is published as a deployment package: docker-compose.yml (and docker-compose.coolify.yml) with the release images pinned by digest, env.example, release.json and the operator/ tools. Upgrading means replacing the Compose file with the one from the next package. The current release is v0.9.8-alpha (package).

How you upgrade depends on how QuoteNode was installed. The checklist and commands below are for an installation directory (server, LAN, laptop, or a NAS folder with docker-compose.yml and .env). A Portainer stack has no such directory: go to Portainer stack. Coolify: go to Coolify.

Known issue in v0.9.5-alpha: sample backup remote

The env.example of v0.9.5-alpha contains an active sample line, BACKUP_RCLONE_REMOTE=s3:mybucket/backups. That remote is not configured: when the line reaches your settings, every backup fails, and a failed upload keeps no local copy of that backup. The settings in these guides never contain it; check your installation if you built .env from env.example or added settings from it during an upgrade.

In an installation directory, comment out exactly that sample line (a real remote stays unchanged) and recreate the containers:

# Run in Bash (for example Linux, Git Bash or WSL); other shells can edit the exact placeholder manually.
(
  set -eu
  umask 077
  temporary_env="$(mktemp .env.backup-remote.XXXXXX)"
  trap 'rm -f "$temporary_env"' EXIT
  carriage_return="$(printf '\r')"
  # Match only the published placeholder, with either LF or Windows CRLF line endings.
  sample_pattern="^BACKUP_RCLONE_REMOTE=s3:mybucket/backups${carriage_return}\{0,1\}$"
  sed "s|${sample_pattern}|# &|" .env > "$temporary_env"
  if LC_ALL=C grep -Eq "^BACKUP_RCLONE_REMOTE=s3:mybucket/backups${carriage_return}?$" "$temporary_env"; then
    echo "Sample backup remote is still active; settings were not replaced." >&2
    exit 1
  else
    match_status=$?
    [ "$match_status" -eq 1 ] || exit "$match_status"
  fi
  # Keep real remote settings unchanged; publish a changed file atomically with private permissions.
  if ! cmp -s .env "$temporary_env"; then
    mv "$temporary_env" .env
  fi
  docker compose up -d
)

In Portainer, delete the stack variable BACKUP_RCLONE_REMOTE if it has that sample value, then update the stack. The v0.9.5-alpha Coolify definition does not pass this variable to the application; delete the sample value from the resource before you upgrade to a later release.

Backups then stay in the backups volume. Start a backup in the admin panel (Admin → Security → Backup), check that it completes successfully, and arrange an off-host copy: a configured rclone remote or regular copies of the archives to another machine (Backup & Recovery).

Upgrading to PostgreSQL 18

The Compose files already use postgres:18-alpine, so fresh installs need nothing. If you ran an earlier alpha on PostgreSQL 16, migrate the data once — a PostgreSQL 18 server cannot start on an older data directory, and the app never rewrites it automatically. Follow the dedicated, data-safe procedure: Migrating data to PostgreSQL 18 (host-CLI and UI-only paths, per platform).

How version checks work

The admin interface fetches https://quotenode.dev/releases/latest.json on admin login. This is a read-only, unauthenticated GET request; no data from your instance is sent. If the manifest is unreachable, the check fails silently and the admin panel works normally.

The check and its manifest URL are part of the prebuilt web interface. Setting VITE_RELEASE_CHECK_ENABLED or VITE_RELEASE_MANIFEST_URL in .env or in Coolify has no effect on a release; these values apply only when you build the frontend image yourself from source. To keep an installation from contacting quotenode.dev, block that address in your network.

Pre-upgrade checklist

For an installation directory; Portainer and Coolify have their own sections below.

  1. Note the current release: release.json in your installation directory, or Admin > License.
  2. Copy .env off the server — it contains secrets that cannot be regenerated (especially DB_ENCRYPTION_KEY).
  3. Download the new package next to the installation. This changes nothing that is running:
mkdir -p ../next && cd ../next
curl -fsSLO https://quotenode.dev/releases/v0.9.8-alpha/quotenode-deploy-v0.9.8-alpha.tar.gz
tar -xzf quotenode-deploy-v0.9.8-alpha.tar.gz --strip-components=1
cd -
# Settings added or removed by the new release? Compare your .env with the new template.
diff <(grep -o '^[A-Z_]*=' .env | sort) <(grep -o '^[A-Z_]*=' ../next/env.example | sort)
  1. Add the settings the new release needs to .env (the comparison above lists them; installations created before release packages: see Installations created before release packages). The running installation does not use them yet.
  2. Create the upgrade checkpoint and a verified backup with the new package’s tool. This changes nothing that is running and stops at the first error:
BACKUP_ARCHIVE_ENCRYPTION_MODE=AGE_RECIPIENT BACKUP_AGE_RECIPIENT=age1... \
  ../next/operator/upgrade.sh prepare --export-to ../quotenode-backups

The checkpoint (in .quotenode-upgrade/, used only by this installation) records the installed docker-compose.yml, .env and release.json, the identity of the running images and the database schema state. The backup is made with the new release’s own tools on this installation’s Compose project and volumes, stays local for this run even if remote backups are configured, and is kept in ../quotenode-backups only after its SHA-256 matches. Copy that directory off the server before you continue. The age recipient comes from your Recovery Kit (Backup & Recovery).

  1. If scheduled backups are enabled, also check that the latest one succeeded (admin panel).

Upgrade procedure

Docker Compose (server, LAN, laptop, NAS folder)

After the checklist, run in the installation directory, so the Compose project and its volumes stay the same:

../next/operator/upgrade.sh apply

upgrade.sh apply downloads every image of the new release first and stops at the first error: if a download fails, the installation keeps running unchanged. Only then does it install the package’s docker-compose.yml, release.json, env.example and operator/, start the new version and wait until it is healthy. Database migrations run automatically when the new backend starts. Docker Compose 2.17 or newer is required.

Portainer stack

upgrade.sh needs an installation directory, so a Portainer stack is upgraded in Portainer:

  1. Check that the latest scheduled backup succeeded (admin panel), back up the stack’s volumes with your platform’s snapshot or backup tool, and save a copy of the current stack definition and its environment variables.
  2. Open the stack’s Editor and replace the definition with the block below: the Portainer adaptation of the release’s docker-compose.yml, the same block as in Install with Portainer. Do not paste the package’s original file: it reads .env, which Portainer does not have.
  3. Keep the stack name and its environment variables (stack.env); add the settings the new release needs (compare with its env.example).
  4. Click Update the stack with image pulling enabled. Database migrations run when the new backend starts.
# 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:

To roll back, restore the volume backup together with the saved stack definition. Never start the previous definition on a database the new release has already migrated.

Coolify

Replace the resource’s Compose definition with the new package’s docker-compose.coolify.yml (Edit Compose File) and deploy. Coolify pulls the new images before it stops the running containers. See Install with Coolify.

Installations created before release packages

If you installed QuoteNode from these guides before release packages existed (a handwritten docker-compose.yml with :latest images), upgrade in the same directory with the same commands: upgrade.sh takes the backup with the new release’s own tools on your installation’s Compose project and volumes, so no operator/ directory or backup worker is needed beforehand. Before prepare, add these settings to .env; the running installation does not use them yet:

  • DOMAIN now configures the new entry point (caddy): a public host name gets HTTPS automatically; for plain HTTP on a LAN or laptop use DOMAIN=http://<address> (for example http://192.168.1.20 or http://localhost).
  • If ports 80/443 are taken, set HTTP_PORT / HTTPS_PORT instead of editing the Compose file.

The package keeps the volume names of the earlier guides (postgres_data, backend_uploads, backend_pdfs), so your data is reused. Run upgrade.sh from the same directory (or keep the same Portainer stack name): a different project name would start with empty volumes.

If a migration stops the upgrade

A migration can refuse to run when it finds data it is not allowed to rewrite on its own. The backend then does not start and that migration changes nothing — it rolls back completely, leaving nothing to repair in the migration history. Earlier migrations from the same upgrade may already have been applied, so your database sits just before the refused step rather than back at the version you started from. There is no rush and no data loss.

Read the reason in the log:

docker compose logs backend | grep -i "cannot enforce"

For offer section assignments, a guided repair ships in the image and runs without a working backend — it shows the affected offers and lets you choose what happens to them. See Upgrade stops with “cannot enforce offer-section ownership”.

Do not use flyway repair or baseline to get past a refused migration. They do not fix the data, and a blocked migration leaves nothing to repair.

Because earlier migrations may already be applied, a failed start alone never allows returning to the previous images — see Rolling back.

Post-upgrade verification

  1. Open /health — it returns UP.
  2. Open /actuator/info: its commitSha equals commitSha in the new release.json.
  3. Log in and check Admin > License for the new version.
  4. Create a test offer and generate a PDF.
  5. Open a public link to verify routing.
  6. If SMTP is configured, send a test notification email.

Rolling back

Never run docker compose down -v — it deletes all data, including the database.

upgrade.sh rollback returns to the definition saved by prepare only when that is safe: the checkpoint must belong to this installation, the database schema must be exactly as recorded — a failed start does not prove that, earlier migrations may already be committed — and every previous image must still be the one that ran. Otherwise it refuses and changes nothing:

../next/operator/upgrade.sh rollback
  • Allowed: the saved docker-compose.yml, .env and release.json come back and the previous images start without pulling; services added by the new release are removed, volumes are kept.
  • Refused: do not start the previous images by hand. If a migration refused, fix forward (see If a migration stops the upgrade) or restore the pre-upgrade backup — database and files as one unit — as described in Backup & Recovery. The restore checks the archive and its compatibility before it changes anything and keeps a safety copy until it has verified the result.
  • Installations created before release packages roll back the same way. After a refusal, restore the backup from ../quotenode-backups with this release’s operator/restore.sh; the release upgrades the restored data when it starts.

Version format

QuoteNode uses semantic versioning with a channel suffix:

  • v0.9.0-alpha — early development
  • v0.9.0-beta — public beta
  • v0.9.0-rc — release candidate
  • v0.9.0 — stable release

A release label is never reused for different images. release.json and https://quotenode.dev/releases/latest.json list the version, channel, label, commit and the image digests of the release.

Last reviewed: Recently