Reverse Proxying WordPress from a Home Server

Step 2: The static asset mirror

Dynamic requests cross the WAN link — they're small, and the home server's hardware makes short work of them. But static assets — images, CSS, JavaScript, fonts — would also cross that link on every uncached request. That's wasteful. A 2 MB header image takes 160 milliseconds over a 100 Mbps uplink, and every visitor loading the page for the first time pays that cost. An rsync cron job on the VPS pulls static files from the home server every 10 minutes.

Create a dedicated SSH key on the VPS for the rsync job — no passphrase, restricted to this one task:

# On the VPS — generate a key just for rsync
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_rsync -N "" -C "rsync-static-mirror"

The -N "" creates the key without a passphrase — cron can't prompt for one. Now get the public key onto the home server.

If the home server still accepts password authentication, copy the key with ssh-copy-id — then go lock down SSH using the guide linked below:

ssh-copy-id -i ~/.ssh/id_ed25519_rsync.pub username@100.100.100.100

If password authentication is already disabled — as it should be — append the public key manually on the home server:

# On the VPS — print the public key
cat ~/.ssh/id_ed25519_rsync.pub

# On the home server — append it to authorized_keys
echo "ssh-ed25519 AAAAC3..." >> ~/.ssh/authorized_keys

Limit what this key can do on the home server by adding command restrictions to its authorized_keys entry. The full approach — key hardening, authorized_keys options, and securing SSH — is covered in the Hardening SSH guide.

The rsync script — on the VPS:

#!/bin/bash
# /usr/local/bin/sync-remote-static.sh

TAILSCALE_IP="100.100.100.100"  # Your home server's Tailscale IP
REMOTE_USER="username"
REMOTE_PATH="/home/username/www/example.com/public_html/"
LOCAL_PATH="/home/username/www/example.com/public_html/"

rsync -avz --delete \
    -e "ssh -i /home/username/.ssh/id_ed25519_rsync" \
    --include='wp-content/***' \
    --include='wp-includes/***' \
    --exclude='*.php' \
    --exclude='wp-config.php' \
    --exclude='.htaccess' \
    --exclude='.user.ini' \
    "$REMOTE_USER@$TAILSCALE_IP:$REMOTE_PATH" \
    "$LOCAL_PATH"

PHP files are explicitly excluded. Even if the mirror copied everything, nginx on the VPS has no php.conf include for the proxied site — there is no PHP handler configured for it. A .php file served from the VPS disk is served as plain text, not executed.

Schedule it every 10 minutes:

# /etc/cron.d/sync-remote-static
*/10 * * * * username /usr/local/bin/sync-remote-static.sh > /dev/null 2>&1

For a busy editorial site where images are uploaded frequently, reduce the interval to 1–2 minutes. For a personal blog where new uploads are rare, 10 minutes is plenty. The first visitor after an upload gets the slower proxied path; the next rsync run picks up the file, and from then on it's served from the VPS disk.

The upstream pool — on the VPS:

Before the server block, define an upstream pool that points at the home server's Tailscale IP. The keepalive directives hold connections open between the VPS and the backend so nginx doesn't tear down and rebuild a TCP connection inside the WireGuard tunnel for every proxied request:

upstream home_backend {
    server 100.100.100.100:80;  # Home server's Tailscale IP
    keepalive 16;               # Keep up to 16 idle connections warm
    keepalive_timeout 120s;     # Hold idle connections open longer
    keepalive_requests 1000;    # Requests per connection before recycling
}

Without keepalive, every proxied request — and a single WordPress page issues one PHP request for the HTML plus follow-up requests for any assets not yet synced — pays the TCP three-way handshake over WireGuard. On a 20ms link that's 20ms of unnecessary latency per connection. With keepalive, the first request establishes the connection and subsequent requests reuse it. The keepalive_requests 1000 recycles each connection after a thousand requests to prevent the backend from holding stale connections indefinitely.

The nginx config — on the VPS:

Static paths are intercepted and served from local disk before the proxy directive is reached:

