All lessons
Linux & serversIntermediate15 min readIn my stack

The web server stack: nginx, PHP-FPM and Node

How nginx, Apache, PHP-FPM and Node apps fit together on a real server.

  • nginx
  • Apache
  • PHP-FPM
  • Node + pm2
  • Reverse proxy
Step 1/5
The browser sends GET /logo.png over HTTPS (:443). nginx, the receptionist, terminates TLS right here, so the apps behind it don't have to.

In 30 seconds

A web server is the front door of your server: it accepts every HTTP request, serves files directly and hands dynamic requests to your application. Today that door is usually nginx, talking to PHP through PHP-FPM and to Node apps over a local port. Once you understand server blocks, locations and reverse proxying, you can host a Laravel API and a Next.js frontend side by side on one machine and deploy them without downtime.

Why I use it

  • I use nginx in front of almost everything I run, because it serves static files fast, terminates HTTPS and proxies to Laravel and Next.js apps with very little memory.
  • Most production bugs I have chased on servers ended in a web server or PHP-FPM log, so knowing this layer well is what lets me fix issues quickly instead of guessing.
  • I have run both hosting panels and plain servers, and understanding the stack underneath is what makes either one safe to operate.
  • A clean nginx config is also documentation: anyone can read which domain goes to which app, what is cached and what is blocked.

How it works

01What a web server does, and how nginx does it

Think of a busy restaurant. The host at the door greets every guest, hands out menus (static files) and sends real orders to the kitchen (your application). The web server is that host. It listens on ports 80 and 443, parses the HTTP request, decides who should answer it and sends the response back. nginx is event-driven: a small number of worker processes (usually one per CPU core) each handle thousands of connections using non-blocking I/O, so an idle or slow client costs almost nothing. Apache traditionally used a process or thread per connection (prefork/worker MPMs); its modern event MPM narrowed the gap, and Apache still shines with per-directory .htaccess files and its huge module ecosystem. In practice many stacks, including most hosting panels, put nginx in front for static files and TLS and keep Apache behind it for compatibility.

  • Master process: reads config, opens ports, manages workers.
  • Worker processes: handle connections; worker_processes auto matches CPU cores.
  • Apache .htaccess is convenient but is read on every request; nginx has no equivalent, by design.

02Server blocks and locations

nginx config is a tree. The http context holds global settings. Inside it, each server block is one virtual host, chosen by the port and the Host header (server_name). Inside a server, location blocks match the URL path and decide what happens: serve a file, pass to PHP, proxy to Node, or deny. Location matching has rules worth memorising: exact matches (=) win first, then the longest prefix match, unless a regular-expression location (~) also matches, in which case the first matching regex wins (a ^~ prefix stops regex checks). try_files is the workhorse: it checks for a real file, then a directory, then falls back to an application entry point. On Ubuntu, sites live in /etc/nginx/sites-available and are enabled with a symlink in sites-enabled.

nginx
server {
    listen 80;
    server_name static.example.com;
    root /var/www/static/public;
    index index.html;
 
    location / {
        try_files $uri $uri/ =404;
    }
 
    location ~* \.(css|js|svg|png|jpg|webp|woff2)$ {
        expires 30d;
        access_log off;
    }
 
    location ~ /\.(?!well-known) {
        deny all;
    }
}

03PHP-FPM: how nginx runs PHP

nginx cannot execute PHP itself. It forwards the request over the FastCGI protocol to PHP-FPM (FastCGI Process Manager), a separate service that keeps a pool of PHP worker processes ready. The connection is usually a Unix socket such as /run/php/php8.5-fpm.sock (PHP 8.5 is the default on Ubuntu 26.04; 24.04 ships 8.3). A pool is a group of workers with its own user, socket and limits. Give each app its own pool running as its own system user: one compromised site then cannot read another site's files, and one slow app cannot eat all the workers. pm = dynamic scales workers between limits; pm.max_children is the hard cap, so size it as roughly (RAM for PHP) divided by (average worker memory, often 40 to 80 MB for Laravel). When every child is busy, requests queue and you see 502/504 errors and 'server reached pm.max_children' in the FPM log.

ini
; /etc/php/8.5/fpm/pool.d/api.conf
[api]
user = api
group = api
listen = /run/php/php8.5-fpm-api.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
request_terminate_timeout = 60s

