Troubleshooting
Common problems when running the desk, what causes them and what to check. Error codes are the ones the API returns
in { "error": { "code": "…", "message": "…" } }; log lines are from the desk's container log.
The desk doesn't start
| The log shows | Cause |
|---|---|
Invalid production configuration and a list of reasons | Development secrets, a secret shorter than 32 characters, the same value for both secrets, or a test switch. See what production refuses. |
Invalid configuration | A value doesn't parse, often a missing DATABASE_URL. |
| It stops at a migration | The database couldn't be migrated; see upgrades. |
The Docker Compose setup
With the recommended setup, run these in deploy/:
| Command | Shows |
|---|---|
docker compose ps | What runs, and whether desk is healthy. |
docker compose logs desk | The app's log. |
docker compose logs caddy | The proxy's log. |
| Symptom | Likely cause | Fix |
|---|---|---|
set … in .env when starting | A required value is missing from .env (DOMAIN, the five secrets). Compose names the missing value. | Fill in the value it names. |
| No certificate, or the browser warns | Caddy needs DOMAIN's DNS to point to the server, and ports 80 and 443 to reach it from the internet. | docker compose logs caddy shows the reason, such as a DNS mismatch, a closed port or a rate limit. Certificates are kept in the caddy-data volume, so restarts don't request new ones. |
| The build runs out of memory | Building needs about 2 GB. | Add swap, or build on another machine and copy the image over (docker save and docker load), tagged itsmengine:local. |
| Ports 80 or 443 are already in use | Another web server is running. | Stop it, or use it as the proxy instead of Caddy; see the installing notes on existing proxies. |
The setup link is lost or doesn't work
Where to find the link:
-
With
SETUP_TOKENset, the link ishttps://<your address>/setup?token=<SETUP_TOKEN>; it works until the first admin exists. ASETUP_TOKENshorter than 24 characters is ignored (the log says so). -
Otherwise the link is in the log, in a line starting with
No admin yet. It's the same on every restart until it's used:docker logs desk 2>&1 | grep "No admin yet"
When it doesn't work:
| Symptom | Likely cause | Fix |
|---|---|---|
setup_link_invalid ("That setup token isn't right, or the link was used already") | The token is wrong, or the link was opened already, maybe in another browser. | Check the token. Restart the desk; it prints a new one. The browser that opened the first link can carry on. |
setup_link_required ("Open the setup link from the log") | This browser has no setup session. A setup session lasts 24 hours. | Open the link again in it. |
| The address in the link is wrong | The public address isn't set yet. | Open /setup?token=… on the address you reach the desk at. |
| The wizard keeps asking for the link | The setup session cookie needs https. Over plain http (other than for local development with COOKIE_SECURE=false), the browser drops it. | Serve the desk over https. |
Without a browser, or when every admin is locked out, create an admin with create-admin instead (see
installing).
Nobody can stay signed in
Session cookies are only sent over https (COOKIE_SECURE). Serve the desk through a reverse proxy with TLS and set
Public address to the https address.
People signed out after a while without using the desk: that's SESSION_IDLE_HOURS (12 by default).
No email arrives
- Settings → Installation, Still to do: "No email server: nothing can be sent" or "Resend needs an API key".
- Send test email under Settings → Installation, Email, tries the values in the form and shows the transport's
own error (for example a rejected login on the SMTP server, or
Resend: …with Resend's reason). - In the log, every email sent has a line from
Mailstarting withsent "…". Emails are sent from the job queue, and a failed send is retried 5 times, waiting longer each time (about half an hour in all), then dropped. - Without a transport, sends fail with
Email isn’t set up: choose Resend or SMTP. - Arrived in spam, or refused by the receiver: check that the sender domain (the brand's sender address, or Default sender) is verified with your email provider, with SPF and DKIM records.
Attachments and logos disappear after a restart
There is no file storage: "No file storage: attachments and logos are kept in memory and lost on restart".
- Set up S3-compatible storage under Settings → Installation, File storage.
- Use Test upload, which writes, reads back and deletes a small file.
[!WARNING] Files uploaded before that are gone.
Browsers never talk to the storage directly: the desk serves every file itself.
Wrong client address behind a proxy
Symptoms:
- everyone gets
rate_limitedat once; - a server-mode form answers
ip_not_allowedwith the proxy's address in the message (IP … is not allowed for this form); - the audit log shows the same address for everyone.
The desk only believes forwarded addresses (X-Forwarded-For, or CF-Connecting-IP when there is none) from
Trusted proxies (see configuration).
- Open Settings → Installation, Name and address. It shows Your address, as the desk sees it and Connected from (the direct connection), plus the forwarded headers it received.
- Add the address or range under Connected from to Trusted proxies until "your address" is your own.
- For a proxy chain, add every proxy.
[!CAUTION] Don't add
0.0.0.0/0: anyone could then claim any address.
Turnstile refuses submissions
| Where | Message | Cause |
|---|---|---|
| Form or portal | turnstile_missing, "Please complete the bot check" | The page sent no token. The widget didn't load (a script blocker, or a headless form without the widget). |
| Form or portal | turnstile_failed, "The bot check failed. Please try again." | Cloudflare rejected the token. The log shows Turnstile rejected: <codes> with Cloudflare's reason. |
| Log | TURNSTILE_SECRET_KEY is not set; refusing | No secret key: every check fails. |
| Settings → Installation | "Cloudflare refused the site key for this address" | The site key isn't valid for the domain the page is on. Add the domain to the widget in Cloudflare. |
| Check the keys | "Cloudflare refused the check: is the secret key for the same site key?" | The secret key belongs to another widget. |
Common codes from Cloudflare: invalid-input-secret (wrong secret key), timeout-or-duplicate (the token was used
already or is older than five minutes; reload the page).
/healthz warnings and 503s
GET /healthz stays 200 while warnings lists settings that still need attention. The details are shown only on
the server itself (see installing):
| Warning | Fix |
|---|---|
APP_URL | Set the Public address, with https in production. |
SMTP_URL, RESEND_API_KEY | Set up email. |
S3_ENDPOINT | Set up file storage. |
TURNSTILE_SITE_KEY | Set real Turnstile keys (missing, or Cloudflare's test keys in production). |
INBOUND_SECRET, INBOUND_PROVIDER | Finish incoming email: the service's secret, or the Resend API key for Resend. |
It also stays 200 when unknownMigrations lists migrations the database has but this image doesn't know: an
older image runs on a newer database, for example after a rollback without restoring the backup. Start the image
that matches the database (see rolling back).
It answers 503 when:
| Answer | Cause | Fix |
|---|---|---|
"database":"unreachable" | The desk can't reach Postgres. | Check DATABASE_URL, that Postgres runs, and the network between them. |
"pendingMigrations":[…] | The image is newer than the database. | Run the migrations, or leave MIGRATE_ON_START on (see upgrades). |
Assistants can't connect (OAuth)
[!IMPORTANT] Hosted assistants (claude.ai, ChatGPT) connect from the internet, so the desk must be reachable at its public address, over https, from outside.
Then check:
- Public address is exactly the address in the connector (
https://support.example.com/mcp). The desk names itself with it in its OAuth metadata; a different address makes the approval end withinvalid_target. https://support.example.com/.well-known/oauth-authorization-serverandhttps://support.example.com/.well-known/oauth-protected-resourceanswer with JSON from outside your network.- The proxy passes the
Authorizationheader and doesn't buffer or cut off streamed responses on/mcp.
More in MCP server.
A website can't use a form
The browser console shows a CORS error, or the form answers origin_not_allowed ("This website may not use this
form").
The site's origin must be listed under Allowed websites on the form (Settings → Forms, the form, Security),
exactly: scheme, host and port, no path or trailing slash (https://www.example.com).
example.comandwww.example.comare different origins.- The desk's own hosted form is always allowed.
More on the form's settings in forms.
Other refusals and their codes are in form submissions.
Too many sign-in attempts
Sign-in answers rate_limited ("Too many attempts, try again later") when a limit is reached. Wait for the window
to pass.
| What | Limit |
|---|---|
| Sign-in | 20 attempts from one address, or 10 for one email address, within a 10-minute window. |
| Forgot password | 5 requests per address per 15 minutes, and 3 per email address per hour. |
| Two-factor codes | 10 tries per 10 minutes. |
[!TIP] If it happens to many people at once, check the client address: without trusted proxies, everyone behind the proxy shares one address.
Live updates don't arrive
Sidebar counts, New activity on a case and colleagues on a case should change within seconds. If they only change
every few minutes, or when you move to another page, the live-update connection (/api/v1/stream) isn't getting
through.
| Symptom | Likely cause | Fix |
|---|---|---|
| Updates arrive late | A proxy in front of the desk buffers or compresses it. | Turn buffering off for /api/v1/stream (nginx: proxy_buffering off;), leave it uncompressed, and allow it to stay open longer than 30 seconds. The bundled Caddyfiles already do. See install. |
The browser shows the request failing in its developer tools (Network, stream) | A 401 means the session ended. | Sign in again. |
The desk keeps working without it; it falls back to asking every few minutes.
Scale: colleagues on another server don't show on a case
People on different application servers only see each other on a case through Redis. Check that every application
server has the same REDIS_URL and can reach it: the app logs Redis errors when it can't. With
compose.scale.yaml this is set for you.