Skip to content
View in the app

A better way to browse. Learn more.

Learn more

nginx and PHP-FPM behind a reverse proxy, without the redirect loops

Featured Replies

Most of the small web apps we host live the same way. One LXC container or VM per service on Proxmox, nginx and PHP-FPM inside it, and a separate reverse proxy in front handling TLS. Each app is isolated, backups and rebuilds are easy, and when a PHP upgrade goes sideways only one app is down instead of everybody's.

Two details get missed almost every time. The app needs to see the real client IP instead of the proxy's, and PHP needs to know the original request was HTTPS. Miss those and you get logs full of one IP, broken rate limiting and redirect loops.

What I'm assuming you have

  • A Debian 12 LXC or VM for the app, 10.10.20.21 in the examples
  • A reverse proxy (nginx, Nginx Proxy Manager, Caddy, Traefik) at 10.10.20.5, handling TLS
  • A DNS name pointed at the proxy

Install nginx and PHP-FPM

apt update
apt install -y nginx php-fpm php-mysql php-xml php-mbstring php-curl php-zip
systemctl enable --now nginx php8.2-fpm
ls /run/php/

On Debian 12 the default PHP is 8.2 and the FPM socket is /run/php/php8.2-fpm.sock. Use whatever the ls actually shows you.

The proxy side

The proxy has to pass along the original host, client IP and scheme. In plain nginx on the proxy:

location / {
    proxy_pass http://10.10.20.21;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Most GUI proxies, Nginx Proxy Manager included, set these headers for you.

The app's nginx site

Create /etc/nginx/sites-available/app.conf:

# Translate the proxy's scheme header into an on/off value for PHP
map $http_x_forwarded_proto $https_flag {
    default off;
    https   on;
}

server {
    listen 80;
    server_name app.example.com;
    root /var/www/app/public;
    index index.php index.html;

    # Trust X-Forwarded-For only from our reverse proxy
    set_real_ip_from 10.10.20.5;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    client_max_body_size 64m;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        fastcgi_param HTTPS $https_flag;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}

The map block sits outside server because it has to be in the http context. Debian includes site files inside http, so that's fine.

set_real_ip_from plus real_ip_header make nginx replace $remote_addr with the client IP from the header, but only when the request comes from the trusted proxy. Anyone else sending a forged header gets ignored.

fastcgi_param HTTPS sets $_SERVER['HTTPS'], which is what most PHP apps and frameworks check when building URLs and deciding whether to redirect. Debian's fastcgi.conf (included by the snippet) also sends HTTPS $https if_not_empty, but $https is empty on a plain HTTP listener, so only our value is sent. If the app has a "force HTTPS" setting and this flag isn't passed, it sees every request as plain HTTP and redirects forever. Some frameworks also need their own trusted proxy setting (Laravel's TrustProxies, for example).

A lot of guides online tell you to add try_files $uri =404; inside the PHP location. On Debian and Ubuntu, snippets/fastcgi-php.conf already has a try_files line, so adding yours makes nginx -t fail with "try_files" directive is duplicate. Leave it out. The snippet already returns 404 for PHP files that don't exist.

Enable the site and remove the default one:

ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
nginx -t && systemctl restart nginx

The reload that looked like it worked

If you skip nginx -t and run systemctl reload nginx with a broken config, the reload fails but nginx keeps running the old configuration. The site stays up, so it looks like nothing happened, and you lose time wondering why your change has no effect. Worse, the next full restart (or a reboot) fails and the site goes down. My habit is always nginx -t, then restart, then actually test with a browser or curl -I.

Lock down the backend

Only the proxy should be able to reach port 80 on the app container. If clients can hit it directly, they can bypass the proxy and send whatever X-Forwarded-Proto they like. Use the Proxmox firewall, nftables or your network firewall to allow TCP 80 from 10.10.20.5 only.

One more thing that bites the first time someone tries to upload a big file. client_max_body_size has to be raised on both the proxy and the backend, and PHP's upload_max_filesize and post_max_size in /etc/php/8.2/fpm/php.ini need to match. If they don't, uploads fail with a 413. Restart php8.2-fpm after changing them.

Checking it end to end

Drop a temporary test file in the web root:

cat > /var/www/app/public/ipcheck.php <<'EOF'
<?php
header('Content-Type: text/plain');
echo 'REMOTE_ADDR: ' . $_SERVER['REMOTE_ADDR'] . "\n";
echo 'HTTPS: ' . ($_SERVER['HTTPS'] ?? '(unset)') . "\n";
EOF

Browse to https://app.example.com/ipcheck.php. You should see your own public IP and HTTPS: on. Check that tail -f /var/log/nginx/access.log shows real client addresses, not 10.10.20.5. Then delete the test file so it isn't sitting there for anyone to find.

Create an account or sign in to comment

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.