04A Laravel API behind nginx

Laravel has a single entry point, public/index.php, and the document root must point at the public folder so that .env, storage and vendor are never reachable from the web. try_files serves real files first and sends everything else to index.php with the query string intact. Using $realpath_root instead of $document_root matters when you deploy with a 'current' symlink: PHP then sees the resolved release path, so OPcache does not keep serving the old release after the symlink switches. The certificate paths below are what certbot creates for Let's Encrypt. http2 on is the modern way to enable HTTP/2; the old 'listen 443 ssl http2' form is deprecated since nginx 1.25.1.

nginx
server {
    listen 443 ssl;
    http2 on;
    server_name api.example.com;
    root /var/www/api/current/public;
    index index.php;
    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    client_max_body_size 20m;
 
    location / { try_files $uri $uri/ /index.php?$query_string; }
 
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.5-fpm-api.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }
    location ~ /\.(?!well-known) { deny all; }
}

05A Next.js frontend as a reverse proxy

A Node app (Next.js, NestJS, Express) is its own HTTP server listening on a local port such as 127.0.0.1:3000. nginx acts as a reverse proxy: it accepts the public HTTPS connection and forwards the request to that port, adding headers so the app knows the real client IP and protocol. Bind the Node app to 127.0.0.1 so it is never reachable directly. Keep the process alive with pm2 (pm2 start npm --name web -- start, then pm2 startup and pm2 save) or a systemd unit with Restart=always. The map block belongs in the http context; it lets WebSocket upgrades through while keeping normal requests on reusable keepalive connections. Add a second server block on port 80 that returns 301 to HTTPS, and enable gzip (gzip_types for CSS, JS, JSON, SVG); Brotli needs an extra module, or you can let a CDN such as Cloudflare compress at the edge.

