Proxying Strapi with HAProxy
Page summary:Tell Strapi its public address and that a proxy sits in front of it, using the
server.urlandserver.proxyoptions. Then define an HAProxy frontend that terminates TLS and a backend that health checks Strapi on/_health.
Strapi listens on a plain HTTP port and does not terminate TLS itself. A reverse proxy such as HAProxy sits in front of it to handle HTTPS, serve your application on port 443, and forward requests to the Strapi process. HAProxy is a good fit when you also want health checking and load balancing across more than one Strapi instance. This guide covers the Strapi configuration that makes your application proxy-aware, then the HAProxy configuration that routes traffic to it. The Strapi changes belong in your project, so make them before you deploy. The HAProxy changes are made on the machine or in the container that runs HAProxy.
- A Strapi 5 application that starts and runs locally (see deployment guidelines).
- HAProxy 2.2 or later, running either on the same host as Strapi or as a container that can reach it (see the HAProxy installation instructions). The health check syntax in this guide requires 2.2 or later.
- A domain name whose DNS
Arecord points at the HAProxy host. - A TLS certificate and private key, concatenated into a single PEM file.
- 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 2 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
HAProxy 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. A client can then send X-Forwarded-For: 203.0.113.9 and, once HAProxy appends the real address, Strapi reads the spoofed value from the front of the chain instead of the real one at the end. Always set maxIpsCount to the real number of proxies in front of Strapi.
Strapi reads the header named by proxy.ipHeader, which defaults to X-Forwarded-For. The option forwardfor directive in the configuration below sets that same header, so you do not need to change it.
Raise the body size limits for uploads
HAProxy does not cap request body size 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.
Large uploads also need room in the HAProxy timeouts, which the configuration below sets to 60 seconds. Raise timeout client and timeout server if uploads take longer than that to complete.
Configure HAProxy
With Strapi aware of the proxy, the next step is the HAProxy frontend and backend that carry traffic to it.
Write the configuration
HAProxy reads its configuration from /etc/haproxy/haproxy.cfg. The following defines a frontend that accepts public traffic and a backend that forwards it to Strapi:
global
log /dev/log local0
# Needed by the `show stat` command used in the Validation section
stats socket /var/run/haproxy.sock mode 660 level admin
defaults
mode http
log global
option httplog
option forwardfor
timeout connect 5s
timeout client 60s
timeout server 60s
frontend strapi_front
bind :80
bind :443 ssl crt /etc/haproxy/certs/api.example.com.pem
# Send every plain HTTP request to HTTPS
http-request redirect scheme https unless { ssl_fc }
# Tell Strapi the original request arrived over HTTPS
http-request set-header X-Forwarded-Proto https if { ssl_fc }
default_backend strapi_back
backend strapi_back
option httpchk
http-check send meth GET uri /_health
http-check expect status 204
server strapi1 127.0.0.1:1337 check
3 directives in that file do the work Strapi depends on.
option forwardfor adds the X-Forwarded-For header carrying the client address. Without it, Strapi sees only the HAProxy address.
http-request set-header X-Forwarded-Proto https if { ssl_fc } is set by hand, because HAProxy does not add it for you. Strapi uses this header to mark the admin panel refresh-token cookie as Secure. Absolute URLs for password resets, login provider callbacks, and media assets are built from the url option instead, not from this header.
bind :443 ssl crt expects the certificate and private key concatenated into one PEM file. This differs from Nginx, which takes them as 2 separate paths.
Validate the file and reload HAProxy:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
The haproxy -c command checks the configuration without applying it. Reloading a broken configuration takes the site down, so do not skip it.
Health check Strapi
Strapi exposes a health check route at /_health that responds with HTTP 204 No Content. The backend above uses it to decide whether an instance should receive traffic:
backend strapi_back
option httpchk
http-check send meth GET uri /_health
http-check expect status 204
server strapi1 127.0.0.1:1337 check
The http-check send line matters. Left to its default, option httpchk sends an OPTIONS request over HTTP/1.0, so naming the method and URI explicitly is what makes the check hit /_health as a GET. The http-check expect status 204 line then requires exactly the status Strapi returns. Without it, HAProxy accepts any 2xx or 3xx response, which would treat an unrelated redirect as healthy.
Run several Strapi instances
Add a server line per instance and a balancing algorithm to spread traffic across them:
backend strapi_back
balance roundrobin
option httpchk
http-check send meth GET uri /_health
http-check expect status 204
server strapi1 10.0.0.11:1337 check
server strapi2 10.0.0.12:1337 check
Because the instances share one database, a few Strapi behaviors need attention before you scale out.
Strapi runs its schema synchronization on startup, once per process, with no lock shared between instances. A restart that leaves the content-types unchanged is safe, because the synchronization detects an unchanged schema and does nothing.
The synchronization is not safe in 2 cases: a release that changes content-types, and a release with pending migrations. Instances starting together then issue concurrent schema changes and migrations against the same database.
For those releases, start or roll a single instance first and let it finish booting before the next one starts. This is a property of running several processes against one database, so spreading the instances across separate hosts does not avoid it.
Running several instances has 3 other consequences worth knowing:
- CRON jobs are scheduled inside each Strapi process, so a job runs once per instance rather than once overall. A task set to run nightly runs as many times as you have instances. If your project sets
cron.enabledtotrue, either move the jobs out of Strapi or keep them on a single instance. - Anything Strapi holds in memory belongs to one instance only, because each instance is a separate process. Nothing in Strapi core replicates state between them.
- The default local upload provider writes files to the instance's own disk. Instances sharing a machine and a project directory share that disk, but instances on different machines or in different containers do not. Use one of the Media Library providers backed by object storage in that case.
Validation
Requesting the health check route through the proxy confirms that HAProxy 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 mixed-content warnings. - 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 the HAProxy address.
- Confirm HAProxy considers the backend healthy. The
show statcommand reports each server's state:
echo "show stat" | sudo socat stdio /var/run/haproxy.sock
Troubleshooting
HAProxy returns 503 Service Unavailable. No backend server is passing its health check. Confirm the Strapi process is running and that curl -i http://127.0.0.1:1337/_health returns 204 from the HAProxy host.
The health check fails even though Strapi responds. The http-check expect status 204 line requires exactly 204. If a proxy or middleware in front of the route changes the status, adjust the expected value to match what Strapi actually returns.
Uploads fail or time out. Raise formidable.maxFileSize in the Strapi body middleware, and raise timeout client and timeout server in HAProxy so the transfer has time to finish.
Strapi logs the HAProxy address as the client IP. Either option forwardfor is missing from the HAProxy configuration, or proxy.koa is not set to true in Strapi.
Admin panel sessions do not persist over HTTPS. The X-Forwarded-Proto header is not being set, so Strapi treats the request as plain HTTP and does not mark the refresh-token cookie as Secure. Add the http-request set-header line to the frontend.
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 so it restarts on failure and survives a reboot, as covered in the PM2 guide.
- Review the full list of server configuration options.
- Read the deployment guidelines for build and environment variable requirements.