Proxying Strapi with Caddy
Page summary:Point
server.urlat your public domain and setserver.proxy.koatotrueso Strapi trusts the forwarded headers. Then write a 3-line Caddyfile, which obtains and renews the TLS certificate for you.
Strapi listens on a plain HTTP port and does not terminate TLS itself. A reverse proxy such as Caddy sits in front of it to handle HTTPS, serve your application on port 443, and forward requests to the Strapi process. Caddy fills that role by provisioning and renewing TLS certificates for you. This guide covers the Strapi configuration that makes your application proxy-aware, then the Caddyfile that routes traffic to it. The Strapi changes belong in your project, so make them before you deploy. The Caddy changes are made on the machine or in the container that runs Caddy.
- A Strapi 5 application that starts and runs locally (see deployment guidelines).
- Caddy running either on the same host as Strapi or as a container on the same Docker network (see the Caddy installation documentation).
- A domain name whose DNS
Arecord points at that server. - Ports 80 and 443 open to the public internet. Caddy needs port 80 to complete the certificate challenge.
- Shell access with
sudoprivileges.
Configure Strapi for a reverse proxy
Strapi needs to know the public address it is served from, and it needs to trust the headers the proxy adds. Without these two settings, Strapi builds URLs from localhost:1337 and reads the proxy's IP address as the client IP.
Set the public URL
The url option in the server configuration defines the public address of your application. Strapi uses it to build absolute URLs for password reset emails, third-party login providers, and media asset paths.
Set it to the address your application's visitors use in their browser:
- JavaScript
- TypeScript
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://api.example.com'),
app: {
keys: env.array('APP_KEYS'),
},
});
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://api.example.com'),
app: {
keys: env.array('APP_KEYS'),
},
});
Changing /config/server.js requires rebuilding the admin panel. Run yarn build or npm run build after saving the file.
Trust the proxy headers
Caddy adds an X-Forwarded-For header carrying the original client IP address. Strapi ignores that header until you turn proxy support on.
Enable proxy support through the proxy options in the server configuration:
- JavaScript
- TypeScript
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://api.example.com'),
proxy: {
koa: true,
maxIpsCount: 1,
},
app: {
keys: env.array('APP_KEYS'),
},
});
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
url: env('PUBLIC_URL', 'https://api.example.com'),
proxy: {
koa: true,
maxIpsCount: 1,
},
app: {
keys: env.array('APP_KEYS'),
},
});
Each option plays a different role:
| Option | Effect |
|---|---|
proxy.koa | When true, Strapi trusts the X-Forwarded-* headers. Client IP, protocol, and host are read from the proxy instead of the socket. |
proxy.maxIpsCount | 5.52.0+ Number of addresses to read from the end of the forwarded header chain. Set it to 1 for a single proxy, or to the number of proxies when requests pass through several. |
proxy.ipHeader | 5.52.0+ Header the client IP is read from. It defaults to X-Forwarded-For, so set it only when your proxy sends another header, such as CF-Connecting-IP. |
Setting proxy.koa to true without proxy.maxIpsCount leaves the count at its default of 0, which means unlimited. Set maxIpsCount to the real number of proxies in front of Strapi so that only addresses added by your own infrastructure are read. Caddy discards client-supplied X-Forwarded-* values by default, so this matters most when a proxy or CDN sits in front of Caddy.
Strapi reads the header named by proxy.ipHeader, which defaults to X-Forwarded-For. Caddy sets that same header, so you do not need to change it.
Caddy helps here in a way that not every proxy does. It discards any X-Forwarded-* values a client sends and writes its own, so a client cannot inject a fake address into the chain. That protection depends on Caddy being the first proxy to see the request. If a CDN or load balancer sits in front of Caddy, declare it as a trusted proxy so Caddy preserves the addresses that upstream added:
{
servers {
trusted_proxies static private_ranges
}
}
api.example.com {
reverse_proxy 127.0.0.1:1337
}
Count every proxy in that chain when you set proxy.maxIpsCount on the Strapi side.
Raise the body size limits for uploads
Caddy does not cap request bodies by default, so the Strapi limits are the ones that apply. If you upload files through the Media Library, raise them.
On the Strapi side, the body middleware parses incoming requests. Uploaded files arrive as multipart data, so formidable.maxFileSize is the option that caps them. The formLimit and jsonLimit options cover ordinary form fields and JSON payloads, not the file itself:
module.exports = [
// ...
{
name: 'strapi::body',
config: {
formLimit: '100mb', // form body
jsonLimit: '100mb', // JSON body
textLimit: '100mb', // text body
formidable: {
maxFileSize: 100 * 1024 * 1024, // uploaded file size, in bytes
},
},
},
// ...
];
The Media Library provider enforces a separate sizeLimit, which defaults to 1 GB. To change it, see local upload provider configuration and max file size.
Configure Caddy
With Strapi aware of the proxy, the next step is the Caddyfile that forwards traffic to it.
Write the Caddyfile
Caddy reads its configuration from a single file, by default at /etc/caddy/Caddyfile. A working Strapi proxy takes 3 lines:
api.example.com {
reverse_proxy 127.0.0.1:1337
}
Naming a public domain at the top of the block is what triggers automatic HTTPS. Caddy obtains a Let's Encrypt certificate for api.example.com on first start, redirects HTTP to HTTPS, and renews the certificate before it expires. No certificate paths appear in the configuration.
The reverse_proxy directive sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host on its own, which is why the block stays short. Those are the headers the Strapi proxy options above rely on. WebSocket connections are also proxied without extra configuration, which the remote data transfer feature relies on.
The Caddyfile is the format most deployments use, but it is not the only one. Caddy's native configuration format is JSON, and config adapters translate YAML, TOML, HCL, CUE, and even an existing Nginx configuration into it. Select a format with the --adapter flag, as in caddy run --config caddy.yaml --adapter yaml. See the Caddy config adapters documentation for the full list. The Strapi options in this guide are the same whichever format you choose.
Reload Caddy to apply the file:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
The caddy validate command checks the configuration before you reload. Reloading a broken configuration takes the site down, so do not skip it.
Set an upload size cap
Caddy accepts request bodies of any size unless you tell it otherwise. Setting an explicit cap in /etc/caddy/Caddyfile rejects oversized uploads at the proxy rather than after Strapi has buffered them:
api.example.com {
request_body {
max_size 100MB
}
reverse_proxy 127.0.0.1:1337
}
Keep this value at or above the Strapi formLimit, otherwise Caddy rejects uploads that Strapi would have accepted.
The request_body directive requires Caddy v2.10.0 or later, where it is still marked experimental and may change in a future release. On an earlier version, or if you would rather not depend on an experimental directive, leave it out and let formidable.maxFileSize in Strapi enforce the limit instead.
Proxy to Strapi running in a container
When Caddy and Strapi both run as containers, reverse_proxy targets the Strapi service by name. Inside the Caddy container, 127.0.0.1 refers to that container itself, not to Strapi. The Caddyfile below is mounted into the container by the Compose file that follows:
api.example.com {
request_body {
max_size 100MB
}
reverse_proxy strapi:1337
}
Strapi must also bind to 0.0.0.0. Bound to localhost, it accepts connections only from inside its own container and Caddy cannot reach it. The host value shown earlier in this guide already uses 0.0.0.0.
The following Compose file puts both services on one network and publishes only Caddy:
services:
strapi:
image: my-strapi-app
environment:
HOST: 0.0.0.0
PORT: 1337
PUBLIC_URL: https://api.example.com
# expose keeps the port reachable inside the network only
expose:
- '1337'
networks:
- web
caddy:
image: caddy:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
- strapi
networks:
- web
volumes:
caddy_data:
networks:
web:
Mount a persistent volume at /data, as shown above. Caddy stores issued certificates there. Without it, every container restart requests new certificates, which reaches the Let's Encrypt rate limits and leaves the site without a valid certificate until the limit resets.
Validation
Strapi exposes a health check route at /_health that responds with HTTP 204 No Content and a strapi header. Requesting it through the proxy confirms that Caddy reaches Strapi:
curl -I https://api.example.com/_health
A working setup returns the status line and the header:
HTTP/2 204
strapi: You are so French!
Then confirm the rest of the chain:
- Open
https://api.example.com/adminin a browser and log in. The admin panel loads over HTTPS with no certificate warning. - Upload an image in the Media Library. Its URL uses your domain rather than
localhost:1337. - Check the Strapi output for the real client IP address rather than
127.0.0.1. If Strapi runs in the foreground, the addresses appear in that terminal.
Troubleshooting
Caddy fails to obtain a certificate. The certificate challenge needs port 80 reachable from the public internet, and the domain's DNS A record must already resolve to this server. Check both, then read the Caddy logs with journalctl -u caddy --no-pager | tail -50.
Caddy returns 502 Bad Gateway. Caddy cannot reach Strapi. Confirm the Strapi process is running and listening on the port used in reverse_proxy, then check that host in /config/server.js is not bound to an interface Caddy cannot reach.
Uploads are rejected as too large. The request exceeded a size limit. Raise max_size in the Caddyfile request_body block and formidable.maxFileSize in the Strapi body middleware, and raise the provider sizeLimit if the file is larger than 1 GB.
Strapi logs 127.0.0.1 as the client IP. proxy.koa is not set to true, so Strapi reads the socket address instead of the forwarded header.
Password reset emails link to localhost:1337. The url option is unset or still points at the local address. Set it to the public URL and rebuild the admin panel.
Next steps
- Run Strapi under a process manager such as PM2, so it restarts on failure and survives a reboot.
- Review the full list of server configuration options.
- Read the deployment guidelines for build and environment variable requirements.