nginx
map $http_upgrade $connection_upgrade { default upgrade; '' ''; }
upstream web_app { server 127.0.0.1:3000; keepalive 16; }
server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
 
    location / {
        proxy_pass http://web_app;
        proxy_http_version 1.1;
        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 Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

06Zero-downtime reloads and deploys

Never restart nginx to apply a config change. Test it, then reload: the master process reads the new config, starts new workers and lets old workers finish their in-flight requests before exiting. If the test fails, nothing changes and the old config keeps serving. The same idea applies to the apps. PHP-FPM supports a graceful reload. For Laravel, deploy each release into its own folder, run composer install and migrations there, then switch a 'current' symlink atomically and reload PHP-FPM; rolling back is just pointing the symlink back. Restart queue workers (php artisan queue:restart) so they pick up the new code. For Node, pm2 reload in cluster mode restarts instances one by one; with a single fork-mode instance it is effectively a restart, so run at least two instances if you need true zero downtime.

bash
# nginx: validate, then graceful reload
sudo nginx -t && sudo systemctl reload nginx
 
# Laravel release switch (atomic symlink swap)
ln -sfn /var/www/api/releases/2026-11-05_1200 /var/www/api/current
sudo systemctl reload php8.5-fpm
php /var/www/api/current/artisan queue:restart
 
# Node: rolling restart of a pm2 cluster
pm2 reload web

07Hosting panels vs a plain server

Panels such as Plesk and cPanel install and manage this whole stack for you: web server, PHP versions, databases, mail, DNS, certificates and backups, all through a web UI. They are great when many small sites need to be managed by non-specialists, or when you sell hosting. The cost is control and overhead. Panels add licence fees, extra services that use memory and need patching, their own config templates that overwrite hand edits, and a bigger attack surface (the panel itself is a login page on the internet). A plain server with nginx, PHP-FPM, Node and a few scripts is leaner and fully understandable, but you own every piece: updates, certificates, backups and monitoring. For a handful of apps run by an engineer, plain is usually better; for dozens of brochure sites and mailboxes, a panel can still be the pragmatic choice.

Use it when

  • nginx: as the public entry point for almost any web app, for TLS, static files, compression and proxying.
  • PHP-FPM with separate pools: whenever you host PHP apps, especially more than one on the same server.
  • pm2 or systemd: to keep Node apps running, restart them on crash and start them on boot.
  • Apache behind nginx: when legacy apps depend on .htaccess rules or Apache-only modules.

Skip it when

  • Do not hand-roll nginx configs if a managed platform (a PaaS or serverless host) already fits your app and budget.
  • Do not expose Node or PHP-FPM ports directly to the internet; always keep a reverse proxy in front.
  • Do not add a hosting panel to a server that runs one or two apps you deploy yourself; it is overhead without benefit.

Alternatives

  • Caddy

    Automatic HTTPS and a very short config; great for small setups, smaller ecosystem than nginx.

  • Apache httpd

    Mature, flexible, .htaccess support; heavier per connection than nginx in classic modes.

  • Traefik

    Reverse proxy that configures itself from Docker or Kubernetes labels; popular with containers.

  • FrankenPHP / Laravel Octane

    Keeps the PHP app in memory between requests for speed; needs care with state.

Best practices

  1. Always run nginx -t before reload, and keep configs in version control.
  2. One server block per domain, one PHP-FPM pool and one system user per app.
  3. Point the document root at the public folder only; deny dotfiles except .well-known.
  4. Bind app servers to 127.0.0.1 or a Unix socket and let nginx be the only public listener.
  5. Redirect HTTP to HTTPS, enable HTTP/2 and gzip, and set long cache headers for fingerprinted assets.
  6. Use release folders plus an atomic symlink switch so deploys and rollbacks take seconds.
  7. Watch the nginx error log and the PHP-FPM log; they explain most 502 and 504 errors.

Common mistakes

  • Setting root to the project folder instead of public/, exposing .env and source code.
  • Restarting services during the day instead of reloading them, dropping live requests.
  • Leaving pm.max_children at the default (5) so the site queues under modest traffic, or setting it so high the server swaps.
  • Forgetting the X-Forwarded-Proto header, so the app thinks requests are HTTP and builds wrong redirect URLs.
  • Editing panel-managed config files by hand and losing the changes on the next panel update.

Deep dive

08Buffering, slow clients and streaming responses

One of nginx's most valuable jobs is invisible: buffering. When a phone on a weak connection uploads a form, nginx reads the whole request body first (in memory up to client_body_buffer_size, then in a temporary file) and only then hands it to PHP-FPM or Node. On the way back it reads the full upstream response quickly into proxy or FastCGI buffers and trickles it to the slow client itself. The expensive application worker is freed in milliseconds instead of being held for seconds by someone on a train. The exception is streaming: Server-Sent Events, long polling and streamed AI or report responses must reach the client as they are produced. For those locations turn proxy buffering off and raise proxy_read_timeout, which defaults to 60 seconds between two reads. An app can also disable buffering per response with the X-Accel-Buffering: no header.

nginx
location /events/ {
    proxy_pass http://web_app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;
    proxy_buffering off;          # send bytes as soon as they arrive
    proxy_cache off;
    proxy_read_timeout 1h;        # default 60s would cut idle streams
}

09Microcaching: surviving a traffic spike

For pages that are the same for every anonymous visitor (a product listing, a public API response), nginx can cache the upstream response for as little as one second. That sounds useless, but under 500 requests per second it turns 500 PHP executions into one. fastcgi_cache_lock makes concurrent requests for the same uncached key wait for a single upstream fetch instead of stampeding PHP, and fastcgi_cache_use_stale with updating serves the slightly old copy while one request refreshes it in the background. The danger is caching personal data. Only cache GET and HEAD for anonymous users: bypass the cache whenever a session cookie or Authorization header is present, and never cache responses that set cookies. The X-Cache header (HIT, MISS, BYPASS, STALE) lets you verify behaviour with curl -I. proxy_cache works the same way in front of Node apps.

nginx
# http context
fastcgi_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:50m
                   max_size=1g inactive=10m use_temp_path=off;
 
# inside location ~ \.php$
fastcgi_cache api_cache;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 1s;
fastcgi_cache_lock on;
fastcgi_cache_use_stale error timeout updating http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_bypass $cookie_app_session $http_authorization;
fastcgi_no_cache     $cookie_app_session $http_authorization;
add_header X-Cache $upstream_cache_status always;

10Rate limiting with limit_req

limit_req implements a leaky bucket per key, usually the client address. limit_req_zone lives in the http context and defines the key, a shared memory zone and the rate (r/s or r/m). On 64-bit systems each state takes 128 bytes, so a 10m zone tracks about 80,000 addresses. limit_req inside a location applies it. burst is the size of the queue for requests above the rate. Without nodelay those queued requests are delayed to match the rate, which keeps upstreams smooth but makes pages feel slow; with nodelay they are served immediately and only requests beyond the burst are rejected. delay=N (since 1.15.7) mixes both. Rejections default to 503, which looks like an outage to monitoring, so set limit_req_status 429. Use $binary_remote_addr rather than $remote_addr to save memory, and configure real IPs first if a CDN sits in front, or every visitor shares the CDN's bucket.

nginx
# http context
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m   rate=10r/s;
limit_req_status 429;
 
# server context
location = /api/login {
    limit_req zone=login burst=5 nodelay;
    try_files $uri /index.php?$query_string;
}
location /api/ {
    limit_req zone=api burst=20 nodelay;
    try_files $uri /index.php?$query_string;
}

11Edge cases: default servers, real IPs and connection limits

If a request's Host header matches no server_name, nginx hands it to the default server for that port, which is the first one defined unless one is marked default_server. Scanners that hit your IP directly would then see your first site and, over HTTPS, its certificate, revealing which domains live there. A catch-all server that rejects the TLS handshake (ssl_reject_handshake, nginx 1.19.4+) and closes plain HTTP with the non-standard 444 avoids that. Behind Cloudflare or a load balancer, use the realip module: set_real_ip_from for each trusted proxy range and real_ip_header CF-Connecting-IP or X-Forwarded-For, so logs, rate limits and fail2ban see the visitor. Never trust that header from untrusted sources. Finally, capacity: worker_processes times worker_connections caps concurrent connections, and a proxied request uses two (client plus upstream), so raise worker_rlimit_nofile along with worker_connections.

nginx
server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;   # no certificate leaked to IP scanners
    return 444;                # close the connection without a response
}
 
