<?xml version="1.0"?>
<rss version="2.0"><channel><title><![CDATA[Linux & Self-Hosting Latest Topics]]></title><link>https://wtmsp.com/forum/10-linux-self-hosting/</link><description><![CDATA[Linux & Self-Hosting Latest Topics]]></description><language>en</language><item><title>Nightly MySQL and file backups with a shell script and cron</title><link>https://wtmsp.com/topic/17-nightly-mysql-and-file-backups-with-a-shell-script-and-cron/</link><description><![CDATA[<p>Most of the self-hosted apps we look after are a database plus a folder of uploads and config. You don't need a backup suite for that. A short shell script, cron and a second location do the job, as long as the database dump is consistent, retention is sensible and a copy gets off the box. Then you test the restore, because the day someone calls about missing files is a bad day to find out your backups were empty.</p>
<p>You'll need the host running the app with MySQL or MariaDB, <code>mysqldump</code> and <code>gzip</code> (in the MySQL/MariaDB client package), and somewhere off-box. That can be another server you can rsync to over SSH (<code>192.0.2.10</code> in the examples) or an object storage bucket with rclone configured.</p>
<h2>Keep the password off the command line</h2>
<p>A password on the command line shows up in <code>ps</code> and shell history. Put it in <code>/root/.backup.my.cnf</code> instead:</p>
<pre class="ipsCode" data-language="ini">[client]
user=backup
password=long-random-string</pre>
<pre class="ipsCode" data-language="bash">chmod 600 /root/.backup.my.cnf</pre>
<p>Give that MySQL user only what a dump needs, which is <code>SELECT</code>, <code>SHOW VIEW</code>, <code>TRIGGER</code>, <code>LOCK TABLES</code> and <code>PROCESS</code>. MySQL 8 wants <code>PROCESS</code> for tablespace information, or you can use <code>--no-tablespaces</code>.</p>
<h2>The script</h2>
<p>Save this as <code>/usr/local/sbin/backup-myapp.sh</code>:</p>
<pre class="ipsCode" data-language="bash">#!/usr/bin/env bash
set -euo pipefail

APP="myapp"
DB_NAME="myapp"
APP_DIR="/var/www/myapp"
BACKUP_DIR="/var/backups/${APP}"
RETENTION_DAYS=14
REMOTE="backup@192.0.2.10:/srv/backups/${APP}/"
STAMP="$(date +%F_%H%M)"

umask 077
mkdir -p "${BACKUP_DIR}"

DB_FILE="${BACKUP_DIR}/${APP}_db_${STAMP}.sql.gz"
FILES_FILE="${BACKUP_DIR}/${APP}_files_${STAMP}.tar.gz"

# Remove partial files if anything fails
cleanup() { rm -f "${DB_FILE}.tmp" "${FILES_FILE}.tmp"; }
trap cleanup EXIT

echo "$(date -Is) starting backup of ${APP}"

# 1. Database: consistent snapshot without locking InnoDB tables
mysqldump --defaults-extra-file=/root/.backup.my.cnf \
    --single-transaction --quick --routines --triggers --no-tablespaces \
    "${DB_NAME}" | gzip &gt; "${DB_FILE}.tmp"
gzip -t "${DB_FILE}.tmp"
mv "${DB_FILE}.tmp" "${DB_FILE}"

# 2. Uploads and config
tar -czf "${FILES_FILE}.tmp" -C "${APP_DIR}" uploads config
mv "${FILES_FILE}.tmp" "${FILES_FILE}"

# 3. Local retention
find "${BACKUP_DIR}" -type f -name "${APP}_*" -mtime +"${RETENTION_DAYS}" -delete

# 4. Off-box copy
rsync -a "${BACKUP_DIR}/" "${REMOTE}"

