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 nginx config — on the VPS:

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

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;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.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://100.100.100.100/;
    }

    location @backend {
        include proxy_params;
        proxy_pass http://100.100.100.100;
    }
}

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. 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 (.env, .yml, .git, .tfstate, and others) before they reach the proxy or a PHP handler. The full file with rate-limited deny blocks 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;

    client_max_body_size 8M;  # Must match or exceed the proxy's value

    include drop.conf;
    include wp_security.conf;
    include static_caching.conf;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    include php.conf;
}

A note on client_max_body_size: 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 8 MB, any upload between 1 and 8 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_security.conf for WordPress-specific blocks, static_caching.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.