# http context, behind Cloudflare (one line per range from cloudflare.com/ips)
set_real_ip_from 173.245.48.0/20;
real_ip_header CF-Connecting-IP;

Real-world scenarios

01

Random 502 errors at lunchtime

Problem

A Laravel API works in the morning, then returns intermittent 502 and 504 errors every day around noon. Restarting PHP-FPM fixes it for an hour.

How to solve it

Read the FPM log first: 'server reached pm.max_children' confirms the pool is exhausted. Enable pm.status_path for that pool and watch active processes and the listen queue during the peak. Then find why workers are slow: enable the slow log (request_slowlog_timeout = 5s, slowlog path) to get stack traces of stuck requests, which usually point at an external API call without a timeout or an unindexed query. Fix that first, add HTTP client timeouts, and only then raise pm.max_children within the memory budget (free RAM divided by average worker RSS from ps). Restarting just hid the queue.

02

Uploads fail with 413 or silently empty files

Problem

Users report that uploading a 30 MB video fails. Sometimes the browser shows 413 Request Entity Too Large, sometimes the request succeeds but the file is missing in the app.

How to solve it

There are three limits in series and the smallest wins. nginx rejects bodies above client_max_body_size with 413, so raise it in the server or a specific upload location. PHP then enforces post_max_size and upload_max_filesize; when they are exceeded PHP discards the body and the app sees an empty request instead of an error, which explains the missing files. Set them in the pool with php_admin_value[post_max_size] = 64M and php_admin_value[upload_max_filesize] = 64M, reload FPM, and check request_terminate_timeout for slow links. If Cloudflare is in front, its plan limits request bodies too (100 MB on the free plan). For large media, upload directly to object storage with presigned URLs.

03

Every visitor has the same IP in the logs

Problem

After moving DNS to Cloudflare with proxying on, the access log shows only a handful of Cloudflare addresses. Laravel rate limiting now blocks everyone at once, and audit logs are useless.

How to solve it

Teach nginx who the real client is. Add set_real_ip_from lines for every range published at cloudflare.com/ips (IPv4 and IPv6, ideally generated by a small script on a schedule) and real_ip_header CF-Connecting-IP in the http context, then nginx -t and reload. $remote_addr, logs, limit_req and fail2ban now see the visitor. In Laravel, configure trusted proxies so request()->ip() and HTTPS detection use the forwarded headers, but only from your own nginx. Finally, restrict ports 80 and 443 to Cloudflare ranges so nobody can bypass the edge and spoof the header.