upstream home_backend {
    server 100.100.100.100:80;
    keepalive 16;
    keepalive_timeout 120s;
    keepalive_requests 1000;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com;

    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    root /home/username/www/example.com/public_html;

    access_log /var/log/nginx/example.access.log;
    error_log /var/log/nginx/example.error.log;

    include drop.conf;

    # --- Static assets served from local disk ---

    # Images — immutable, no WAN hop
    location ~* ^/wp-content/.*\.(png|jpg|jpeg)$ {
        try_files $uri @backend;
        expires max;
        add_header Cache-Control "public, no-transform, immutable";
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    }

    location ~* ^/wp-content/.*\.(gif|ico|webp|woff2|woff|ttf)$ {
        try_files $uri @backend;
        expires max;
        add_header Cache-Control "public, no-transform, immutable";
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    }

    # CSS, JS — revalidation allowed
    location ~* ^/wp-content/.*\.(css|js|html|htm|txt|xml|svg)$ {
        gzip_static on;
        try_files $uri @backend;
        expires max;
        add_header Cache-Control "public, must-revalidate, proxy-revalidate";
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    }

    # Catch-all for wp-content and wp-includes
    location /wp-content/ { try_files $uri @backend; }
    location /wp-includes/ { try_files $uri @backend; }

    # --- Dynamic requests proxied to home server ---

    location / {
        include proxy_params;
        proxy_pass http://home_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }

    location @backend {
        include proxy_params;
        proxy_pass http://home_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
}

How the try_files chain works: A request for /wp-content/uploads/photo.jpg checks the VPS disk first. If the rsync job synced it, nginx serves it directly — zero WAN hops, full cache headers. Cloudflare caches it at the edge. If the file hasn't been synced yet — uploaded within the last 10 minutes — try_files falls through to the named @backend location, which proxies to the home server.

proxy_http_version 1.1 and proxy_set_header Connection "" are what make the upstream keepalive pool actually work. Nginx talks HTTP/1.0 to backends by default, and HTTP/1.0 closes the connection after every request. Switching to 1.1 and clearing the Connection header lets nginx manage persistent connections itself — the upstream keepalive directive then controls how many stay open and for how long. These directives live in the location blocks, not in proxy_params, because not every proxy target benefits from keepalive and you don't want to force HTTP/1.1 on a backend that expects 1.0.

The HSTS header is repeated in every location block that uses add_header for other purposes — nginx replaces parent headers, it doesn't merge them, so omitting HSTS in these blocks would mean static assets go out without it.

The drop.conf include is the same housekeeping snippet used across every site on this server — it denies access to hidden files, backup files, and sensitive extensions before they reach the proxy or a PHP handler. The full file is in the Securing Nginx and PHP guide. The proxy_params include is the standard set of headers that tell the backend what the original request looked like:

# proxy_params — included in every proxy location
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 X-Forwarded-Host $http_host;

These headers are what let the backend WordPress site know it's running behind a proxy — X-Forwarded-Proto tells it the original connection was HTTPS even though the proxy talks to it over HTTP.


Step 3: The home server configuration

The backend nginx is minimal — HTTP only, no SSL, no HSTS. Tailscale encrypts the wire, so there's no security benefit to adding a second TLS layer inside the tunnel.

server {
    listen 100.100.100.100:80;  # Tailscale IP only
    listen 127.0.0.1:80;        # localhost for internal scripts

    server_name example.com;

    root /home/username/www/example.com/public_html;

    access_log /var/log/nginx/example.access.log;
    error_log /var/log/nginx/example.error.log;
	
    include wp.conf;
    
    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }
    
    include drop.conf;
	include static.conf;
	include php.conf;
}

A note on client_max_body_size (inside wp.conf): whatever you set on the home server must match or be less than what the VPS proxy allows. Nginx's default is 1 MB. If the proxy keeps that default and the home server is configured for 16 MB, any upload between 1 and 16 MB gets rejected by the proxy before the home server ever sees it — the client receives a 413 Request Entity Too Large and the upload silently fails. Set the directive in both places or, if you prefer to keep the proxy configuration leaner, set it on the proxy and let the home server match it. The WordPress on Nginx guide covers client_max_body_size tuning alongside PHP upload limits.

The backend uses the same modular snippet structure as any standalone site — drop.conf for hidden files and backup patterns, wp.conf for WordPress-specific blocks, static.conf for asset headers (without HSTS — the proxy owns that header), and php.conf for the PHP handler. The only difference: the server listens exclusively on the Tailscale IP and localhost. It is not reachable from the public internet. The full WordPress security configuration is covered in the WordPress on Nginx guide.

WordPress HTTPS awareness: Since the backend receives HTTP requests from the proxy, WordPress needs to know the original connection was HTTPS. Add this to wp-config.php:

// Force HTTPS awareness behind a proxy
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
    $_SERVER['HTTPS'] = 'on';
}

// Hardcode the public URL
define('WP_HOME', 'https://example.com');
define('WP_SITEURL', 'https://example.com');

// Disable built-in cron — use system crontab instead
define('DISABLE_WP_CRON', true);

Without the $_SERVER['HTTPS'] override, WordPress generates http:// URLs for every asset, every link, and every redirect — the admin panel breaks, mixed content warnings appear, and the site becomes unusable. The system cron replacement is covered in the WP-CLI guide.


Step 4: Cloudflare — transparent edge

Cloudflare sits in front of the VPS but makes zero security decisions. The same transparent proxy configuration applies here as on any origin — the VPS owns every header, every redirect, every policy:

SSL/TLS: Full (Strict)
Validates the VPS origin certificate. The home server never sees Cloudflare traffic — it only sees the VPS, which it trusts.
HSTS, Auto Minify, Rocket Loader, Polish: Off
The VPS owns every header and optimization decision. The full rationale for every toggle is in the Cloudflare Edge Configuration guide.

Cloudflare does what it's best at — DDoS protection, edge caching, anycast DNS — and the VPS handles everything else.