---
title: Troubleshooting
description: 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](/self-hosting/oauth) for sign-in setup and
[environment variables](/self-hosting/environment) 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 on
  `feedback.example.com`. No board host ends with `.localhost`, so every board origin
  fails the check.
- `APP_URL` left as `http://localhost:3001` from `.env.example` while the dashboard
  serves `https://app.example.com`. The subdomain rule then demands the board's
  protocol and port match `http://localhost:3001`, which no real board does.
- Boards on a domain outside your root, for example `feedback.example.com` with
  `APP_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.ts` defaults `SMTP_HOST` to `127.0.0.1` and
  `SMTP_PORT` to `2500`. Inside the compose network nothing listens there, so the
  connection is refused.
- nodemailer sends authentication only when `SMTP_USERNAME` is 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=true` together with `NODE_ENV=development`
  on the server. Never ship that combination to production.

## Next steps

<CardGroup cols={2}>
  <Card title="OAuth login" href="/self-hosting/oauth">
    Callback URLs, client credentials, and the local emulator.
  </Card>
  <Card title="Webhooks" href="/developers/webhooks">
    Event payloads, signing, and retry behavior for outgoing webhooks.
  </Card>
</CardGroup>