Cheat sheet

Test config and reload gracefully
sudo nginx -t && sudo systemctl reload nginx
Print the full merged config
sudo nginx -T | less
Enable a site on Ubuntu
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
Test a PHP-FPM config
sudo php-fpm8.5 -t
Expose FPM pool status (protect it)
pm.status_path = /fpm-status
Log slow PHP requests with stack traces
request_slowlog_timeout = 5s slowlog = /var/log/php8.5-fpm-api.slow.log
Check which sockets PHP-FPM listens on
sudo ss -xlp | grep php
Test a vhost before DNS points to it
curl -sI --resolve example.com:443:127.0.0.1 https://example.com
Watch errors live
sudo tail -f /var/log/nginx/error.log /var/log/php8.5-fpm.log
Return 429 instead of 503 for rate limits
limit_req_status 429;

In my real projects

  • I maintain Plesk servers where nginx sits in front of Apache and PHP-FPM, serving many Laravel and Next.js sites.
  • On my servers, Laravel queue workers run under supervisor, Node apps under pm2, and schedulers through cron.
  • On a dedicated SaaS server, nginx is the reverse proxy in front of the Docker Compose services.
  • My plan for consolidation is to run without a panel: plain nginx with PHP-FPM and Node apps behind it.

Questions & answers

Q1Why is nginx usually faster than Apache for static files and many connections?

nginx uses an event-driven, non-blocking model: a few worker processes each multiplex thousands of connections, so idle keepalive or slow clients cost very little memory. Classic Apache MPMs dedicate a process or thread to each connection, which gets expensive at high concurrency. Apache's event MPM narrowed the gap, and for dynamic requests the bottleneck is usually the application, not the web server. The other practical difference is .htaccess: Apache checks it on every request, which is flexible but slower, while nginx loads all config once at startup. That is why many stacks put nginx in front for TLS, static files and buffering, and keep Apache only where its modules are needed.

Q2How does nginx talk to PHP, and what causes a 502 Bad Gateway?

nginx forwards PHP requests over FastCGI to PHP-FPM, normally through a Unix socket. PHP-FPM keeps a pool of worker processes that execute the script and return the response. A 502 means nginx could not get a valid response from that upstream: PHP-FPM is down, the socket path or permissions are wrong, or a worker crashed. A 504 means the upstream took too long. My first steps are the nginx error log, the PHP-FPM log and systemctl status for the FPM service. A very common cause is 'server reached pm.max_children', meaning every worker is busy, often because of slow database queries or external API calls that need fixing, not just more workers.

Q3How would you host a Laravel API and a Next.js frontend on one server?

Two server blocks in nginx, one per domain. The API block uses the Laravel public folder as root, try_files to fall back to index.php, and fastcgi_pass to a dedicated PHP-FPM pool running as its own user. The frontend block proxies to the Next.js server on 127.0.0.1:3000, kept alive by pm2 or systemd, with forwarding headers and WebSocket upgrade support. Both get Let's Encrypt certificates, HTTP to HTTPS redirects, HTTP/2 and compression. Queue workers run under supervisor or systemd, the scheduler via cron. Deploys use release folders and a symlink switch for Laravel and pm2 reload for Next.js, so neither side has downtime.

Q4What is the difference between nginx reload and restart?

A restart stops the master process and all workers, then starts again: open connections are dropped and, if the new config is broken, the site stays down. A reload sends a signal to the master, which parses the new config; if it is valid, it starts new workers with it and tells old workers to stop accepting connections and exit after finishing current requests. If the config is invalid, the old one keeps running. So the safe habit is nginx -t followed by systemctl reload nginx. You only need a real restart for things like changing some low-level settings or upgrading the binary, and even that has a graceful in-place mechanism.

Q5Should we use a hosting panel like Plesk or cPanel?

It depends on who operates the server and how many sites it carries. A panel is worth it when you host many small sites, mailboxes and DNS zones and want non-engineers to manage them through a UI, with built-in backups and certificates. For a few custom applications deployed by developers, a plain server is leaner: no licence cost, less memory overhead, no panel login exposed to the internet, and configs you fully control and can keep in Git. The trade-off is that you must handle updates, certificates, backups and monitoring yourself, ideally scripted. Many teams move from a panel to plain servers once their apps outgrow brochure-site hosting.

