Skip to content
Q
QuoteNode

Installation

Installation Troubleshooting

Common QuoteNode deployment problems with links, CORS, SMTP, DNS, Docker health checks, and lost secrets.

Installation Troubleshooting

Most installation problems come from URL mismatch, missing SMTP, blocked ports, or lost secrets. Start with the visible URL in your browser and work inward.

Check what URL was copied or sent.

  • http://localhost/... works only on the same machine.
  • http://192.168.x.x/... works only on the LAN or VPN. VPN can be a valid bridge for internal teams, but it is not a public customer-link strategy.
  • https://crm.example.com/... works publicly only when DNS, HTTPS, proxy, and firewall are correct.

For public customer links, deploy with a public domain using Ubuntu public server or Coolify.

Browser shows CORS errors

CORS_ALLOWED_ORIGINS must match the browser origin exactly. These are different origins:

https://crm.example.com
https://www.crm.example.com
http://crm.example.com
https://crm.example.com:8443

Fix the variable, redeploy, and retry in a fresh browser session.

Email is not delivered

Check these first:

  • SMTP_HOST and SMTP_PORT are correct.
  • SMTP_AUTH=true when your provider requires authentication.
  • SMTP_STARTTLS=true for port 587.
  • SMTP_USERNAME and SMTP_PASSWORD are valid.
  • The provider allows SMTP/app-password login.
  • NOTIFICATIONS_EMAIL_ENABLED=true when notification emails should be sent.

Manual public-link sharing and PDF download can still work without SMTP.

Set:

NOTIFICATIONS_PUBLIC_PREFERENCES_URL=https://<frontend-domain>/notifications/preferences

Do not leave it pointing to localhost in a public deployment.

Docker containers are unhealthy

Run:

docker compose ps
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f gotenberg

Common causes:

  • PostgreSQL is still starting.
  • DB_PASSWORD differs between PostgreSQL and backend.
  • DB_ENCRYPTION_KEY is missing or malformed.
  • Gotenberg is not healthy, so PDF generation cannot start.
  • Host ports 80/443 are already used by another service.

After fixing configuration, restart the affected service or the stack:

docker compose restart backend
docker compose up -d

My IP is blocked — cannot log in

There are two common causes: the IP allowlist locked you out, or brute-force protection blocked your IP after too many failed logins.

IP allowlist lockout

QuoteNode has an IP allowlist configured in Settings > Security > IP Allowlist (or via the SECURITY_IP_WHITELIST environment variable). When enabled it gates the whole admin surface — including the login and 2FA screens — so an excluded address gets 403 IP_NOT_ALLOWED and cannot even sign in. (Health checks and customer-facing public offer links stay reachable.)

First, wait two minutes if you just changed it in the app. An in-app enable or narrow is lockout-protected: it must be re-confirmed by your session within a short window (default 120 s), and if your current address is not covered it rolls back automatically to the previous allowlist. So an accidental self-lockout from the Security screen heals itself — just wait and retry. (This safety net does not apply to the SECURITY_IP_WHITELIST environment variable, which is enforced at startup.)

Recovery (operator override): if you are still locked out — e.g. an operator SECURITY_IP_WHITELIST, an invalid stored list, or you cannot wait — add this variable to the backend container environment (.env or docker-compose environment: section) and restart:

SECURITY_IP_WHITELIST_FORCE_DISABLE=true
docker compose restart backend

This bypasses the allowlist entirely and lets you log in. Once inside, fix the allowlist in Settings > Security, then remove SECURITY_IP_WHITELIST_FORCE_DISABLE and restart again.

Do not leave SECURITY_IP_WHITELIST_FORCE_DISABLE=true in production. It disables the entire IP access control.

Brute-force protection lockout

QuoteNode blocks IPs that fail too many login attempts within one hour:

Threshold Failures in 1 h Effect
Soft 10 Progressive delay (1 s per extra attempt, max 30 s)
Temp lock 20 Locked for 5 minutes
Hard lock 50 Locked until manually cleared

How to unblock:

  1. Admin panel — another administrator opens Settings > Security Dashboard > Blocked IPs and clicks Unblock.
  2. Wait — temp locks expire after SECURITY_TEMP_LOCK_MINUTES (default 5 minutes). Hard locks do not expire on their own.
  3. Raise thresholds temporarily if locked out and no other admin can help. Add to .env, restart, log in, then remove:
SECURITY_MAX_FAILED_LOGINS_SOFT=9999
SECURITY_MAX_FAILED_LOGINS_TEMP_LOCK=9999
SECURITY_MAX_FAILED_LOGINS_HARD_LOCK=9999

Reset these to their defaults after regaining access.

