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.
Public links open on my computer but not for customers
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_HOSTandSMTP_PORTare correct.SMTP_AUTH=truewhen your provider requires authentication.SMTP_STARTTLS=truefor port 587.SMTP_USERNAMEandSMTP_PASSWORDare valid.- The provider allows SMTP/app-password login.
NOTIFICATIONS_EMAIL_ENABLED=truewhen notification emails should be sent.
Manual public-link sharing and PDF download can still work without SMTP.
Notification preference links are wrong
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_PASSWORDdiffers between PostgreSQL and backend.DB_ENCRYPTION_KEYis 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=truein 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:
- Admin panel — another administrator opens Settings > Security Dashboard > Blocked IPs and clicks Unblock.
- Wait — temp locks expire after
SECURITY_TEMP_LOCK_MINUTES(default 5 minutes). Hard locks do not expire on their own. - 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.
Public link IP blocks
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
frontendservice.
I lost .env
If you lost .env, stop and assess before rotating values.
DB_PASSWORDcan usually be reset with PostgreSQL admin access.TIMING_TOKEN_SECRETandPUBLIC_LINK_PASSWORD_SESSION_SECRETcan be regenerated, but existing sessions/tokens may expire.DB_ENCRYPTION_KEYis 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
- Create a fresh
.envwith new secrets. - Restore the database from your backup (
pg_dump/pg_restore). - All business data (customers, offers, products, users) will be fully accessible.
- 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=truewas 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:
- If SMTP is configured, use the login page: Forgot your password?
- If another administrator can still sign in, open Users in the admin panel and reset the password.
- 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 -vis 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.