Q6How do you keep a Node.js app running in production?

Run it under a process manager, never in a terminal session. pm2 is popular: it restarts the app on crash, can run several instances in cluster mode, rotates logs with a module, and pm2 startup plus pm2 save restores the processes after a reboot. A systemd unit with Restart=always, a dedicated user and environment file is an equally good, dependency-free option. Either way, bind the app to 127.0.0.1, put nginx in front for TLS and buffering, set memory limits so a leak triggers a restart instead of swapping, and expose a health endpoint that monitoring can check. For zero downtime, run at least two instances and reload them one at a time.

Senior-level questions

S1A single nginx and PHP-FPM server must survive a ten-times traffic spike next week. What do you do?

First measure the current ceiling with a load test against staging, watching FPM's active processes, CPU and database load, so I know which resource breaks first. Then remove work rather than add workers. Static assets go through a CDN with long cache headers. Anonymous pages get a one-second microcache with cache lock and stale-while-updating, which collapses identical requests. Expensive endpoints get rate limits returning 429. Slow side effects such as emails and webhooks move to queues. Only then do I size pm.max_children against real worker memory and, if CPU is the limit, scale vertically for the event. Adding workers without fixing the bottleneck usually just moves the queue into the database.

S2PHP-FPM or a long-running runtime like Laravel Octane or FrankenPHP: how do you decide?

PHP-FPM boots the framework on every request, which costs a few to tens of milliseconds but gives perfect isolation: no state leaks between requests, and a memory leak dies with the request. Octane on FrankenPHP, Swoole or RoadRunner keeps the booted application in memory, so latency and throughput improve noticeably for framework-heavy endpoints. The price is discipline: singletons, static properties and anything resolved at boot can carry one user's data into the next request, and leaks accumulate until a worker restarts. I choose FPM by default, and Octane when profiling shows bootstrap time dominates, the codebase is audited for request state, and the team has tests and max-request recycling to catch leaks.

S3How do you preserve the client IP and protocol through a CDN, nginx and the application safely?

Each hop must trust only the hop in front of it. At the CDN edge the client IP is known; the CDN adds a header such as CF-Connecting-IP. nginx uses the realip module with set_real_ip_from limited to the CDN's published ranges, so $remote_addr becomes the visitor only when the connection really comes from the CDN. nginx then sets X-Forwarded-For and X-Forwarded-Proto toward the app, and the framework trusts proxies only from 127.0.0.1 or the private load balancer address. The firewall allows web ports only from the CDN, otherwise anyone could connect directly and forge the header. Getting this wrong breaks rate limits, fraud checks, audit logs and HTTPS redirect detection.

S4When would you pick Caddy or Traefik over nginx?

Caddy is a strong choice for small setups where automatic certificate management and a ten-line config matter more than fine-grained tuning; it obtains and renews certificates by itself and has sane TLS defaults. Traefik fits container platforms: it discovers services from Docker labels or Kubernetes resources and reconfigures routes without reloads, which is ideal when services come and go. nginx wins when you need mature caching, detailed rate limiting, FastCGI to PHP-FPM, a huge body of documented configurations and predictable performance under load. Operationally, the best proxy is the one your team can debug at night. I standardise on nginx for PHP and mixed stacks, and would accept Traefik in a label-driven container environment.

Glossary

Reverse proxy
A server that accepts client requests and forwards them to backend applications, returning their responses as if it produced them.
Upstream
In nginx, the backend server or group of servers that requests are proxied to, such as a Node app or PHP-FPM.
FastCGI
A binary protocol a web server uses to send requests to a long-running application process manager such as PHP-FPM.
Pool (PHP-FPM)
A group of PHP worker processes with its own user, socket and limits, usually one per application.
worker_connections
The maximum number of simultaneous connections one nginx worker can open, including connections to upstreams.
Keepalive
Reusing one TCP connection for several HTTP requests, which saves handshakes between client, nginx and upstream.
Buffering
nginx storing a request or response fully before passing it on, so slow clients do not hold application workers.
Microcache
Caching dynamic responses for a very short time, often one second, to collapse bursts of identical requests.
Default server
The server block nginx uses for a port when the request's Host header matches no server_name.
OPcache
PHP's built-in cache of compiled scripts in shared memory, which avoids re-parsing PHP files on every request.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.