The public link rate limiter blocks IPs that exceed probe or failure limits. These blocks are in-memory and expire after PUBLIC_RATE_LIMIT_IP_BLOCK_MINUTES (default 60 minutes) or after a backend restart.

Upgrade stops with “cannot enforce offer-section ownership”

After an upgrade the backend does not start, and docker compose logs backend shows:

Migration V145 cannot enforce offer-section ownership: 3 offer_items row(s) point at a
section of another offer or tenant.

What this means

An offer is built from sections (“Hardware”, “Services”) and the items placed in them. This version makes that link strict: an item may only sit in a section of its own offer. A few old rows in your database break that rule, so the upgrade stops and asks you to decide what those items should do instead of rewriting your offers on its own.

Nothing is broken and nothing was changed by the refusal. This one migration rolled back completely — no half-applied change and nothing to repair in the migration history. Note that earlier migrations from the same upgrade may already have been applied; your database is at the point just before this step, not necessarily back at the version you started from. That also means the previous containers may or may not run against it, depending on what those earlier migrations changed — do not count on rolling back the images as a way out.

Choose what should happen

Option What it does What changes for you
Check the problem (default) Lists the affected offers and items. Nothing. Read-only.
Unlink the wrong assignments Clears the section of only those items. Those items stay in their offer with the same price, quantity and description. They lose their section heading and appear as ungrouped lines.
Pick a section for each item Asks you per item and offers only sections of that item’s own offer. You keep the grouping you want. Items you skip stay unresolved and the upgrade will stop again.

Offers already sent to customers keep showing exactly what the customer saw — a sent offer’s content is frozen when it is sent, so neither the public link nor the PDF changes.

Find the right installation first (Coolify, or several stacks on one server)

Every command below has to run against your installation. One server can host several, and more than one of them can have a service called backend, so list the candidates first — each line is one installation, with its project name and the Compose file it was started from:

docker inspect $(docker ps -aq --filter "label=com.docker.compose.service=backend" \
                             --filter "label=com.docker.compose.oneoff=False") \
  --format '{{index .Config.Labels "com.docker.compose.project"}} | {{index .Config.Labels "com.docker.compose.project.config_files"}}'

If you get more than one line, pick the one that is yours; do not guess. Then run every command with that project and file named explicitly, instead of a bare docker compose, so you cannot act on the wrong installation:

docker compose -p <project> -f <compose-file> run --rm backend --repair-offer-sections

Replace <project> and <compose-file> with the values from your line, and keep them on every command below — that is what makes each one act on your installation and not another. If the line shows several files separated by commas, repeat -f for each one, in that order. If the list is empty, the containers were not started by Docker Compose — use the directory your provider or install guide told you to deploy from, or ask whoever set the server up. On Coolify, run this in a terminal on the server, not on the container: the container terminal needs a running backend, and yours is not running. (The tool’s own messages print a short docker compose … without them, to stay readable — add your -p and -f when you copy one.)

Back up first

PostgreSQL keeps running even when the backend does not, so you can take a backup without the app. The command reads the database name and user from the container itself, so it works whatever you called them, and no password appears on your screen:

docker compose -p <project> -f <compose-file> exec -T postgres sh -c \
  'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom -f /tmp/before-repair.dump \
   && pg_restore --list /tmp/before-repair.dump | head -3'
docker compose -p <project> -f <compose-file> cp postgres:/tmp/before-repair.dump ./before-repair.dump
ls -lh before-repair.dump

The pg_restore --list line prints the first entries of the archive: if you see them and ls -lh shows a non-empty file, the backup is readable. Keep before-repair.dump next to your docker-compose.yml — it is on the server’s disk, not inside a container, so it survives everything below.

Check the problem

docker compose -p <project> -f <compose-file> run --rm backend --repair-offer-sections

This only reads. It prints the affected offers, what is wrong with each item, and the options:

Found 3 offer item(s) in 2 offer(s) assigned to a section that does not belong to that offer.

  Offer OF-2026-014 [DRAFT]
    item:    Mounting frame
    section: section "Installation" — section belongs to another offer

Preview, then apply

Without --apply=true every command is a preview: it shows exactly what it would do and ends having changed nothing, in any terminal.

docker compose -p <project> -f <compose-file> run --rm backend --repair-offer-sections --plan=unlink

Add --apply=true --acknowledge-backup=true to perform it. This is the only form that writes:

docker compose -p <project> -f <compose-file> run --rm backend --repair-offer-sections --plan=unlink --apply=true --acknowledge-backup=true

To decide each item yourself, use --plan=choose. It asks one question per item and lists that offer’s own sections, so you never type a database id. Without --apply=true it still only shows you the plan:

docker compose -p <project> -f <compose-file> run --rm backend --repair-offer-sections --plan=choose --apply=true --acknowledge-backup=true

Finish the upgrade

When the tool reports that no item points at a wrong section any more:

docker compose -p <project> -f <compose-file> up -d

The blocked migration left nothing behind, so it simply runs on the next start. You do not need any flyway repair or baseline command — and you should not use one, because it would hide the data problem instead of solving it.

Verify as usual: open /health, then check Admin > License for the new version.

If it says the data changed since your preview

The tool applies the plan you approved, not whatever it finds at the moment it writes. If a wrong assignment appeared, moved, or a section you picked was deleted while you were confirming, it refuses the whole repair and changes nothing. Run the check again to see the current state, then make your choice again.

If it says the repair was saved, or that it could not confirm

The repair runs in one transaction, so it is either fully saved or not saved at all. Two endings need care. If a later step fails — writing the record, or the re-read that confirms the result — the tool keeps the word SAVED in the message: the repair worked, so do not restore your backup. If instead it says UNCONFIRMED, the database never answered and nobody can say which way it went; do not restore a backup and do not repeat the repair. In both cases run the read-only check again: if it reports nothing to repair, you are done and can start the stack; if it still lists the same items, nothing was saved and you can repeat your choice.

docker compose -p <project> -f <compose-file> run --rm backend --repair-offer-sections

One thing to read carefully: if you used Pick a section for each item and skipped some, those skipped items are still listed by the check. That is expected and does not mean the repair failed. Compare the list with the items you actually chose to fix: if only your skipped items remain, the repair worked and what is left is the decision you postponed — run the tool again for them when you are ready. If items you did choose to fix are still listed, nothing was saved.

What was changed, and what to do if it still fails

Each run that changes anything writes a record of the affected item, offer and section ids to /app/data/backups/ inside the backend volume. It contains no customer data and can be attached to a support request. If the file cannot be written, the tool prints the same record on screen instead — copy it before closing the terminal:

docker compose -p <project> -f <compose-file> run --rm --entrypoint sh backend \
  -c 'ls -1 /app/data/backups/offer-section-repair-*.json'

If the upgrade still stops afterwards:

  • run the check again — items you skipped are still listed;
  • if the message names a different migration, this tool does not apply to it. It repairs offer-section assignments only;
  • if the message is unchanged and the check reports nothing to repair, report the issue with the backend log and the record above.

HTTPS certificate does not issue

For public deployments:

  • DNS must point to the deployment server.
  • Ports 80 and 443 must be reachable from the internet.
  • Only one proxy should terminate HTTPS for the same domain.
  • In Coolify, assign the domain to the frontend service.

I lost .env

If you lost .env, stop and assess before rotating values.

  • DB_PASSWORD can usually be reset with PostgreSQL admin access.
  • TIMING_TOKEN_SECRET and PUBLIC_LINK_PASSWORD_SESSION_SECRET can be regenerated, but existing sessions/tokens may expire.
  • DB_ENCRYPTION_KEY is critical. Without the original key, encrypted values cannot be decrypted.
  • If PII encryption is enabled, encrypted customer/contact fields depend on DB_ENCRYPTION_KEY.

Restore .env from backup whenever possible.

Recovery if .env is lost but database backup exists

  1. Create a fresh .env with new secrets.
  2. Restore the database from your backup (pg_dump / pg_restore).
  3. All business data (customers, offers, products, users) will be fully accessible.
  4. Only a few fields are lost: MFA codes (users re-enroll), SMTP password (re-enter in settings), FX API key (re-enter). If ENCRYPT_PII=true was enabled, encrypted customer data is also lost.

If your backups are age-encrypted (the default), you need the age identity file from your recovery kit to decrypt them first. See the Backup and Recovery guide.

Forgot admin password

Use a non-destructive recovery path:

  1. If SMTP is configured, use the login page: Forgot your password?
  2. If another administrator can still sign in, open Users in the admin panel and reset the password.
  3. If no administrator can sign in and the email reset flow is unavailable, restore access from a known-good backup or use your internal maintenance procedure.

docker compose down -v is not a password reset procedure. It deletes all data — users, offers, customers, settings, and backups. Use it only for disposable test environments.

License verification fails

For real deployments, keep:

LICENSE_SKIP_SIGNATURE_CHECK=false

If a valid license still fails, check that the deployment has the expected app version and an NTP-synced system clock. Confirm that the license matches the deployed edition/version, then contact support or replace the license file through the documented operator path. Do not set signature bypass to true as a production workaround.

Last reviewed: Recently