echo "$(date -Is) backup of ${APP} complete"</pre>
<pre class="ipsCode" data-language="bash">chmod 700 /usr/local/sbin/backup-myapp.sh
/usr/local/sbin/backup-myapp.sh
ls -lh /var/backups/myapp/</pre>
<p>A few parts of that script are there because they've burned us before. <code>set -euo pipefail</code> stops the script on any error, including a failed <code>mysqldump</code> in the middle of a pipe. Without <code>pipefail</code>, a failed dump piped into a successful <code>gzip</code> counts as success, and you get a tiny, useless file every night.</p>
<div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>The 20-byte backups</strong></p></div>
<p>Before we added <code>pipefail</code>, a client's backups ran "successfully" for weeks after a password change broke <code>mysqldump</code>. Each night produced a valid gzip of an empty stream. A size check or one restore test would have caught it on day one.</p>
</div>
<p><code>--single-transaction</code> takes a consistent snapshot of InnoDB tables without locking them, so people can keep using the app during the dump. It doesn't protect MyISAM tables, so if your app still uses those, check before relying on it. <code>--quick</code> streams rows instead of buffering whole tables in memory.</p>
<p>Writing to <code>.tmp</code> and renaming means a failed run never leaves behind a file that looks like a good backup, and <code>gzip -t</code> checks the archive is intact. The <code>find -mtime +14 -delete</code> line removes local copies older than 14 days, matched by name so it can't wander into other files.</p>
<p><code>rsync -a</code> without <code>--delete</code> means the remote keeps files even after local retention removes them, so give the remote its own retention job. That longer remote history matters more than it sounds. If backups have been failing for longer than your local retention window, local cleanup can leave you with nothing usable, and the remote is all you've got.</p>
<h2>Getting a copy off the box</h2>
<p>For rsync, set up an SSH key for root to the backup host and restrict it on that end. For object storage, swap the last step for rclone:</p>
<pre class="ipsCode" data-language="bash">rclone copy "${BACKUP_DIR}" "b2:example-backups/${APP}" --max-age 48h</pre>
<p>On-box backups cover you for a bad update or an accidental delete. They do nothing for a dead disk, a deleted VM, a compromised host or ransomware, because those take the backups along with the data. I like a pull-based setup even better, where the backup server connects in and fetches the files, so a compromised app server has no credentials that can delete the backup history. Object storage with versioning or object lock gets you something similar.</p>
<h2>Scheduling</h2>
<p>Create <code>/etc/cron.d/backup-myapp</code>:</p>
<pre class="ipsCode" data-language="plaintext">30 2 * * * root /usr/local/sbin/backup-myapp.sh &gt;&gt; /var/log/backup-myapp.log 2&gt;&amp;1</pre>
<p>Files in <code>/etc/cron.d</code> need the user field (<code>root</code> here), unlike a personal crontab. Pick a quiet time and stagger it from other heavy jobs.</p>
<p>Cron won't tell anyone when a job fails. Have the script ping a dead man's switch service (healthchecks-style) on success, so you get alerted when the ping doesn't arrive.</p>
<div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Prove it restores</strong></p></div>
<p>Do this when you set it up, and on a schedule after that (we do quarterly). Restore into a scratch database, never the live one:</p>
<pre class="ipsCode" data-language="bash">LATEST=$(ls -1t /var/backups/myapp/myapp_db_*.sql.gz | head -n1)

mysql -u root -p -e "CREATE DATABASE restore_test;"
gunzip -c "$LATEST" | mysql -u root -p restore_test
mysql -u root -p -e "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='restore_test';"
mysql -u root -p -e "DROP DATABASE restore_test;"

mkdir -p /tmp/restore-test
tar -xzf "$(ls -1t /var/backups/myapp/myapp_files_*.tar.gz | head -n1)" -C /tmp/restore-test
ls -R /tmp/restore-test | head
rm -rf /tmp/restore-test</pre>
<p>Because the dump was taken of a single database without <code>--databases</code>, it has no <code>CREATE DATABASE</code> or <code>USE</code> statements, so it restores cleanly into whatever database name you pick. Compare the table list and rough row counts with production. The best test is a full one, restoring both parts onto a spare VM and logging in to the app.</p>
<p>Also check the off-box copy actually exists by listing it from the backup host, not by trusting the log.</p>
</div>]]></description><guid isPermaLink="false">17</guid><pubDate>Sun, 18 Oct 2026 20:27:43 +0000</pubDate></item><item><title>Our Docker Compose routine for self-hosted apps</title><link>https://wtmsp.com/topic/16-our-docker-compose-routine-for-self-hosted-apps/</link><description><![CDATA[<p>When someone hands us a self-hosted app running in Docker, the trouble is almost never Docker itself. It's a <code>.env</code> full of passwords anyone on the box can read, data living somewhere nobody can point to, an update that quietly pulled a breaking major version, or backups nobody ever took. Compose fits this kind of work well because one YAML file describes the whole stack, so we use the same layout and routine on every Compose host. That way whichever tech gets the call already knows where everything is.</p>
<p>You'll need Docker Engine with the Compose plugin (<code>docker compose</code>, not the old <code>docker-compose</code>), and root or sudo.</p>
<h2>One directory per app under /opt</h2>
<pre class="ipsCode" data-language="plaintext">/opt/wiki/
  compose.yaml
  .env
  data/          # bind-mounted application data
  db/            # bind-mounted database data</pre>
