A 500 is a category, not a diagnosis. It means Strapi hit an exception it did not have a specific handler for, and, deliberately, told you nothing about it. Leaking stack traces to API clients is an information disclosure risk, so Strapi returns a generic message in production and writes the real one to the server log.
{
"data": null,
"error": {
"status": 500,
"name": "InternalServerError",
"message": "Internal Server Error"
}
}The error you need is in your server logs. Everything below is about getting to it efficiently.
Step 1: Read the server log
Not the browser console. Not the network tab. The terminal or log destination where Strapi is running.
# Development
npm run develop
# PM2
pm2 logs strapi --lines 200
# Docker
docker compose logs -f --tail=200 strapi
# systemd
journalctl -u strapi -n 200 -f
# Kubernetes
kubectl logs -f deploy/strapi --tail=200Reproduce the request while watching. You are looking for a stack trace timestamped at the moment of the failure. The first line names the actual error; the frames underneath tell you whether it originated in your code or inside Strapi.
If there is no stack trace at all, the request may not be reaching Strapi. Check whether your proxy is generating the 500 itself: an Nginx 500 is an HTML page, a Strapi 500 is the JSON above. That distinction alone saves a lot of time.
Step 2: Turn up the detail
If the log is too terse, raise the log level:
export default {
level: 'debug',
};Or per-run:
STRAPI_LOG_LEVEL=debug npm run developLevels are fatal, error, warn, info, debug, trace. Use debug while you are hunting and put it back afterwards, because debug in production is noisy and can log data you would rather not persist.
To see the SQL Strapi is running, enable the database debug flag temporarily:
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: { /* ... */ },
debug: true, // logs every query, development only
},
});Step 3: Narrow it down
Reproduce with curl so you eliminate the frontend entirely:
curl -i https://cms.example.com/api/articles \
-H "Authorization: Bearer $TOKEN"Then bisect:
- Does the same route fail with different parameters?
?populate=*failing where a plain call succeeds points at a relation or component. - Does it fail for every entry or one? One entry means bad data, not bad code.
- Does it fail on a fresh local database? If not, it is data-dependent.
- Did it start after a deploy?
git logthe range and look at schema and controller changes. - Does it fail for one role and not another? Look at policies and lifecycle hooks that branch on the user.
The causes that actually come up
An unhandled exception in your own code
Custom controllers, services, lifecycle hooks, and middleware are the most common source. Anything that throws and is not caught becomes a 500.
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article', ({ strapi }) => ({
async findBySlug(ctx) {
const { slug } = ctx.params;
const entries = await strapi.documents('api::article.article').findMany({
filters: { slug },
limit: 1,
});
// Without this guard, the line below throws on an unknown slug
if (!entries.length) {
return ctx.notFound('Article not found');
}
return this.transformResponse(entries[0]);
},
}));Return the right status deliberately rather than letting an exception decide for you. Strapi provides ctx.notFound(), ctx.badRequest(), ctx.forbidden(), and ctx.unauthorized().
A lifecycle hook throwing
Lifecycle hooks run inside the request, so an error there surfaces as a 500 on an endpoint whose controller is perfectly fine. This is a genuinely misleading failure, because the stack trace points at the model, not the route.
export default {
async beforeCreate(event) {
const { data } = event.params;
// If `data.title` is undefined this throws, and the POST returns 500
data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
},
};Guard your inputs, and wrap anything that calls an external service in try/catch with a decision about whether failure should block the write.
The database connection dropped
If Strapi booted fine and started 500ing later, the database may have gone away, whether a failover, a restart, or an idle connection killed by a proxy or by Docker.
Look for ECONNRESET, Connection terminated unexpectedly, or Knex: Timeout acquiring a connection in the log. In Docker or serverless, set pool.min: 0 so the pool does not hand out dead sockets. See the Knex pool guide.
A misconfigured email or upload provider
Sending an email or uploading a file through a provider with missing credentials throws inside the plugin. The endpoint that fails, such as /api/auth/forgot-password or /api/upload, is a useful clue that a provider is involved.
Check the provider configuration in config/plugins and confirm the environment variables exist in the environment that is failing.
A content-type or component mismatch
If Strapi 500s on every request to one content type, its schema may reference a component or relation that does not exist. That usually prevents boot entirely, but a partially broken relation can survive startup and fail at query time. See Cannot read properties of undefined (reading 'attributes').
A request that is not what you think it is
The last reply in the original forum thread is a good example: a frontend was posting plain text to a login endpoint without Content-Type: application/json, so Strapi received an unparsed body and threw when it tried to read fields off it.
await fetch('https://cms.example.com/api/auth/local', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // easy to forget
body: JSON.stringify({ identifier, password }),
});Check the raw request before you go looking in the backend. A missing header, a stringified body sent as an object, or a form-encoded payload where JSON is expected all produce 500s that look like server bugs.
Missing environment variables
APP_KEYS, JWT_SECRET, ADMIN_JWT_SECRET, API_TOKEN_SALT, and ENCRYPTION_KEY are required. A missing JWT_SECRET typically 500s specifically on authentication endpoints while everything else works, which is a confusing signature until you know it.
Getting more from errors long-term
Customise error handling
You can intercept errors globally to log richer context:
export default [
'strapi::logger',
{
name: 'strapi::errors',
config: {
// Never enable this in production: it exposes stack traces to clients
// handler: ...
},
},
// ...
];See error handling for the supported customisation points.
Ship logs somewhere you can search
pm2 logs is fine until the incident is three days old. Send Strapi's output to a log aggregator such as Better Stack, Datadog, Grafana Loki, or CloudWatch, so you can search by timestamp and correlate with a deploy.
Add error tracking
Sentry, Bugsnag, or similar gives you the stack trace, the request, and the user, without needing to reproduce. Register it in src/index:
import * as Sentry from '@sentry/node';
export default {
register({ strapi }) {
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
});
}
},
bootstrap() {},
};Make health visible
Poll /_health from an uptime monitor so you learn about a down instance from an alert rather than from a colleague. See the health check guide.
What not to do
Generic web-server advice (clear your browser cache, try another browser, check file permissions, look at .htaccess) comes from the shared-hosting PHP world and does not apply here. Strapi is a Node process. Nothing the browser does causes a 500, and there is no .htaccess.
Read the server log. The answer is in it.
Checklist
- Read the Strapi log, not the browser.
- Set
level: 'debug'if the trace is thin. - Reproduce with
curlto rule out the frontend. - Identify whether the frame is in your code or Strapi's.
- Check whether it is data-dependent (one entry vs all).
- Check whether it started with a deploy.
- Verify environment variables exist in that environment.
- Check the database is still reachable.




