Category: WordPress

  • Troubleshooting PHP CPU Saturation in a Multi-Site WordPress Hosting Stack

    Running many independent WordPress installations behind one OpenResty/Nginx layer, one PHP-FPM container, and one MySQL instance creates a distinctive failure mode: the server may have abundant free memory while CPU utilization and load average climb until every site becomes slow.

    This article documents a production-style investigation of that pattern. The important lesson is not a single tuning value. It is the method: identify which layer is doing work, measure the work by site and request, find the application behavior multiplying it, reduce concurrency amplification, and verify the result with fresh statistics.

    1. The Initial Symptom

    A typical environment has ample free RAM, a generously sized InnoDB buffer pool, a large PHP-FPM pool, and many independent WordPress sites. Despite the available memory, load average and CPU utilization remain high, with the PHP container appearing to consume most of the CPU.

    This is a common point at which administrators make the wrong first move. Increasing memory, increasing pm.max_children, or increasing MySQL caches may make the system less stable because none of those actions answers the key question:

    Is the server short of memory, waiting for storage, executing expensive code, or admitting too much concurrent work?

    High load is not synonymous with high CPU usage. Linux load includes runnable tasks and tasks blocked in uninterruptible sleep, often storage I/O. Establish the resource bottleneck before tuning either MySQL or PHP.

    2. Classify the Bottleneck First

    Start with operating-system evidence:

    uptime
    nproc
    vmstat 1 10
    iostat -x 1 10
    docker stats
    

    Interpret the important signals as follows:

    SignalLikely meaning
    vmstat runnable queue r remains above the CPU core countCPU scheduling pressure
    us is highUser-space work, commonly PHP or MySQL query execution
    sy is highKernel, networking, container, or syscall overhead
    wa and iostat await are highStorage latency or queueing
    b is high while CPUs remain partly idleTasks blocked on I/O
    si or so is non-zeroActive swapping
    PHP container CPU dominates docker statsPHP request execution requires investigation

    Inspect PHP and MySQL at thread level when necessary:

    top -H -p "$(pidof php-fpm)"
    pidstat -t -p "$(pidof php-fpm)" 1
    top -H -p "$(pidof mysqld)"
    pidstat -u -w -p "$(pidof mysqld)" 1 10
    

    Do not treat a Docker CPU limit as a performance fix. It is useful for isolation, but it only limits the damage after expensive requests have already entered PHP.

    3. Prove Whether MySQL Is the Root Cause or a Victim

    WordPress requests often spend a large part of their lifetime waiting on MySQL. Conversely, inefficient WordPress plugin behavior can make both PHP and MySQL busy. The layers must be correlated rather than investigated independently.

    3.1 Check active database concurrency

    SHOW GLOBAL STATUS WHERE Variable_name IN (
        'Threads_connected',
        'Threads_running',
        'Max_used_connections',
        'Connections'
    );
    
    SHOW FULL PROCESSLIST;
    

    Threads_running is more useful than the number of connected or sleeping sessions. If it remains well above the available CPU core count, queries are competing for CPU or other shared resources. Raising max_connections does not resolve this; it permits a larger overload event.

    Check locks separately:

    SELECT * FROM sys.innodb_lock_waits;
    
    SELECT
        trx_id,
        trx_mysql_thread_id,
        trx_started,
        trx_state,
        trx_rows_locked,
        trx_rows_modified,
        LEFT(trx_query, 300) AS trx_query
    FROM information_schema.innodb_trx
    ORDER BY trx_started;
    

    3.2 Use statement digests, not isolated slow-query anecdotes

    The slow query log finds individually slow statements. Performance Schema digests also reveal inexpensive statements executed millions of times and queries whose aggregate cost dominates the server.

    SELECT
        SCHEMA_NAME,
        DIGEST_TEXT,
        COUNT_STAR,
        ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds,
        ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
        SUM_ROWS_EXAMINED,
        SUM_ROWS_SENT,
        SUM_CREATED_TMP_DISK_TABLES,
        SUM_SORT_ROWS
    FROM performance_schema.events_statements_summary_by_digest
    WHERE SCHEMA_NAME IS NOT NULL
    ORDER BY SUM_TIMER_WAIT DESC
    LIMIT 30;
    

    Read the digest table in two dimensions:

    • High aggregate time: statements that consume the most database capacity overall.
    • High execution count or rows examined: individually fast statements whose frequency or scan volume makes them expensive.

    A query does not need to appear in the slow query log to be a major resource consumer. For example, a 5 ms query executed ten million times consumes far more capacity than an isolated five-second administration query. Likewise, a lookup that returns one row but examines hundreds of thousands usually indicates an unsuitable access path.

    When a suspicious digest is found, identify the affected schema, capture the parameterized query shape, inspect the table and indexes, and test the execution plan:

    SHOW CREATE TABLE target_table\G
    SHOW INDEX FROM target_table;
    
    EXPLAIN ANALYZE
    SELECT selected_columns
    FROM target_table
    WHERE indexed_candidate = 'sample value'
    LIMIT 1;
    

    Use EXPLAIN instead of EXPLAIN ANALYZE if executing the statement could be unsafe or excessively expensive. Optimize from actual column types, selectivity, and query patterns rather than adding every filtered column to one large index.

    3.3 Read buffer-pool statistics correctly

    Collect the relevant status counters:

    SHOW GLOBAL STATUS WHERE Variable_name IN (
        'Innodb_buffer_pool_pages_dirty',
        'Innodb_buffer_pool_pages_free',
        'Innodb_buffer_pool_read_requests',
        'Innodb_buffer_pool_reads',
        'Innodb_buffer_pool_wait_free',
        'Innodb_log_waits'
    );
    

    With the usual 16 KiB InnoDB page size, free pages can be converted into approximate unused capacity. Physical reads should be compared with logical read requests, while dirty pages, buffer-pool waits, and redo-log waits reveal different forms of pressure.

    The buffer-pool hit rate can be estimated as:

    1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests

    If the hit rate is already extremely high and many pages remain free, adding more buffer-pool memory will not fix CPU-heavy scans. Data can be fully cached while MySQL still compares a large number of rows on every call. Conversely, do not use hit rate alone: examine waits, working-set size, query plans, and storage latency together.

    4. Fix the Application Multiplier Before Tuning Around It

    Once an expensive query or request pattern is identified, determine which WordPress component produces it. The source may be core behavior, a theme, a plugin, a scheduled task, an AJAX endpoint, a REST endpoint, or hostile traffic repeatedly invoking valid application code.

    Choose the least complex durable correction:

    1. Remove or disable a feature that has no business value.
    2. Reduce how often it runs through caching, batching, or scheduling.
    3. Fix its query shape or add a selective index after plan analysis.
    4. Move long-running work out of web requests and into controlled background jobs.
    5. Rate-limit or block abusive callers before they enter PHP.

    Avoid tuning infrastructure merely to preserve wasteful behavior. A query reduced from 500 ms to 50 ms is still expensive if an unnecessary endpoint calls it millions of times.

    For shared fixes across independently installed sites, a carefully maintained must-use plugin can enforce common policies from each site’s wp-content/mu-plugins/ directory. Use this only for behavior that is truly universal, document it, and test it against plugin and WordPress upgrades.

    Proxy identity is another common multiplier. Applications should prefer a trusted, normalized client address and must not blindly trust client-supplied X-Forwarded-For. Misconfigured proxy chains can duplicate addresses, defeat per-IP controls, and create inconsistent application keys.

    5. Why Excessive PHP-FPM Concurrency Amplifies Incidents

    Consider a PHP-FPM pool configured as:

    pm = static
    pm.max_children = 400

    In static mode, FPM maintains exactly pm.max_children workers. The settings pm.start_servers, pm.min_spare_servers, and pm.max_spare_servers do not apply.

    A very large static pool is not automatically capable of the same number of useful concurrent requests. When requests are CPU-heavy or generate expensive SQL, excessive concurrency creates:

    • excessive runnable processes and context switching;
    • contention for CPU caches and memory bandwidth;
    • a burst of simultaneous MySQL work;
    • increased tail latency;
    • a feedback loop in which slow requests occupy workers longer and admit more competing work.

    Nginx queueing a short burst is often healthier than allowing hundreds of WordPress requests to execute simultaneously.

    An illustrative dynamic-pool starting point for a large server is:

    [www]
    listen = 0.0.0.0:9000
    listen.backlog = 1024
    
    pm = dynamic
    pm.max_children = 100
    pm.start_servers = 16
    pm.min_spare_servers = 8
    pm.max_spare_servers = 24
    pm.max_requests = 500
    
    pm.status_path = /status
    slowlog = /var/log/php/fpm.slow.log
    request_slowlog_timeout = 3s
    request_terminate_timeout = 60s
    

    This is a starting point, not a universal formula. Choose the final limit from three constraints:

    1. Worker memory: measure RSS and preferably proportional set size under real traffic.
    2. CPU capacity: determine where additional concurrency stops increasing throughput.
    3. Backend capacity: ensure PHP cannot create more simultaneous database or external API work than those services can sustain.

    Useful worker-memory measurements include:

    ps --no-headers -o rss -C php-fpm | awk '
    {sum += $1; count++}
    END {
        if (count) {
            printf "workers=%d avg=%.2fMB total=%.2fMB\n", \
                count, sum/count/1024, sum/1024
        }
    }'
    

    pm.max_requests periodically replaces workers and limits long-term growth caused by extensions or plugin code. request_terminate_timeout prevents a pathological request from occupying a worker indefinitely, but long-running imports and maintenance tasks may require a separate pool or CLI worker rather than a globally larger timeout.

    6. Validate and Tune OPcache with Evidence

    Inspect OPcache with opcache_get_status(false) and focus on used and free bytecode memory, interned-string capacity, cached script count, restart counters, and JIT utilization. Common warning signs include little free memory, a completely full interned-strings buffer, or a large JIT allocation with negligible use.

    The configuration was changed to provide ample bytecode and interned-string capacity and to disable JIT:

    [opcache]
    opcache.enable = 1
    opcache.enable_cli = 0
    opcache.memory_consumption = 4096
    opcache.interned_strings_buffer = 256
    opcache.max_accelerated_files = 100000
    opcache.validate_timestamps = 1
    opcache.revalidate_freq = 60
    opcache.save_comments = 1
    opcache.jit = 0
    opcache.jit_buffer_size = 0
    

    These values are examples, not defaults for every host. Size the bytecode memory, interned-strings buffer, and accelerated-file table from measured usage, then leave reasonable growth capacity. A healthy steady state has free space, no recurring OOM or hash restarts, low wasted memory, and enough cached-key capacity for all active sites.

    A modest overall hit rate immediately after restart is not evidence of failure. A host with many sites must compile a large number of unique scripts during cache warm-up. The correct test is the incremental hit rate over a stable interval:

    incremental hit rate = delta(hits) / (delta(hits) + delta(misses))

    Do not keep enlarging OPcache once it has comfortable free space and no restart events. At that point, request behavior and concurrency are the more important CPU targets.

    7. Add One PHP-FPM Access Log for Every Site in the Pool

    Nginx access logs and PHP-FPM access logs are independent. A per-site Nginx access_log does not override the FPM pool’s access.log.

    Add the following once to the shared [www] pool:

    access.log = /var/log/php/fpm.access.log
    access.format = "%t host=%{HTTP_HOST}e client=%R method=%m uri=%{REQUEST_URI}e script=%f status=%s duration=%{milliseconds}dms memory=%{megabytes}MMB cpu=%C%%"
    

    Every request reaching this pool will be written to the same file, and host= identifies the site.

    Ensure the common FastCGI configuration supplies the required values:

    include fastcgi_params;
    
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param HTTP_HOST       $host;
    fastcgi_param REQUEST_URI     $request_uri;
    fastcgi_param REMOTE_ADDR     $remote_addr;
    fastcgi_param HTTP_X_REAL_IP  $remote_addr;
    fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;

    After real_ip_header has accepted a header only from trusted proxy addresses, $remote_addr should represent the normalized client IP. Avoid repeatedly appending the same address at every proxy layer.

    Validate before reloading:

    nginx -t
    php-fpm -tt

    In containers, verify the FPM SAPI configuration rather than assuming CLI php -i reads the same files.

    8. Rank URLs by Estimated CPU Time, Not by Summed Percentages

    The FPM %C field is the percentage of CPU used by a request during its lifetime. Simply adding percentages across requests is dimensionally wrong: a 100% request lasting 20 ms costs far less CPU than a 40% request lasting 5 seconds.

    Estimate CPU time per request as:

    CPU milliseconds = duration milliseconds x CPU percent / 100

    The following parser ranks host + URI by estimated cumulative CPU time. It expects the exact key-value log format shown above:

    awk '
    {
        host = uri = "";
        duration_ms = cpu_pct = 0;
    
        for (i = 1; i <= NF; i++) {
            if ($i ~ /^host=/) {
                host = substr($i, 6);
            } else if ($i ~ /^uri=/) {
                uri = substr($i, 5);
            } else if ($i ~ /^duration=/) {
                value = substr($i, 10);
                sub(/ms$/, "", value);
                duration_ms = value + 0;
            } else if ($i ~ /^cpu=/) {
                value = substr($i, 5);
                sub(/%$/, "", value);
                cpu_pct = value + 0;
            }
        }
    
        if (host != "" && uri != "") {
            key = host " " uri;
            cpu_ms[key] += duration_ms * cpu_pct / 100;
            wall_ms[key] += duration_ms;
            requests[key]++;
        }
    }
    END {
        for (key in requests) {
            printf "%.0f\t%d\t%.1f\t%.0f\t%s\n", \
                cpu_ms[key], \
                requests[key], \
                cpu_ms[key] / requests[key], \
                wall_ms[key] / requests[key], \
                key;
        }
    }' /var/log/php/fpm.access.log \
    | sort -t $'\t' -k1,1nr \
    | head -50
    

    The columns are:

    estimated_cpu_ms    requests    avg_cpu_ms    avg_wall_ms    host URI

    Use a similar aggregation by host to find the sites consuming the most PHP CPU:

    awk '
    {
        host = ""; duration_ms = cpu_pct = 0;
        for (i = 1; i <= NF; i++) {
            if ($i ~ /^host=/) host = substr($i, 6);
            else if ($i ~ /^duration=/) {
                v = substr($i, 10); sub(/ms$/, "", v); duration_ms = v + 0;
            } else if ($i ~ /^cpu=/) {
                v = substr($i, 5); sub(/%$/, "", v); cpu_pct = v + 0;
            }
        }
        if (host != "") {
            cpu_ms[host] += duration_ms * cpu_pct / 100;
            requests[host]++;
        }
    }
    END {
        for (host in requests)
            printf "%.0f\t%d\t%.1f\t%s\n", \
                cpu_ms[host], requests[host], cpu_ms[host]/requests[host], host;
    }' /var/log/php/fpm.access.log \
    | sort -t $'\t' -k1,1nr \
    | head -30
    

    These are estimates based on FPM’s reported average CPU percentage, but they are much more meaningful than sorting single requests or summing percentages.

    Normalize query strings when appropriate. Otherwise tracking parameters and search terms may split one endpoint into thousands of keys. Conversely, keep query strings when a particular parameter is suspected of triggering expensive behavior.

    9. Correlate Expensive URLs with Slow Stacks

    The FPM access log answers which host and URI consumed resources. The FPM slow log helps answer which PHP code path was active:

    slowlog = /var/log/php/fpm.slow.log
    request_slowlog_timeout = 3s

    Inspect it with:

    tail -200 /var/log/php/fpm.slow.log

    Look for plugin and theme paths in backtraces. Common expensive WordPress endpoints include:

    • /wp-cron.php;
    • /wp-admin/admin-ajax.php;
    • /xmlrpc.php;
    • REST endpoints under /wp-json/;
    • search, filtering, and uncached product pages;
    • form submission, search, filtering, and reporting endpoints.

    Slow wall time does not always mean high CPU. A request with low estimated CPU time but long duration is probably waiting on MySQL, storage, DNS, an external API, or a lock. A request with both high CPU time and high wall time is a stronger candidate for expensive PHP execution.

    10. Reduce How Often Requests Reach PHP

    FPM tuning controls the blast radius; it does not eliminate unnecessary executions. For mostly anonymous WordPress sites, the most effective CPU optimization is often full-page caching at Nginx, OpenResty, or the CDN.

    The desired path is:

    Client -> CDN or FastCGI cache hit -> response

    rather than:

    Client -> Nginx -> PHP-FPM -> WordPress bootstrap -> plugins -> MySQL

    Also consider:

    • disabling visitor-triggered WP-Cron and scheduling it from the system;
    • staggering cron execution across many sites instead of starting all jobs on the same minute;
    • auditing Action Scheduler backlogs and high-frequency hooks;
    • using persistent object caching where it measurably reduces repeat database work;
    • rate-limiting abusive endpoints before PHP;
    • applying bot controls at Cloudflare or Nginx, with carefully maintained allowlists;
    • disabling or replacing plugins whose per-request work has little business value.

    In the database digests, Action Scheduler lookups were individually fast but executed more than 160 million times. High-frequency cheap calls may not lead the aggregate-time table, yet they reveal a large background-work volume worthy of separate investigation.

    11. Logging and Proxy Configuration Pitfalls

    Nginx and FPM logs do not override each other

    An access_log inside a server block overrides inherited Nginx access logs at the http level for that server, unless multiple destinations are explicitly declared. It does not affect PHP-FPM’s access.log.

    Keeping per-site Nginx logs plus one shared FPM log is a useful arrangement:

    LogPrimary use
    Per-site Nginx access logTraffic, bots, status codes, total and upstream time
    Shared FPM access logPHP duration, memory, estimated CPU, executing script
    Shared FPM slow logPHP stack samples for slow requests

    proxy_set_header does not configure FastCGI

    Headers declared with proxy_set_header apply to proxy_pass. They do not automatically become FastCGI parameters. Values required by PHP must be supplied with fastcgi_param, preferably in a common include used by all sites.

    Rotate the shared FPM log

    A global log for many sites can grow rapidly. Configure rotation in the host or container logging system. A simple logrotate policy might use daily rotation, compression, seven retained files, and a safe FPM reopen signal. copytruncate is convenient but can lose a small number of lines and imposes copying overhead on large files; signaling FPM to reopen the log is preferable when supported by the deployment.

    12. A Reliable Verification Loop

    Performance Schema and OPcache counters are cumulative. Old values remain after a fix and can make successful changes look ineffective.

    Use a controlled before-and-after process:

    1. Record OS, FPM, MySQL, and OPcache baselines.
    2. Make one logically related change set.
    3. Validate configuration syntax.
    4. Restart or reload only the required service.
    5. Reset the relevant performance summary or record counter deltas.
    6. Observe through a representative traffic interval.
    7. Compare request rate, CPU time, latency, error rate, FPM queueing, and database work.

    To reset only MySQL statement digest statistics:

    TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;
    

    This does not delete application data. After waiting through a representative traffic interval, query the digest table again and compare the new aggregate time, execution count, rows examined, temporary tables, and sort work with the baseline.

    For PHP-FPM, monitor:

    listen queue
    max listen queue
    active processes
    idle processes
    max active processes
    max children reached
    slow requests
    

    Do not automatically raise pm.max_children when max children reached increases. If CPUs are already saturated, more workers usually increase contention. First determine whether queueing comes from insufficient capacity or slow and unnecessary requests.

    13. Recommended Incident Order of Operations

    For this class of multi-site WordPress incident, the following order minimizes guesswork:

    1. Classify CPU, I/O, swapping, or lock pressure with OS metrics.
    2. Identify the busiest container and processes.
    3. Inspect MySQL active threads, locks, and aggregate statement digests.
    4. Fix, cache, reschedule, rate-limit, or disable the dominant application behavior.
    5. Reduce excessive PHP-FPM concurrency to a measured starting point.
    6. Verify OPcache capacity and disable unhelpful JIT for WordPress workloads.
    7. Add one structured FPM access log for the shared pool.
    8. Rank sites and URLs by estimated CPU time, request volume, and wall time.
    9. Correlate expensive requests with FPM slow-log stacks.
    10. Add page caching, staggered cron, endpoint rate limits, and component-specific fixes.
    11. Reset or delta the counters and verify the improvement under real traffic.

    Conclusion

    In this class of incident, insufficient RAM is often not the central problem. High-frequency application work, excessive PHP concurrency, and weak request-level attribution combine to make every layer appear overloaded at once.

    The durable solution combined application, database, runtime, and observability changes:

    • remove unnecessary work and optimize the remaining query and request paths;
    • prevent PHP concurrency from overwhelming CPU and MySQL;
    • size OPcache from measured usage and interpret post-restart misses as warm-up;
    • log every FPM request with host, URI, duration, memory, and CPU percentage;
    • rank URLs using estimated CPU time rather than summed percentages;
    • keep anonymous requests out of PHP through caching and edge controls;
    • verify every change with fresh or delta-based counters.

    That method scales beyond WordPress. Whenever a shared application runtime hosts many tenants, aggregate cost and concurrency amplification matter more than isolated slow requests—and observability must preserve the tenant and endpoint dimensions needed to find them.

  • How to Configure Nginx as a Reverse Proxy for WordPress in a Subdirectory

    Running WordPress behind an Nginx reverse proxy requires more than simply forwarding requests to another server. When the site is published under a subdirectory such as /news, the proxy must preserve the request path, pass the original protocol and hostname, and safely restore the visitor’s real IP address.

    This guide explains how to expose a WordPress installation at:

    https://example.com/news/

    while WordPress is hosted on a separate backend server.

    Architecture Overview

    This setup uses two servers.

    Server A: Public Reverse Proxy

    • Accepts public HTTP and HTTPS traffic
    • Terminates TLS connections
    • Redirects HTTP traffic to HTTPS
    • Proxies requests under /news/ to Server B
    • Passes the original hostname, protocol, and client IP information

    Server B: WordPress Backend

    • Hosts the WordPress files
    • Runs Nginx and PHP-FPM
    • Accepts traffic from Server A
    • Restores the real client IP from trusted proxy headers
    • Recognizes that the original request used HTTPS

    The public WordPress URL will be:

    https://example.com/news/

    This guide assumes that WordPress is installed under the /news directory on Server B. For example:

    /var/www/example.com/news/

    Important Nginx Proxy Path Rule

    Nginx handles proxy_pass differently depending on whether the upstream address includes a URI path.

    For this setup, use the following configuration:

    location /news/ {
        proxy_pass http://wordpress_backend;
    }

    Because the proxy_pass directive does not include an additional URI, Nginx forwards the complete original request path.

    For example, this public request:

    /news/wp-admin/

    is forwarded to Server B as:

    /news/wp-admin/

    This avoids accidental path replacement caused by inconsistent trailing slashes in the location and proxy_pass directives.

    Step 1: Configure Nginx on Server A

    Create or edit the Nginx virtual host configuration for example.com.

    Depending on your Linux distribution, the configuration may be stored in one of these locations:

    /etc/nginx/conf.d/example.com.conf

    or:

    /etc/nginx/sites-available/example.com

    Define the WordPress Backend

    Using an upstream block makes the proxy configuration easier to maintain.

    upstream wordpress_backend {
        server B_SERVER_IP:80;
        keepalive 16;
    }

    Replace B_SERVER_IP with the private or public IP address of Server B.

    Whenever possible, use a private network address so that traffic between the two servers does not travel over the public Internet.

    Redirect HTTP Traffic to HTTPS

    server {
        listen 80;
        listen [::]:80;
    
        server_name example.com www.example.com;
    
        return 301 https://example.com$request_uri;
    }

    This redirects all HTTP requests to the canonical HTTPS hostname while preserving the original URI.

    Configure the HTTPS Reverse Proxy

    server {
        listen 443 ssl;
        listen [::]:443 ssl;
    
        server_name example.com;
    
        ssl_certificate     /etc/nginx/ssl/example.com/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/example.com/private.key;
    
        client_max_body_size 64m;
    
        location = /news {
            return 301 /news/;
        }
    
        location /news/ {
            proxy_pass http://wordpress_backend;
    
            proxy_http_version 1.1;
    
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
    
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    
            proxy_set_header Connection "";
    
            proxy_connect_timeout 10s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;
    
            proxy_buffering on;
            proxy_buffer_size 16k;
            proxy_buffers 8 16k;
            proxy_busy_buffers_size 32k;
        }
    
        add_header Strict-Transport-Security "max-age=31536000" always;
    }

    Understanding the Proxy Headers

    Preserve the Public Hostname

    proxy_set_header Host $host;

    This preserves the public hostname, such as example.com.

    WordPress uses the hostname when generating redirects, canonical URLs, administration URLs, media URLs, and other links.

    Forward the Original Protocol

    proxy_set_header X-Forwarded-Proto $scheme;

    This tells Server B whether the original visitor used HTTP or HTTPS.

    Although Server A may connect to Server B over HTTP, the browser connection can still use HTTPS. WordPress must know the original protocol to avoid redirect loops, insecure cookies, and mixed-content URLs.

    Forward the Client IP Address

    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    X-Real-IP contains the address that connected directly to Server A.

    X-Forwarded-For maintains the complete proxy chain by appending the current client IP to any existing forwarded addresses.

    Server B must trust these headers only when the request comes from Server A.

    Why proxy_redirect Is Usually Unnecessary

    Older reverse-proxy examples often include a directive similar to this:

    proxy_redirect http://B_SERVER_IP/news/ /news/;

    This rule is normally unnecessary when:

    • The original Host header is preserved
    • WordPress home and siteurl use the public HTTPS URL
    • WordPress correctly detects the forwarded HTTPS protocol

    It is generally better to correct URL generation at the WordPress and proxy-header level than to rewrite backend redirects manually.

    If Server B still returns redirects containing its private IP address or internal hostname, verify the WordPress URL settings and forwarded headers before adding a custom proxy_redirect rule.

    Test and Reload Nginx on Server A

    sudo nginx -t

    If the configuration test succeeds, reload Nginx:

    sudo systemctl reload nginx

    A reload is normally preferable to a restart because it applies the new configuration without abruptly terminating active connections.

    Step 2: Configure Real Client IP Handling on Server B

    Without additional configuration, Server B sees Server A as the direct client.

    Use the Nginx Real IP module to restore the original visitor IP address safely.

    Verify the Nginx Real IP Module

    nginx -V 2>&1 | grep -o 'http_realip_module'

    If the module is available, the command should return:

    http_realip_module

    Most standard Nginx packages include this module.

    Trust Only Server A

    Add the following directives inside the Nginx http block on Server B:

    http {
        set_real_ip_from A_SERVER_IP;
        real_ip_header X-Forwarded-For;
        real_ip_recursive on;
    
        # Other Nginx configuration...
    }

    Replace A_SERVER_IP with the address that Server A uses to connect to Server B.

    For example:

    set_real_ip_from 10.10.0.10;

    If the servers communicate over IPv6, add the trusted IPv6 address as well:

    set_real_ip_from 2001:db8::10;

    Do Not Trust All IP Addresses

    Avoid unrestricted configurations such as:

    set_real_ip_from 0.0.0.0/0;

    If Server B is reachable by untrusted clients, an attacker could submit a forged X-Forwarded-For header and impersonate another IP address.

    Only Server A and other known reverse proxies should be permitted to supply trusted client IP headers.

    How real_ip_recursive Works

    real_ip_recursive on;

    When multiple trusted proxies are involved, this setting instructs Nginx to examine the forwarded IP chain and select the last address that does not belong to a trusted proxy.

    It is also safe in a single-proxy architecture when the trusted proxy list is configured correctly.

    Step 3: Configure Nginx on Server B

    Because Server A preserves the original Host header, the backend virtual host should recognize example.com.

    A basic WordPress backend configuration may look like this:

    server {
        listen 80;
    
        server_name example.com;
    
        root /var/www/example.com;
        index index.php index.html;
    
        client_max_body_size 64m;
    
        location = /news {
            return 301 /news/;
        }
    
        location /news/ {
            try_files $uri $uri/ /news/index.php?$args;
        }
    
        location ~ ^/news/.*\.php$ {
            try_files $uri =404;
    
            include fastcgi_params;
    
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_param SCRIPT_NAME $fastcgi_script_name;
    
            fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        }
    
        location ~* ^/news/.*\.(?:css|js|jpg|jpeg|gif|png|svg|webp|ico|woff|woff2|ttf)$ {
            expires 7d;
            access_log off;
            try_files $uri =404;
        }
    }

    Adjust the PHP-FPM socket for the PHP version installed on Server B.

    Common examples include:

    /run/php/php8.3-fpm.sock
    /run/php/php8.4-fpm.sock

    PHP-FPM may also listen on a TCP port:

    fastcgi_pass 127.0.0.1:9000;

    Expected WordPress Directory Structure

    With this configuration, WordPress should be installed at:

    /var/www/example.com/news/

    The WordPress front controller should therefore be:

    /var/www/example.com/news/index.php

    Test and Reload Nginx on Server B

    sudo nginx -t
    sudo systemctl reload nginx

    Step 4: Make WordPress Recognize Forwarded HTTPS

    Server B receives an HTTP request from Server A even though the visitor connected to Server A over HTTPS.

    Without additional handling, WordPress may believe that the request is insecure. This can cause:

    • Infinite HTTPS redirect loops
    • HTTP administration URLs
    • Mixed-content warnings
    • Incorrect canonical URLs
    • Login and cookie problems

    Add the following code to wp-config.php before WordPress loads wp-settings.php.

    // Trust the forwarded protocol only when the request comes from Server A.
    $trusted_proxy_ips = [
        'A_SERVER_IP',
    ];
    
    if (
        in_array($_SERVER['REMOTE_ADDR'] ?? '', $trusted_proxy_ips, true)
        && isset($_SERVER['HTTP_X_FORWARDED_PROTO'])
    ) {
        $forwarded_proto = strtolower(
            trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0])
        );
    
        if ($forwarded_proto === 'https') {
            $_SERVER['HTTPS'] = 'on';
            $_SERVER['SERVER_PORT'] = 443;
        }
    }

    Replace A_SERVER_IP with the actual address of Server A.

    The code should be placed before this line:

    require_once ABSPATH . 'wp-settings.php';

    Do Not Rewrite REMOTE_ADDR in WordPress

    Some configurations use PHP code such as:

    $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP'];

    or:

    $_SERVER['REMOTE_ADDR'] = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];

    This is not recommended when Nginx is already configured with the Real IP module.

    Nginx should validate the trusted proxy and restore the real client IP before PHP receives the request.

    After the backend Nginx configuration is correct, this PHP variable should already contain the visitor IP:

    $_SERVER['REMOTE_ADDR']

    Directly trusting X-Real-IP or X-Forwarded-For inside PHP can create an IP spoofing vulnerability when Server B is reachable without passing through Server A.

    Step 5: Configure the Public WordPress URLs

    The WordPress Address and Site Address should both use the public HTTPS URL.

    In the WordPress dashboard, open:

    Settings → General

    Set the following values:

    WordPress Address (URL): https://example.com/news
    Site Address (URL):      https://example.com/news

    Define the URLs in wp-config.php

    You may explicitly define the public URLs in wp-config.php:

    define('WP_HOME', 'https://example.com/news');
    define('WP_SITEURL', 'https://example.com/news');

    This prevents the URLs from being changed through the WordPress dashboard and ensures that WordPress consistently generates the correct public address.

    Do not add a trailing slash to these values.

    Update the URLs with WP-CLI

    cd /var/www/example.com/news
    
    wp option update home 'https://example.com/news'
    wp option update siteurl 'https://example.com/news'

    Update the URLs with SQL

    UPDATE wp_options
    SET option_value = 'https://example.com/news'
    WHERE option_name IN ('home', 'siteurl');

    The database table prefix may not be wp_, so verify the actual options table name before running the query.

    Step 6: Restrict Direct Access to Server B

    Whenever possible, Server B should accept web traffic only from Server A.

    This can be enforced with:

    • A private network
    • A cloud security group
    • nftables
    • iptables
    • UFW or another host firewall
    • Nginx access-control directives

    For example, inside the backend server block:

    allow A_SERVER_IP;
    deny all;

    Be careful to allow any monitoring services, health checks, deployment systems, or trusted administration addresses that also require access.

    A firewall rule is generally stronger than an Nginx access rule because it blocks unwanted traffic before it reaches the web server.

    Step 7: Test the Reverse Proxy

    Test the Public WordPress URL

    curl -I https://example.com/news/

    A successful response may resemble:

    HTTP/2 200
    content-type: text/html; charset=UTF-8

    Test the Missing Trailing Slash

    curl -I https://example.com/news

    The expected response is:

    HTTP/2 301
    location: /news/

    Check for Redirect Loops

    curl -IL --max-redirs 10 https://example.com/news/

    The request should eventually return a successful response instead of repeatedly redirecting between HTTP and HTTPS or between different hostnames.

    Inspect Generated WordPress URLs

    curl -s https://example.com/news/ | grep -Eo 'https?://[^"]+' | head

    Generated URLs should begin with:

    https://example.com/news/

    They should not expose:

    • The backend IP address
    • The backend hostname
    • Plain HTTP URLs
    • URLs missing the /news prefix

    Verify the Real Client IP

    On Server B, monitor the Nginx access log:

    sudo tail -f /var/log/nginx/access.log

    Access the website from another device or network. The backend log should show the real visitor IP rather than the address of Server A.

    Create a Temporary PHP Diagnostic File

    <?php
    
    header('Content-Type: text/plain');
    
    echo 'REMOTE_ADDR: ';
    echo $_SERVER['REMOTE_ADDR'] ?? 'not set';
    
    echo PHP_EOL;
    
    echo 'X-Forwarded-For: ';
    echo $_SERVER['HTTP_X_FORWARDED_FOR'] ?? 'not set';
    
    echo PHP_EOL;
    
    echo 'X-Forwarded-Proto: ';
    echo $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'not set';
    
    echo PHP_EOL;
    
    echo 'HTTPS: ';
    echo $_SERVER['HTTPS'] ?? 'not set';

    Remove the diagnostic file immediately after testing because it exposes request and infrastructure information.

    Step 8: Troubleshoot Common Problems

    Problem: Too Many Redirects

    Common causes include:

    • WordPress does not recognize X-Forwarded-Proto
    • WP_HOME or WP_SITEURL uses HTTP
    • Server A and Server B both apply conflicting HTTPS redirects
    • A WordPress plugin forces a different hostname
    • A CDN or another proxy sends an unexpected forwarded protocol chain

    Verify that Server A sends the following headers:

    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Port $server_port;

    Then confirm that wp-config.php changes HTTPS to on only when the request comes from the trusted proxy.

    Problem: WordPress Redirects to the Backend IP

    wp option get home
    wp option get siteurl

    Both values should be:

    https://example.com/news

    Also verify that Server A preserves the public hostname:

    proxy_set_header Host $host;

    Problem: CSS, JavaScript, or Images Return 404

    Confirm that the WordPress URLs include /news and that the backend files are located under:

    /var/www/example.com/news/

    Also verify that Server A preserves the original URI:

    location /news/ {
        proxy_pass http://wordpress_backend;
    }

    Do not unintentionally strip the /news/ prefix from the upstream request.

    Problem: Backend Logs Show Server A’s IP

    Confirm that Server B contains:

    set_real_ip_from A_SERVER_IP;
    real_ip_header X-Forwarded-For;
    real_ip_recursive on;

    Then verify that Server A sends:

    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    Reload Nginx on both servers after changing the configuration.

    Problem: WordPress Login Does Not Persist

    This is usually caused by inconsistent HTTPS detection, mismatched domains, or incorrect cookie paths.

    Confirm that:

    • The public URL always uses HTTPS
    • The website does not alternate between www and non-www hostnames
    • home and siteurl include /news
    • WordPress detects HTTPS before it initializes
    • No plugin forces a conflicting login URL

    Optional: Add Nginx Proxy Caching

    Nginx proxy caching can improve performance, but WordPress pages must not be cached indiscriminately.

    Logged-in users, administrators, previews, POST requests, WooCommerce sessions, and personalized pages should bypass the cache.

    Define the Cache Zone

    Add the following directive to the http block on Server A:

    proxy_cache_path /var/cache/nginx/wordpress
        levels=1:2
        keys_zone=wordpress_cache:100m
        max_size=10g
        inactive=60m
        use_temp_path=off;

    Create Cache Bypass Variables

    map $request_method $skip_cache_method {
        default 1;
        GET  0;
        HEAD 0;
    }
    
    map $http_cookie $skip_cache_cookie {
        default 0;
        ~*wordpress_logged_in 1;
        ~*wp-postpass 1;
        ~*comment_author 1;
        ~*woocommerce_items_in_cart 1;
        ~*woocommerce_cart_hash 1;
    }

    Enable Caching for /news/

    location /news/ {
        proxy_pass http://wordpress_backend;
    
        proxy_http_version 1.1;
    
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Connection "";
    
        set $skip_cache 0;
    
        if ($skip_cache_method) {
            set $skip_cache 1;
        }
    
        if ($skip_cache_cookie) {
            set $skip_cache 1;
        }
    
        if ($request_uri ~* "^/news/(wp-admin|wp-login\.php|wp-cron\.php|wp-json/)") {
            set $skip_cache 1;
        }
    
        proxy_cache wordpress_cache;
        proxy_cache_key "$scheme$request_method$host$request_uri";
    
        proxy_cache_bypass $skip_cache;
        proxy_no_cache $skip_cache;
    
        proxy_cache_valid 200 301 302 10m;
        proxy_cache_valid 404 1m;
    
        add_header X-Proxy-Cache $upstream_cache_status always;
    }

    This is only a starting point. Websites using WooCommerce, membership systems, multilingual plugins, dynamic personalization, or authentication integrations require additional cache exclusions.

    Production Configuration Checklist

    • https://example.com/news/ loads successfully
    • /news redirects once to /news/
    • Server A preserves the original request URI
    • The original Host header reaches Server B
    • WordPress detects the forwarded HTTPS protocol
    • home and siteurl use the public HTTPS URL
    • Server B trusts forwarded IP headers only from Server A
    • Direct backend access is restricted where possible
    • WordPress login and administration pages are not cached
    • Backend IP addresses do not appear in redirects or page source
    • Nginx logs on Server B contain the real visitor IP address

    Conclusion

    A reliable WordPress reverse-proxy setup depends on consistent handling across every layer.

    Server A must preserve the public hostname, request path, protocol, and client IP chain. Server B must trust only known proxies and use the Nginx Real IP module to restore the visitor address. WordPress must recognize the forwarded HTTPS protocol and generate URLs containing the correct /news prefix.

    With these elements configured correctly, WordPress can operate securely and predictably behind Nginx while remaining publicly accessible at:

    https://example.com/news/