<p>Everything about the app lives in one place, so backing it up, moving it or rebuilding the host means dealing with one directory. Compose also uses the directory name as the project name, so containers and networks get predictable names.</p>
<h2>Secrets go in .env, locked down</h2>
<pre class="ipsCode" data-language="ini">DB_PASSWORD=long-random-string
APP_SECRET=another-long-random-string</pre>
<pre class="ipsCode" data-language="bash">chmod 600 /opt/wiki/.env
chown root:root /opt/wiki/.env</pre>
<p>Compose reads <code>.env</code> from the project directory automatically for variable substitution, so <code>compose.yaml</code> can reference <code>${DB_PASSWORD}</code> and still be safe to share or commit. Never commit the <code>.env</code> itself.</p>
<h2>The compose file</h2>
<pre class="ipsCode" data-language="yaml">services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: wiki
      POSTGRES_USER: wiki
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./db:/var/lib/postgresql/data

  app:
    image: ghcr.io/example/wiki:2.5
    restart: unless-stopped
    depends_on:
      - db
    environment:
      DB_HOST: db
      DB_PASSWORD: ${DB_PASSWORD}
      APP_SECRET: ${APP_SECRET}
    ports:
      - "127.0.0.1:8080:3000"
    volumes:
      - ./data:/app/data</pre>
<p>A few things in there are on purpose. <code>unless-stopped</code> brings containers back after a crash or reboot but respects a deliberate <code>docker compose stop</code>. With no restart policy, nothing comes back after a reboot, and you find out when someone calls.</p>
<p>The image is pinned to <code>:2.5</code> rather than <code>:latest</code>, so an update is a decision and not a surprise. I bump the tag after reading the release notes.</p>
<p>The app is published on <code>127.0.0.1</code> only because a reverse proxy on the same host fronts it. Docker's port publishing bypasses typical host firewall rules, so don't count on those. The app reaches the database as <code>db</code> on the project's default network, which means the database port never gets published at all.</p>
<h3>Bind mounts or named volumes?</h3>
<p>For self-hosting I prefer bind mounts (<code>./data:/app/data</code>) inside the project directory. The data sits in a directory you chose, you can see it, back it up with ordinary tools, and it moves with the project. Named volumes are managed by Docker under <code>/var/lib/docker/volumes/</code> and avoid some file-ownership headaches, but they're easier to lose track of, and <code>docker compose down -v</code> deletes them. If your data is in named volumes, that's your data gone. Bind mounts aren't touched. We only use named volumes where an image's documentation strongly recommends them.</p>
<p>The cost of bind mounts is permissions. Containers that run as a non-root user need the host directory owned by that UID. Check the image docs, then <code>chown -R</code> the host directory to match before first start.</p>
<h2>Day to day</h2>
<pre class="ipsCode" data-language="bash">cd /opt/wiki
docker compose up -d                 # create or update and start everything
docker compose ps                    # status
docker compose logs -f --tail=100 app
docker compose restart app
docker compose down                  # stop and remove containers (data stays)</pre>
<h2>Updating without surprises</h2>
<pre class="ipsCode" data-language="bash">cd /opt/wiki
# 1. back up first (Step 6)
# 2. change the image tag in compose.yaml if moving versions
docker compose pull
docker compose up -d
docker compose logs -f --tail=100
docker image prune -f</pre>
<p><code>pull</code> fetches new images and <code>up -d</code> recreates only the containers whose image or configuration changed. Watch the logs until migrations finish before you call it done.</p>
<h2>Backups</h2>
<p>For a consistent copy, stop the stack, archive the directory and start it again:</p>
<pre class="ipsCode" data-language="bash">cd /opt
docker compose -f /opt/wiki/compose.yaml stop
tar -czf /var/backups/wiki-$(date +%F).tar.gz wiki
docker compose -f /opt/wiki/compose.yaml start</pre>
<p>If you can't take the downtime, dump the database with its own tool instead (for example <code>docker compose exec -T db pg_dump -U wiki wiki</code>) and archive the data directory alongside it. Either way, copy the result off the host.</p>
<h2>Cap the logs before they fill the disk</h2>
<p>The default json-file log driver has no size limit. Set one in <code>/etc/docker/daemon.json</code>:</p>
<pre class="ipsCode" data-language="json">{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}</pre>
<p>Restart Docker afterwards. It only applies to newly created containers, so run <code>docker compose up -d --force-recreate</code> in each project to pick it up.</p>
<div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Don't docker cp your config in</strong></p></div>
<p>We once needed to customise a config file inside a running container, so we used <code>docker cp</code> to drop it in and restarted. The change disappeared, and on another image the container wouldn't start at all. Many images run an entrypoint script on every start that does a recursive <code>chown</code> of the data directory, regenerates config from environment variables, or runs under <code>set -e</code> so any unexpected state aborts startup. Whatever you copied in gets overwritten, has its ownership changed, or trips the script. And it's lost entirely the next time an update recreates the container.</p>
<p>Put the file in the project directory and bind-mount it instead, read-only where you can:</p>
<pre class="ipsCode" data-language="yaml">    volumes:
      - ./data:/app/data
      - ./config/custom.ini:/app/config/custom.ini:ro</pre>
