Troubleshooting
Fix the failure modes that hit self-hosted Feeblo deployments, each with a cause you can verify in the source.
Each entry below follows the same shape: the symptom, the cause in Feeblo’s code or compose configuration that produces it, and the fix. If your symptom is not here, check OAuth login for sign-in setup and environment variables for the full variable reference.
Sessions invalid after restart
Symptom: Every restart or redeploy of the server logs all users out at once. Users report they were signed in yesterday and now must sign in again. Verification links and one-time codes that were still pending stop working.
Cause: The server signs session tokens and encrypts verification state with
AUTH_ENCRYPTION_KEY. packages/auth/src/config.ts reads it and passes it to
better-auth as the secret, and packages/domain/src/auth/config.ts encrypts
verification OTP cookies with the same value. Change the key and every existing cookie
fails verification on the next request, so all sessions die together.
The key changes when you regenerate .env between restarts, when a script mints a
fresh value on every boot, or when two server instances behind a load balancer carry
different keys and each rejects the other’s cookies.
Fix: Generate the key once and keep it.
openssl rand -hex 32
Store it in your secret manager or .env, and never regenerate it. Every server
instance that shares the database must hold the same value. The production compose file
already forces the issue: the server container refuses to start without
AUTH_ENCRYPTION_KEY set. The same key also encrypts webhook endpoint credentials when
INTEGRATION_ENCRYPTION_KEY is unset, and a value shorter than 32 bytes makes webhook
create and update fail with “Webhook credentials could not be encrypted”.
Widget requests blocked by CORS
Symptom: The public board renders, but widget API calls from board pages fail in
the browser console with CORS errors. Sign-in from a board subdomain can also fail with
INVALID_ORIGIN or INVALID_CALLBACK_URL. Everything works when you open the API URL
directly.
Cause: Two layers gate browser requests, and both derive their rules from
APP_URL, API_URL, and APP_ROOT_DOMAIN.
apps/server/src/http/cors.ts allows an origin only when it exactly matches
API_URL or APP_URL, or when it ends with .<APP_ROOT_DOMAIN> and carries the
same protocol and port as APP_URL. The auth server’s trusted origins
(packages/auth/src/utils.ts, plus the compose default
AUTH_TRUSTED_ORIGINS=$APP_URL,$API_URL,*.$APP_ROOT_DOMAIN) use the same variables for
the INVALID_ORIGIN and INVALID_CALLBACK_URL checks.
The misconfigurations that break both at once:
APP_ROOT_DOMAIN=localhost(the compose default) while boards run onfeedback.example.com. No board host ends with.localhost, so every board origin fails the check.APP_URLleft ashttp://localhost:3001from.env.examplewhile the dashboard serveshttps://app.example.com. The subdomain rule then demands the board’s protocol and port matchhttp://localhost:3001, which no real board does.- Boards on a domain outside your root, for example
feedback.example.comwithAPP_ROOT_DOMAIN=example.net.
Fix: Make the three variables describe reality: APP_URL must be the full
dashboard origin (protocol and non-default port included), APP_ROOT_DOMAIN the bare
root domain with no protocol or port, and wildcard DNS (*.example.com) must resolve
to the web server. Set AUTH_TRUSTED_ORIGINS only when browsers legitimately call the
API from origins outside that set, and include every one of them, because the variable
replaces the defaults rather than extending them.
Emails not sending
Symptom: Verification codes, password resets, and welcome emails never arrive.
The server log shows SMTP provider submission failed with an ECONNREFUSED or
ETIMEDOUT error code.
Cause: The mailer always sends through nodemailer over SMTP
(packages/transactional/src/mailer.ts). It reads SMTP_HOST, SMTP_PORT,
SMTP_USERNAME, SMTP_PASSWORD, SMTP_SECURE, and SMTP_UNSAFE_IGNORE_TLS.
SMTP_TRANSPORT appears in .env.example with values smtp-auth, smtp-api, and
resend, but no code reads that variable, so changing it does not switch transports.
The failure is a mismatch between the SMTP_* values and the relay the server can
reach.
Two defaults hide until mail matters:
packages/transactional/src/config.tsdefaultsSMTP_HOSTto127.0.0.1andSMTP_PORTto2500. Inside the compose network nothing listens there, so the connection is refused.- nodemailer sends authentication only when
SMTP_USERNAMEis set. A relay that requires auth receives anonymous connections and rejects them.
The local dev stack runs Mailpit on 127.0.0.1:1025 (see
docker/docker-compose.dev.yml), which is why .env.example uses that port. A
production deployment gets no SMTP values from compose at all, so defaults apply.
Fix: Point the server at a relay it can reach and authenticate with it.
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-username
SMTP_PASSWORD=your-password
SMTP_FROM_ADDRESS=noreply@example.com
Use the relay’s hostname inside the compose network if it is another container, not
localhost. Set SMTP_SECURE=true only for implicit TLS on port 465; leave it unset
for STARTTLS ports like 587. Set SMTP_UNSAFE_IGNORE_TLS=true only when the relay
does not support STARTTLS at all. Verify the SMTP_FROM_ADDRESS domain passes the
relay’s sender checks, since many relays reject mail from unregistered domains.
Webhook deliveries rejected
Symptom: Creating or updating a webhook endpoint fails, or deliveries fail, with one of these errors: “Webhook endpoint cannot target localhost”, “Webhook endpoint cannot target a private or reserved address”, “Webhook endpoint must use HTTPS in production”, or “Webhook endpoint hostname resolved to a private or reserved address”.
Cause: Every webhook endpoint passes through the egress policy in
integrations/webhook/src/webhook-endpoint-security.ts before it is persisted and
again before each delivery. In production the policy allows HTTPS URLs only, rejects
localhost, and rejects every private or reserved IPv4 and IPv6 range, including
NAT64 prefixes and documentation ranges. At delivery time the server resolves the
hostname itself and pins only public addresses, so a hostname that resolves to a
private address fails even when the URL looks public. That second check closes a DNS
rebinding hole, and it runs for every delivery.
INTEGRATION_ALLOW_PRIVATE_NETWORK does not override this in production. The compose
layer in apps/server/src/app/layers.ts honors it only when NODE_ENV=development,
and .env.example warns never to enable it in production.
Fix: Give webhooks a public endpoint that resolves to public addresses only:
- Serve the receiver over HTTPS with a valid certificate. Plain HTTP endpoints are rejected in production, and a broken TLS handshake fails every delivery.
- Keep the endpoint off
localhost, containers on the server’s own network, and VPN or NAT ranges. A hostname with any private A or AAAA record fails the delivery-time check. - For local development against a local receiver, set
INTEGRATION_ALLOW_PRIVATE_NETWORK=truetogether withNODE_ENV=developmenton the server. Never ship that combination to production.