Strapi does not handle SSL directly, and exposing a Node.js process on the public edge is not something you want to do. The standard deployment puts a reverse proxy in front, such as Nginx, Apache, HAProxy, Traefik, or Caddy, which terminates TLS, serves as a single entry point, and shields the application server.
This is a complete working Nginx setup for Strapi 5, plus the Strapi-side configuration that most guides leave out and that causes the majority of "it works but redirects are wrong" problems.
The two halves
Getting this right means configuring both sides:
- Nginx forwards requests to Strapi and passes along information about the original request.
- Strapi has to be told that it is behind a proxy, so it trusts that information instead of the connection it actually sees.
Skip the second half and you get a working site with subtly broken behaviour: password reset emails containing http://localhost:1337 links, Cannot send secure cookie over unencrypted connection errors on login, and OAuth callbacks that go nowhere.
Nginx: the upstream
Define the backend in its own file so it can be reused and later extended for load balancing:
upstream strapi {
server 127.0.0.1:1337;
keepalive 64;
}keepalive reuses connections to the Node process instead of opening a new TCP connection per request. It is a meaningful improvement under load, and it requires proxy_http_version 1.1 in the location block below to take effect.
Nginx: the virtual host
server {
listen 80;
listen [::]:80;
server_name cms.example.com;
# Everything except the ACME challenge goes to HTTPS
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name cms.example.com;
ssl_certificate /etc/letsencrypt/live/cms.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cms.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Media uploads: raise this to match your Strapi upload limit
client_max_body_size 256M;
location / {
proxy_pass http://strapi;
proxy_http_version 1.1;
# Websockets: required for admin panel hot reload and Preview
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Tell Strapi about the original request
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_pass_request_headers on;
# Long enough for large uploads and slow admin operations
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 60s;
}
location = /_health {
proxy_pass http://strapi/_health;
access_log off;
}
}Enable and reload:
sudo ln -s /etc/nginx/sites-available/cms.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxThe four headers that matter
X-Forwarded-Proto $scheme: without this Strapi believes every request arrived over plain HTTP. This is the direct cause ofCannot send secure cookie over unencrypted connectionat login.Host $host: preserves the original hostname. Without it Strapi sees127.0.0.1:1337.X-Forwarded-For $proxy_add_x_forwarded_for: preserves the client IP for logging and rate limiting.Upgrade/Connection: required for websockets. The admin panel's hot reload in development and the Preview feature both need them.
client_max_body_size
Nginx's default is 1 MB. Any media upload larger than that is rejected by Nginx before Strapi ever sees it, and the browser gets a 413 Request Entity Too Large from Nginx rather than a Strapi error, which makes it look like the Media Library is broken. Set it to match, or slightly exceed, your Strapi upload limit.
Strapi: telling it about the proxy
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://cms.example.com'),
proxy: {
koa: true,
},
app: {
keys: env.array('APP_KEYS'),
proxyIpHeader: 'X-Forwarded-For',
maxIpsCount: 1,
},
});Three things here, all of them load-bearing:
proxy.koa: true makes Koa, the HTTP framework under Strapi, trust X-Forwarded-* headers. ctx.protocol then returns https instead of http, and secure cookies work.
Upgrading from Strapi v4: in v4 this was a top-level boolean,
proxy: true. In Strapi 5,proxyis an object and the Koa setting moved toproxy.koa. A v4 config carried over unchanged will silently not apply.
url is your public HTTPS URL. It is used for password reset links, OAuth callbacks, and the admin panel's API base. It must be set before you run strapi build, because the admin bundle compiles it in.
proxyIpHeader with maxIpsCount is the security pairing. Trusting X-Forwarded-For without limiting how many entries you read lets a client spoof its own IP by sending the header themselves. maxIpsCount: 1 means "there is exactly one proxy in front of me, read one address". Use 2 if you have Cloudflare in front of Nginx, and so on. The Koa default of 0 means unlimited, which is unsafe behind a proxy.
Set these in two places. The docs list them as
app.proxyIpHeaderandapp.maxIpsCount, but Strapi's server source readsserver.proxy.ipHeaderandserver.proxy.maxIpsCountwhen it constructs the Koa app, and theProxytype declares both keys. Until that is reconciled, mirror them underproxyas well and confirm the client IP you actually get:proxy: { koa: true, ipHeader: 'X-Forwarded-For', maxIpsCount: 1, },
Rebuild after changing these:
NODE_ENV=production npm run buildCommon variations
Serving Strapi under a subpath
To serve at https://example.com/cms rather than a dedicated subdomain:
location /cms/ {
proxy_pass http://strapi/;
# ... same proxy_set_header block
}Note the trailing slash on proxy_pass http://strapi/: it strips /cms before forwarding. Set url: 'https://example.com/cms' in config/server and rebuild.
Restricting the admin panel by IP
location /admin {
allow 203.0.113.0/24;
deny all;
proxy_pass http://strapi;
# ... same proxy_set_header block
}Put this before the location / block. See the admin panel MFA guide for why this is worth doing.
Caching the public API
proxy_cache_path /var/cache/nginx/strapi levels=1:2 keys_zone=strapi_api:10m max_size=1g inactive=60m;
location /api/ {
proxy_cache strapi_api;
proxy_cache_valid 200 5m;
proxy_cache_use_stale error timeout updating;
proxy_cache_bypass $http_authorization;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://strapi;
# ... same proxy_set_header block
}proxy_cache_bypass $http_authorization is essential. Without it you will serve one user's authenticated response to another.
Multiple Strapi instances
upstream strapi {
least_conn;
server 10.0.1.10:1337;
server 10.0.1.11:1337;
keepalive 64;
}Every instance must share the same APP_KEYS, ADMIN_JWT_SECRET, JWT_SECRET, API_TOKEN_SALT, and ENCRYPTION_KEY, and must point at the same database and the same upload provider. Local disk uploads do not work across instances, so use S3, Cloudinary, or another shared provider.
Verifying it works
# Health check through the proxy
curl -I https://cms.example.com/_health # → 204
# HTTP redirects to HTTPS
curl -I http://cms.example.com # → 301
# Strapi sees the right protocol: log in to /admin without
# "Cannot send secure cookie over unencrypted connection"
# Upload limit
curl -X POST https://cms.example.com/api/upload \
-H "Authorization: Bearer $TOKEN" \
-F "files=@large-file.mp4"Troubleshooting table
| Symptom | Cause |
|---|---|
502 Bad Gateway | Strapi is not running, or is bound to localhost instead of 0.0.0.0 |
413 Request Entity Too Large | client_max_body_size too low |
Cannot send secure cookie over unencrypted connection | Missing X-Forwarded-Proto, or proxy.koa not set |
Reset emails link to localhost:1337 | url not set in config/server, or admin not rebuilt |
| Admin panel loads blank, console shows calls to localhost | Admin built without PUBLIC_URL |
| Hot reload / Preview does not work | Upgrade and Connection headers missing |
Every client IP logs as 127.0.0.1 | X-Forwarded-For missing, or proxy.koa not set |
504 Gateway Timeout on big uploads | proxy_read_timeout too low |