<p>Now the change is part of the project, survives updates and gets backed up with everything else.</p>
</div>
<div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Before I call a host done</strong></p></div>
<ul>
<li><code>docker compose ps</code> shows all services running (and healthy, if they define health checks)</li>
<li>Reboot the host and everything comes back without anyone touching it</li>
<li><code>ls -l /opt/wiki/.env</code> shows <code>-rw-------</code></li>
<li>A backup archive restored to a test host comes up with <code>docker compose up -d</code></li>
</ul>
</div>
<p>If you only have time for one of those, do the restore test. It's the one that tells you whether the rest of this was worth it.</p>]]></description><guid isPermaLink="false">16</guid><pubDate>Sun, 18 Oct 2026 20:26:42 +0000</pubDate></item><item><title>MySQL in Docker, with the socket shared so host apps think it's local</title><link>https://wtmsp.com/topic/15-mysql-in-docker-with-the-socket-shared-so-host-apps-think-its-local/</link><description><![CDATA[<p>This setup came out of a PHP app's web installer telling me <em>No such file or directory</em> when I pointed it at the database. We'd moved MySQL into a container because upgrades and rollbacks are much easier than with the distro package, but the app itself ran on the host under nginx and PHP-FPM. Apps like that usually expect MySQL on the local Unix socket at <code>/var/run/mysqld/mysqld.sock</code>, the same as a native install. So this is how we do it now, with TCP on loopback only and the socket shared to the host.</p>
<div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>Why localhost gave a file error</strong></p></div>
<p>We'd only published the TCP port, and the installer had the database host set to <code>localhost</code>. With mysqli, the word <code>localhost</code> doesn't mean TCP at all. It tries the default Unix socket (<code>/var/run/mysqld/mysqld.sock</code> on Debian builds), and that socket didn't exist because MySQL was only listening on TCP inside a container. The error sounds like a missing file in the app, which sent me looking in the wrong place. You can fix it by entering <code>127.0.0.1</code> as the host to force TCP, or by sharing the socket as below so <code>localhost</code> works the way the app expects. We went with the socket, so the app config looks exactly like a native install.</p>
</div>
<p>You'll need Docker Engine with the Compose plugin, and root or sudo. Make sure no MySQL or MariaDB server package is installed on the host, because it will fight over port 3306 and <code>/run/mysqld</code>. Client packages are fine.</p>
<h2>Project directory and root password</h2>
<pre class="ipsCode" data-language="bash">mkdir -p /opt/mysql/data
cd /opt/mysql</pre>
<p>Put the root password in <code>/opt/mysql/.env</code> and lock it down:</p>
<pre class="ipsCode" data-language="ini">MYSQL_ROOT_PASSWORD=change-me-to-a-long-random-string</pre>
<pre class="ipsCode" data-language="bash">chmod 600 /opt/mysql/.env</pre>
<h2>A socket directory that survives reboots</h2>
<p><code>/var/run</code> is a symlink to <code>/run</code>, which is a tmpfs, so anything you create there is gone after a reboot. A tmpfiles.d entry recreates it at boot with the right owner, before Docker starts the container. Without it, Docker creates the missing bind-mount source itself as a root-owned directory, and depending on the image and timing, mysqld may not be able to create its socket there. I'd rather not leave that to luck.</p>
<p>First check the UID of the mysql user inside the image:</p>
<pre class="ipsCode" data-language="bash">docker run --rm mysql:8.4 id mysql</pre>
<p>In the official image that's UID and GID 999. Create <code>/etc/tmpfiles.d/mysqld-docker.conf</code>:</p>
<pre class="ipsCode" data-language="plaintext">d /run/mysqld 0755 999 999 -</pre>
<p>The <code>0755</code> matters. The socket file itself is world-writable, but the host user (<code>www-data</code>, for example) has to be able to enter the directory, or the app gets "Permission denied" on the socket. Apply it now without rebooting:</p>
<pre class="ipsCode" data-language="bash">systemd-tmpfiles --create /etc/tmpfiles.d/mysqld-docker.conf
ls -ld /run/mysqld</pre>
<h2>The compose file</h2>
<p><code>/opt/mysql/compose.yaml</code>:</p>
<pre class="ipsCode" data-language="yaml">services:
  mysql:
    image: mysql:8.4
    container_name: mysql
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:3306:3306"
    volumes:
      - ./data:/var/lib/mysql
      - /var/run/mysqld:/var/run/mysqld</pre>
