✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

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:

  1. Nginx forwards requests to Strapi and passes along information about the original request.
  2. 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 nginx

The four headers that matter

  • X-Forwarded-Proto $scheme: without this Strapi believes every request arrived over plain HTTP. This is the direct cause of Cannot send secure cookie over unencrypted connection at login.
  • Host $host: preserves the original hostname. Without it Strapi sees 127.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, proxy is an object and the Koa setting moved to proxy.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.proxyIpHeader and app.maxIpsCount, but Strapi's server source reads server.proxy.ipHeader and server.proxy.maxIpsCount when it constructs the Koa app, and the Proxy type declares both keys. Until that is reconciled, mirror them under proxy as 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 build

Common 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

SymptomCause
502 Bad GatewayStrapi is not running, or is bound to localhost instead of 0.0.0.0
413 Request Entity Too Largeclient_max_body_size too low
Cannot send secure cookie over unencrypted connectionMissing X-Forwarded-Proto, or proxy.koa not set
Reset emails link to localhost:1337url not set in config/server, or admin not rebuilt
Admin panel loads blank, console shows calls to localhostAdmin built without PUBLIC_URL
Hot reload / Preview does not workUpgrade and Connection headers missing
Every client IP logs as 127.0.0.1X-Forwarded-For missing, or proxy.koa not set
504 Gateway Timeout on big uploadsproxy_read_timeout too low

Further reading

Theodore Kelechukwu OnyejiakuDevRel and Community | Software Developer | Technical Writer

Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.

"Cannot destructure property 'client' of 'db.config.connection'" in Strapi
EcosystemIntermediate·6 min read

"Cannot destructure property 'client' of 'db.config.connection'" in Strapi

This error means Strapi loaded no database configuration at all. The file is missing from the build, excluded from the container, or never compiled.

·September 9, 2026
"Cannot send secure cookie over unencrypted connection" in Strapi
EcosystemAdvanced·6 min read

"Cannot send secure cookie over unencrypted connection" in Strapi

Your site is on HTTPS but Strapi thinks the request came over HTTP. The fix is one config key, proxy.koa, plus the proxy headers that make it work.

·September 9, 2026
"Strapi is in production mode" when you are running development mode
EcosystemBeginner·5 min read

"Strapi is in production mode" When you are Running Development Mode

The Content-Type Builder is disabled and Strapi claims production, but your terminal says development. The rule Strapi actually applies, and the fix.

·September 9, 2026