<p><code>127.0.0.1:3306:3306</code> publishes the port on loopback only. Leave the IP prefix off and Docker publishes on all interfaces, and Docker's own firewall rules are applied ahead of most host firewall setups, so a ufw "deny 3306" won't save you.</p>
<p>Bind-mounting <code>/var/run/mysqld</code> puts the container's socket directory on the host. When mysqld creates <code>mysqld.sock</code> inside the container, it shows up on the host at the same path.</p>
<h2>Start it and check the socket</h2>
<pre class="ipsCode" data-language="bash">docker compose up -d
docker compose logs -f mysql      # wait for "ready for connections", then Ctrl+C
ls -l /var/run/mysqld/</pre>
<p>You should see <code>mysqld.sock</code>. Confirm the server agrees on the path:</p>
<pre class="ipsCode" data-language="bash">docker exec -it mysql mysql -uroot -p -e "SHOW VARIABLES LIKE 'socket';"</pre>
<p>If your image version reports the socket somewhere else, change the bind mount to match that directory, or set the socket path explicitly in a config file mounted into <code>/etc/mysql/conf.d/</code>.</p>
<h2>The app's database and user</h2>
<pre class="ipsCode" data-language="bash">docker exec -it mysql mysql -uroot -p</pre>
<pre class="ipsCode" data-language="plaintext">CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'another-long-random-string';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';</pre>
<div class="ipsRichTextBox" data-i-background-color="indigo"><div class="ipsRichTextBox__title"><p><strong>Socket users and TCP users aren't the same</strong></p></div>
<p>MySQL sees connections over the Unix socket as coming from <code>localhost</code>. Connections through the published TCP port arrive from the Docker bridge network instead, so a <code>'appuser'@'localhost'</code> account won't match them. If the app will use TCP, create the user for the bridge subnet (or <code>%</code>, since the port is only on loopback). This is the part I always forget.</p>
</div>
<div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Making sure it holds up</strong></p></div>
<p>From the host, with only the client package installed:</p>
<pre class="ipsCode" data-language="bash">apt install -y default-mysql-client
mysql -u appuser -p appdb -e "SELECT CURRENT_USER(), @@hostname;"
mysql -h 127.0.0.1 -u root -p -e "SELECT 1;"
ss -ltnp | grep 3306</pre>
<p>The first <code>mysql</code> command uses the socket (no host given), the second uses TCP. <code>ss</code> should show 3306 bound to <code>127.0.0.1</code> only. Then reboot the host and check that <code>/run/mysqld</code> exists, the container is up and the socket is back.</p>
</div>]]></description><guid isPermaLink="false">15</guid><pubDate>Sun, 18 Oct 2026 20:25:42 +0000</pubDate></item><item><title>nginx and PHP-FPM behind a reverse proxy, without the redirect loops</title><link>https://wtmsp.com/topic/14-nginx-and-php-fpm-behind-a-reverse-proxy-without-the-redirect-loops/</link><description><![CDATA[<p>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.</p>
<p>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.</p>
<div class="ipsRichTextBox" data-i-background-color="blue"><div class="ipsRichTextBox__title"><p><strong>What I'm assuming you have</strong></p></div>
<ul>
<li>A Debian 12 LXC or VM for the app, <code>10.10.20.21</code> in the examples</li>
<li>A reverse proxy (nginx, Nginx Proxy Manager, Caddy, Traefik) at <code>10.10.20.5</code>, handling TLS</li>
<li>A DNS name pointed at the proxy</li>
</ul>
</div>
<h2>Install nginx and PHP-FPM</h2>
<pre class="ipsCode" data-language="bash">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/</pre>
<p>On Debian 12 the default PHP is 8.2 and the FPM socket is <code>/run/php/php8.2-fpm.sock</code>. Use whatever the <code>ls</code> actually shows you.</p>
<h2>The proxy side</h2>
<p>The proxy has to pass along the original host, client IP and scheme. In plain nginx on the proxy:</p>
<pre class="ipsCode" data-language="nginx">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;
}</pre>
<p>Most GUI proxies, Nginx Proxy Manager included, set these headers for you.</p>
<h2>The app's nginx site</h2>
<p>Create <code>/etc/nginx/sites-available/app.conf</code>:</p>
<pre class="ipsCode" data-language="nginx"># 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;
    }
}</pre>
<p>The <code>map</code> block sits outside <code>server</code> because it has to be in the <code>http</code> context. Debian includes site files inside <code>http</code>, so that's fine.</p>
<p><code>set_real_ip_from</code> plus <code>real_ip_header</code> make nginx replace <code>$remote_addr</code> 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.</p>
<p><code>fastcgi_param HTTPS</code> sets <code>$_SERVER['HTTPS']</code>, which is what most PHP apps and frameworks check when building URLs and deciding whether to redirect. Debian's <code>fastcgi.conf</code> (included by the snippet) also sends <code>HTTPS $https if_not_empty</code>, but <code>$https</code> 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 <code>TrustProxies</code>, for example).</p>
<p>A lot of guides online tell you to add <code>try_files $uri =404;</code> inside the PHP location. On Debian and Ubuntu, <code>snippets/fastcgi-php.conf</code> already has a <code>try_files</code> line, so adding yours makes <code>nginx -t</code> fail with <em>"try_files" directive is duplicate</em>. Leave it out. The snippet already returns 404 for PHP files that don't exist.</p>
<p>Enable the site and remove the default one:</p>
<pre class="ipsCode" data-language="bash">ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
nginx -t &amp;&amp; systemctl restart nginx</pre>
<div class="ipsRichTextBox" data-i-background-color="orange"><div class="ipsRichTextBox__title"><p><strong>The reload that looked like it worked</strong></p></div>
<p>If you skip <code>nginx -t</code> and run <code>systemctl reload nginx</code> 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 <code>nginx -t</code>, then restart, then actually test with a browser or <code>curl -I</code>.</p>
</div>
<h2>Lock down the backend</h2>
<p>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 <code>X-Forwarded-Proto</code> they like. Use the Proxmox firewall, nftables or your network firewall to allow TCP 80 from <code>10.10.20.5</code> only.</p>
<p>One more thing that bites the first time someone tries to upload a big file. <code>client_max_body_size</code> has to be raised on both the proxy and the backend, and PHP's <code>upload_max_filesize</code> and <code>post_max_size</code> in <code>/etc/php/8.2/fpm/php.ini</code> need to match. If they don't, uploads fail with a 413. Restart php8.2-fpm after changing them.</p>
<div class="ipsRichTextBox" data-i-background-color="green"><div class="ipsRichTextBox__title"><p><strong>Checking it end to end</strong></p></div>
<p>Drop a temporary test file in the web root:</p>
<pre class="ipsCode" data-language="bash">cat &gt; /var/www/app/public/ipcheck.php &lt;&lt;'EOF'
&lt;?php
header('Content-Type: text/plain');
echo 'REMOTE_ADDR: ' . $_SERVER['REMOTE_ADDR'] . "\n";
echo 'HTTPS: ' . ($_SERVER['HTTPS'] ?? '(unset)') . "\n";
EOF</pre>
<p>Browse to <code>https://app.example.com/ipcheck.php</code>. You should see your own public IP and <code>HTTPS: on</code>. Check that <code>tail -f /var/log/nginx/access.log</code> shows real client addresses, not <code>10.10.20.5</code>. Then delete the test file so it isn't sitting there for anyone to find.</p>
</div>]]></description><guid isPermaLink="false">14</guid><pubDate>Sun, 18 Oct 2026 20:24:42 +0000</pubDate></item></channel></rss>
