Publishing an internal WordPress website through an Nginx reverse proxy is a common solution when the application server does not have a public IP address.
The architecture often looks like this:
Internet
|
v
Public Nginx reverse proxy or WAF
|
v
Internal WordPress hostname
|
v
WordPress, web server, or Kubernetes ingress
The reverse proxy terminates HTTPS and forwards requests to the internal WordPress server over HTTP.
At first, the website may appear to load correctly. However, links, images, stylesheets, scripts, form actions, AJAX calls, and redirects may still point to the internal hostname.
This leads to problems such as:
- navigation links returning 404 errors;
- images and CSS files failing to load;
- browser mixed-content warnings;
- login redirects going to an inaccessible internal domain;
- WooCommerce cart or checkout requests failing;
- WordPress admin pages redirecting incorrectly;
- AJAX and REST API calls using the wrong hostname;
- redirect loops between HTTP and HTTPS.
The root cause is usually that WordPress does not automatically know which public URL the visitor used. It only sees the hostname and protocol forwarded by the reverse proxy.
This article explains how to configure both Nginx and WordPress correctly while preserving the internal hostname required for backend routing.
Why WordPress Generates Internal URLs
WordPress generates URLs using several sources:
- the
homeoption; - the
siteurloption; - the HTTP
Hostheader; - the HTTPS status detected from the request;
- values stored directly in the database;
- plugin and theme configuration;
- reverse proxy headers.
Suppose the public website is available at:
https://shop.example-public.com
but the reverse proxy sends requests to:
http://wordpress.internal.example
If WordPress sees the internal hostname, it may generate HTML like this:
<a href="http://wordpress.internal.example/products/">Products</a>
<link
rel="stylesheet"
href="http://wordpress.internal.example/wp-content/themes/example/style.css"
>
<script
src="http://wordpress.internal.example/wp-includes/js/jquery/jquery.min.js">
</script>
These URLs may work inside the private network but will fail for external visitors.
The reverse proxy successfully delivers the first page, but the browser then requests the remaining resources directly from the private hostname.
Because that hostname is not publicly accessible, the requests fail.
Why X-Forwarded-Host Alone May Not Solve the Problem
A typical reverse proxy configuration includes:
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
These headers are correct and useful, but WordPress does not consistently use X-Forwarded-Host as its primary host value.
Many WordPress components still rely on:
$_SERVER['HTTP_HOST']
Similarly, WordPress may not recognize that the original connection used HTTPS unless the forwarded protocol is explicitly interpreted.
Therefore, merely sending forwarding headers may not be enough.
The public URL must be reconstructed safely inside WordPress.
The Host Header Routing Problem
A reverse proxy may be able to send the public hostname directly to the backend:
proxy_set_header Host $host;
In simple environments, this is often the best choice.
However, the backend may use hostname-based routing. This is especially common with:
- Kubernetes ingress controllers;
- shared Apache or Nginx virtual hosts;
- internal load balancers;
- multiple applications behind one internal endpoint;
- service meshes;
- development environments with virtual hosts.
For example, the internal ingress may only route requests when it receives:
Host: wordpress.internal.example
If the proxy sends:
Host: shop.example-public.com
the backend may return:
- a default virtual host;
- a 404 response;
- a different application;
- an ingress “not found” page.
This creates an important distinction:
- the backend may need the internal hostname for routing;
- WordPress must generate URLs using the public hostname.
The configuration must support both requirements.
Recommended Nginx Reverse Proxy Configuration
The following example preserves the internal Host header required by the backend while passing the external hostname through trusted forwarding headers.
server {
server_name shop.example-public.com;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/shop.example-public.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop.example-public.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://wordpress.internal.example:80;
proxy_http_version 1.1;
# Internal hostname required by the backend or ingress
proxy_set_header Host wordpress.internal.example;
# Trusted custom headers used by WordPress
proxy_set_header X-Public-Host shop.example-public.com;
proxy_set_header X-Public-Proto https;
# Standard reverse proxy headers
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
# Rewrite HTTP redirect headers returned by the backend
proxy_redirect
http://wordpress.internal.example/
https://shop.example-public.com/;
# Rewrite cookie domains when the backend explicitly sets one
proxy_cookie_domain
wordpress.internal.example
shop.example-public.com;
}
}
server {
listen 80;
server_name shop.example-public.com;
return 301 https://shop.example-public.com$request_uri;
}
What This Configuration Does
The configuration separates internal routing from public URL generation.
The line:
proxy_set_header Host wordpress.internal.example;
ensures that the internal web server or Kubernetes ingress routes the request to the correct application.
The custom headers:
proxy_set_header X-Public-Host shop.example-public.com;
proxy_set_header X-Public-Proto https;
tell WordPress which hostname and protocol external visitors are using.
The standard headers:
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
preserve useful information about the original client request.
Why Use a Custom Public Host Header
It may seem redundant to send both:
X-Forwarded-Host
and:
X-Public-Host
However, using a custom header can make the configuration more predictable.
In complex environments, standard forwarding headers may be:
- overwritten by another proxy;
- appended to an existing value;
- passed through multiple load balancers;
- interpreted differently by plugins;
- supplied directly by an untrusted client.
A custom header set by the final trusted reverse proxy provides a clear signal that the request came through the expected public entry point.
The WordPress configuration should still validate this value against a fixed hostname.
Configure WordPress to Recognize the Public URL
Add the following code to wp-config.php before:
require_once ABSPATH . 'wp-settings.php';
Example:
/*
* Public URL configuration for requests received through
* the trusted reverse proxy.
*/
$public_host = 'shop.example-public.com';
if (
isset($_SERVER['HTTP_X_PUBLIC_HOST']) &&
strtolower(trim($_SERVER['HTTP_X_PUBLIC_HOST'])) === $public_host
) {
$_SERVER['HTTP_HOST'] = $public_host;
$_SERVER['SERVER_NAME'] = $public_host;
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = '443';
if (!defined('WP_HOME')) {
define('WP_HOME', 'https://' . $public_host);
}
if (!defined('WP_SITEURL')) {
define('WP_SITEURL', 'https://' . $public_host);
}
}
Nginx converts the request header:
X-Public-Host
into the PHP server variable:
$_SERVER['HTTP_X_PUBLIC_HOST']
After the validation succeeds, WordPress sees the request as:
https://shop.example-public.com
rather than:
http://wordpress.internal.example
What WP_HOME and WP_SITEURL Mean
These two constants are related but not identical.
WP_HOME
WP_HOME defines the public address visitors use to access the website.
Example:
define('WP_HOME', 'https://shop.example-public.com');
It affects:
- frontend links;
- canonical URLs;
- navigation;
- many redirects;
- asset URLs;
- WooCommerce pages.
WP_SITEURL
WP_SITEURL defines the location of the WordPress installation.
Example:
define('WP_SITEURL', 'https://shop.example-public.com');
In a standard installation, WP_HOME and WP_SITEURL normally have the same value.
Defining them conditionally allows the same WordPress installation to remain accessible through both:
- the internal development hostname;
- the external reverse proxy hostname.
Why the Hostname Must Be Validated
Do not blindly trust a forwarded hostname like this:
define('WP_HOME', 'https://' . $_SERVER['HTTP_X_FORWARDED_HOST']);
That pattern can introduce a Host Header Injection vulnerability.
An attacker may attempt to send a forged header such as:
X-Forwarded-Host: attacker.example
Depending on the application, this could affect:
- password-reset links;
- redirect destinations;
- canonical URLs;
- generated emails;
- cache keys;
- security validation;
- links sent to administrators or customers.
A safer implementation compares the received value with a fixed allowed hostname:
$public_host = 'shop.example-public.com';
if (
isset($_SERVER['HTTP_X_PUBLIC_HOST']) &&
strtolower(trim($_SERVER['HTTP_X_PUBLIC_HOST'])) === $public_host
) {
// Apply public URL configuration.
}
This converts the forwarded header into a trusted boolean condition rather than using arbitrary user-controlled text.
Handling HTTPS Behind the Reverse Proxy
The external visitor connects using HTTPS, but the reverse proxy may connect to WordPress using plain HTTP.
From the backend’s perspective, the request may appear to be:
http://wordpress.internal.example
Without additional configuration, WordPress may:
- generate
http://links; - redirect HTTPS visitors back to HTTP;
- produce mixed-content warnings;
- fail secure-cookie checks;
- mishandle WordPress admin sessions;
- trigger redirect loops.
Setting:
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = '443';
tells WordPress that the original client connection was secure.
The Nginx proxy should also send:
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
A More Generic Forwarded-Protocol Configuration
In environments where both HTTP and HTTPS are intentionally supported, Nginx may forward the actual protocol dynamically:
proxy_set_header X-Forwarded-Proto $scheme;
WordPress can then interpret it:
if (
isset($_SERVER['HTTP_X_FORWARDED_PROTO']) &&
strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https'
) {
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = '443';
}
This should only be used when the forwarded header is supplied by a trusted proxy.
For a public endpoint that always enforces HTTPS, using a fixed value is simpler:
proxy_set_header X-Forwarded-Proto https;
What proxy_redirect Fixes
A backend may respond with:
Location: http://wordpress.internal.example/my-account/
The Nginx directive:
proxy_redirect
http://wordpress.internal.example/
https://shop.example-public.com/;
rewrites the response header to:
Location: https://shop.example-public.com/my-account/
This is useful for:
- WordPress login redirects;
- WooCommerce account redirects;
- checkout redirects;
- canonical redirects;
- plugin-generated HTTP
Locationheaders.
However, proxy_redirect only modifies HTTP response headers.
It does not rewrite links inside HTML, JavaScript, JSON, CSS, XML, or database content.
Why proxy_redirect Is Not Enough
Consider this response body:
<a href="http://wordpress.internal.example/products/">Products</a>
<img src="http://wordpress.internal.example/uploads/product.jpg">
The proxy_redirect directive does not modify those URLs because they are part of the HTML body rather than HTTP headers.
That is why the WordPress-side configuration is essential.
WordPress itself must generate the correct public URLs before the response reaches Nginx.
Cookie Domain Problems
Most WordPress installations use host-only cookies and do not explicitly set a domain. In that case, cookie rewriting may not be necessary.
Some plugins, authentication systems, or legacy configurations may return a cookie like:
Set-Cookie: session=...; Domain=wordpress.internal.example
A browser accessing the public domain will reject or ignore that cookie.
Nginx can rewrite it:
proxy_cookie_domain
wordpress.internal.example
shop.example-public.com;
This may be important for:
- WordPress login sessions;
- WooCommerce customer sessions;
- shopping carts;
- authentication plugins;
- security plugins;
- single sign-on integrations.
Do not add cookie rewriting automatically unless the backend actually returns an internal cookie domain. Inspect the response headers first.
Test Whether Cookie Rewriting Is Needed
Use:
curl -skI https://shop.example-public.com/
Look for:
Set-Cookie:
Then check whether the cookie contains:
Domain=wordpress.internal.example
When no explicit Domain attribute exists, the cookie is usually scoped automatically to the public hostname and no rewrite is required.
Test the Nginx Configuration
Before reloading Nginx, validate the syntax:
sudo nginx -t
A successful test should return output similar to:
nginx: configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload the service:
sudo systemctl reload nginx
On systems that do not use systemd, the command may instead be:
sudo service nginx reload
Verify the Public Homepage
Run:
curl -skI https://shop.example-public.com/
Check:
- the response status;
- the
Locationheader; - cookie domains;
- cache headers;
- whether any redirect points to the internal hostname.
A normal response may be:
HTTP/2 200
or a valid public redirect:
HTTP/2 301
Location: https://shop.example-public.com/
It should not contain:
Location: http://wordpress.internal.example/
Search the HTML for Internal URLs
Download the homepage and search for the internal hostname:
curl -sk https://shop.example-public.com/ \
| grep -oE 'https?://wordpress\.internal\.example[^"<> ]*' \
| head -20
When the configuration is correct, the command should return no results.
A broader search can be used:
curl -sk https://shop.example-public.com/ \
| grep -n 'wordpress.internal.example' \
| head -20
Test multiple pages, not only the homepage:
curl -sk https://shop.example-public.com/products/ \
| grep -n 'wordpress.internal.example'
curl -sk https://shop.example-public.com/cart/ \
| grep -n 'wordpress.internal.example'
curl -sk https://shop.example-public.com/my-account/ \
| grep -n 'wordpress.internal.example'
Test Redirects Separately
Use curl with redirects disabled first:
curl -skI https://shop.example-public.com/wp-admin/
Then follow redirects:
curl -skIL https://shop.example-public.com/wp-admin/
Review every Location header in the redirect chain.
The redirect chain should remain on the public hostname.
Test WooCommerce Pages
WooCommerce depends heavily on cookies, AJAX endpoints, generated URLs, and redirects.
Test at least:
/cart/
/checkout/
/my-account/
/wp-json/
/?wc-ajax=get_refreshed_fragments
Examples:
curl -skI https://shop.example-public.com/cart/
curl -skI https://shop.example-public.com/checkout/
curl -skI https://shop.example-public.com/my-account/
curl -sk https://shop.example-public.com/wp-json/ | head
Also test the website in a browser by:
- adding a product to the cart;
- changing quantities;
- opening the mini-cart;
- logging in;
- logging out;
- resetting a password;
- loading checkout;
- submitting an AJAX request;
- browsing product images;
- opening the REST API.
Stored URLs in the WordPress Database
Correcting WP_HOME, WP_SITEURL, and the request environment fixes dynamically generated URLs.
However, WordPress content may contain absolute internal URLs saved directly in the database.
Common examples include:
- image URLs inside page content;
- Elementor data;
- Gutenberg blocks;
- custom HTML widgets;
- menu items;
- theme settings;
- plugin settings;
- email templates;
- CSS generated by page builders;
- serialized PHP arrays;
- JSON configuration;
- WooCommerce product descriptions.
These values will continue to use the internal hostname until they are updated or dynamically filtered.
Use WP-CLI to Find Stored Internal URLs
Before changing anything, run a dry-run search:
wp search-replace \
'http://wordpress.internal.example' \
'https://shop.example-public.com' \
--all-tables \
--precise \
--dry-run
You may also need to search for an HTTPS internal URL:
wp search-replace \
'https://wordpress.internal.example' \
'https://shop.example-public.com' \
--all-tables \
--precise \
--dry-run
The dry run reports which tables and columns would be changed without modifying the database.
Be Careful When the Internal URL Must Remain Available
A permanent database replacement may not be appropriate when the same WordPress instance must remain accessible through both:
http://wordpress.internal.example
and:
https://shop.example-public.com
Replacing every internal URL with the public URL means internal users may also be redirected through the public reverse proxy.
This may be acceptable, but it should be an intentional decision.
Possible strategies include:
- Make the public URL canonical everywhere.
- Keep dynamic URLs conditional while replacing only content that external users need.
- Use relative URLs for selected custom content.
- Maintain separate databases for internal testing and public staging.
- create a dedicated public staging clone;
- migrate the installation completely to the public hostname.
For production systems, having one canonical URL is generally easier to maintain.
Back Up the Database Before Replacing URLs
Always create a database backup before running a real replacement:
wp db export before-public-url-replacement.sql
A compressed backup can be created with:
wp db export - | gzip > before-public-url-replacement.sql.gz
After reviewing the dry-run output, perform the replacement:
wp search-replace \
'http://wordpress.internal.example' \
'https://shop.example-public.com' \
--all-tables \
--precise
WP-CLI understands WordPress serialized data and is safer than a raw SQL string replacement.
Avoid commands such as:
UPDATE wp_options
SET option_value = REPLACE(
option_value,
'http://wordpress.internal.example',
'https://shop.example-public.com'
);
Raw replacements may corrupt serialized PHP values because serialized strings include their character lengths.
Clear WordPress and Proxy Caches
After changing URL behavior, clear all relevant caches:
- WordPress page cache;
- object cache;
- Redis or Memcached;
- WooCommerce transients;
- page-builder generated CSS;
- Nginx proxy cache;
- CDN cache;
- browser cache.
With WP-CLI, useful commands may include:
wp cache flush
For WooCommerce:
wp transient delete --all
For Elementor:
wp elementor flush-css
The exact commands depend on the installed plugins and infrastructure.
Regenerate Page-Builder Assets
Page builders may store generated CSS files containing absolute URLs.
For example, a generated CSS file might contain:
background-image:
url("http://wordpress.internal.example/wp-content/uploads/banner.jpg");
Even after WordPress starts generating correct URLs, existing cached CSS may still point to the internal hostname.
Regenerate the page-builder CSS and data after changing the public URL.
Also inspect:
/wp-content/uploads/
/wp-content/cache/
/wp-content/elementor/css/
or equivalent generated-content directories.
Avoid Using Nginx sub_filter as the Primary Fix
Nginx supports response-body replacement using sub_filter.
A temporary workaround might look like:
sub_filter
'http://wordpress.internal.example'
'https://shop.example-public.com';
sub_filter_once off;
Although this may appear to solve the problem, it is not a robust primary solution.
Potential issues include:
- compressed responses may not be rewritten;
- JSON may be altered unexpectedly;
- JavaScript strings may break;
- CSS responses may require separate MIME configuration;
- AJAX responses may behave differently;
- response buffering may increase memory use;
- caching becomes more complicated;
- WebSocket or streamed responses are unaffected;
- encoded or escaped URLs may not match;
- WordPress still believes it is running on the wrong URL.
The correct solution is to make WordPress generate the proper URLs.
sub_filter can be useful as a temporary diagnostic tool, but it should not replace correct application-level configuration.
Preventing Redirect Loops
A common redirect loop occurs when:
- the browser connects through HTTPS;
- Nginx forwards the request to WordPress over HTTP;
- WordPress detects HTTP;
- WordPress redirects to HTTPS;
- the reverse proxy sends the redirected request to WordPress over HTTP again;
- the process repeats.
This is prevented by ensuring that WordPress sees:
$_SERVER['HTTPS'] = 'on';
and that Nginx sends:
proxy_set_header X-Forwarded-Proto https;
Another redirect loop may occur when WP_HOME and WP_SITEURL disagree.
For example:
define('WP_HOME', 'https://shop.example-public.com');
define('WP_SITEURL', 'http://wordpress.internal.example');
Unless WordPress is intentionally installed in a different subdirectory, both values should normally use the same public origin.
Preserving the Original Client IP
Without reverse proxy headers, WordPress may record the reverse proxy’s IP address for every request.
This affects:
- access logs;
- security plugins;
- brute-force protection;
- rate limiting;
- WooCommerce fraud checks;
- audit trails;
- geolocation;
- analytics.
Nginx should send:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
However, WordPress and the backend web server must only trust these headers when the request originates from an approved reverse proxy.
Otherwise, external users may spoof their IP address by supplying their own X-Forwarded-For header.
At the network level, the backend should ideally accept traffic only from the reverse proxy.
Securing the Internal Backend
Publishing an internal WordPress instance through a reverse proxy does not automatically secure the backend.
Recommended controls include:
- allow backend traffic only from the reverse proxy;
- use firewall rules between the proxy and application network;
- do not expose the internal hostname publicly;
- validate custom proxy headers;
- enforce HTTPS on the public endpoint;
- keep WordPress, plugins, and themes updated;
- protect
/wp-admin/and login endpoints; - rate-limit authentication attempts;
- configure request-size limits;
- inspect uploaded files;
- enable security logging;
- ensure administrative cookies are secure;
- remove unnecessary server headers;
- use an application-aware WAF policy where appropriate.
The custom public-host header should not be accepted as proof of trust by itself. Network-level access restrictions remain important.
Handling Multiple Public Domains
Some installations may be published through more than one public hostname.
Instead of accepting any forwarded value, define an allowlist:
$allowed_public_hosts = [
'shop.example-public.com',
'staging-shop.example-public.com',
];
if (isset($_SERVER['HTTP_X_PUBLIC_HOST'])) {
$forwarded_host = strtolower(
trim($_SERVER['HTTP_X_PUBLIC_HOST'])
);
if (in_array($forwarded_host, $allowed_public_hosts, true)) {
$_SERVER['HTTP_HOST'] = $forwarded_host;
$_SERVER['SERVER_NAME'] = $forwarded_host;
$_SERVER['HTTPS'] = 'on';
$_SERVER['SERVER_PORT'] = '443';
if (!defined('WP_HOME')) {
define('WP_HOME', 'https://' . $forwarded_host);
}
if (!defined('WP_SITEURL')) {
define('WP_SITEURL', 'https://' . $forwarded_host);
}
}
}
This is safer than accepting an unrestricted header.
However, serving the same WordPress site from multiple canonical domains can create SEO and caching problems.
Whenever possible, choose one canonical public hostname and redirect alternative domains to it.
SEO Considerations
Incorrect reverse proxy configuration can affect search-engine visibility.
Possible SEO problems include:
- canonical tags pointing to an internal hostname;
- XML sitemaps containing inaccessible URLs;
- Open Graph tags using private domains;
- duplicate content across internal and public hosts;
- HTTP URLs appearing on HTTPS pages;
- broken structured-data URLs;
- internal URLs in product feeds;
- search engines indexing staging content;
- redirects to non-public hostnames.
Check the following elements in the rendered HTML:
<link rel="canonical" href="...">
<meta property="og:url" content="...">
<meta property="og:image" content="...">
Also verify:
/wp-sitemap.xml
/robots.txt
/feed/
/wp-json/
For WooCommerce, inspect:
- product schema;
- product image URLs;
- checkout URLs;
- account links;
- product feeds;
- Merchant Center feeds;
- transactional email links.
An internal or staging website should normally be blocked from search-engine indexing until it is ready.
WordPress provides the setting:
Settings → Reading → Discourage search engines from indexing this site
For stronger protection, use authentication or network restrictions rather than relying only on robots.txt.
Canonical Domain Strategy
A website should ideally have one canonical public origin, for example:
https://shop.example-public.com
Redirect all alternative versions:
http://shop.example-public.com
https://www.shop.example-public.com
http://www.shop.example-public.com
to the canonical version.
This reduces:
- duplicate content;
- session inconsistencies;
- cookie problems;
- cache fragmentation;
- analytics duplication;
- search-engine confusion.
Common Configuration Mistakes
Mistake 1: Forwarding Only the Internal Host
proxy_set_header Host wordpress.internal.example;
This may preserve backend routing but causes WordPress to generate internal links.
Fix: Pass a validated public hostname separately and configure WordPress to use it.
Mistake 2: Forwarding the Public Host to an Ingress That Does Not Recognize It
proxy_set_header Host $host;
This may cause the internal ingress to return a 404.
Fix: Either add the public hostname to the ingress rules or preserve the internal Host header and pass the public host separately.
Mistake 3: Using Only proxy_redirect
proxy_redirect fixes Location headers but does not modify HTML links or asset URLs.
Fix: Correct the WordPress request environment and URL constants.
Mistake 4: Trusting Arbitrary Forwarded Host Headers
Using a user-supplied forwarded hostname directly can create security issues.
Fix: Compare the value against a fixed hostname or allowlist.
Mistake 5: Forgetting the HTTPS State
WordPress sees the HTTP connection between the reverse proxy and backend and assumes the visitor used HTTP.
Fix: Set X-Forwarded-Proto and make WordPress recognize HTTPS.
Mistake 6: Replacing URLs Directly with SQL
Raw SQL replacements can damage serialized WordPress data.
Fix: Use wp search-replace with a backup and dry run.
Mistake 7: Forgetting Generated CSS and Caches
Page builders and caching plugins may continue serving old internal URLs.
Fix: regenerate assets and clear all cache layers.
Mistake 8: Exposing the Backend Directly
Attackers may bypass the WAF or public proxy by connecting directly to the backend.
Fix: restrict backend access to trusted proxy addresses.
Diagnostic Checklist
When a proxied WordPress website still produces internal URLs, check the following.
Reverse Proxy
- Does
proxy_passpoint to the correct internal service? - Does the backend require a specific internal
Hostheader? - Is
X-Forwarded-Protoset tohttps? - Is the public hostname forwarded?
- Are redirect headers rewritten?
- Are cookie domains correct?
- Does the HTTP endpoint redirect to HTTPS?
WordPress
- Does WordPress see the public hostname?
- Does WordPress detect HTTPS?
- Are
WP_HOMEandWP_SITEURLcorrect? - Are forwarded values validated?
- Are plugins overriding URL behavior?
- Are internal URLs stored in the database?
Application Content
- Do HTML links contain the internal hostname?
- Do CSS files contain internal image URLs?
- Do scripts or JSON responses contain internal endpoints?
- Do REST API responses use the public origin?
- Do WooCommerce AJAX calls work?
- Do transactional emails contain public URLs?
Infrastructure
- Can clients resolve the public hostname?
- Can the reverse proxy resolve the internal hostname?
- Can the reverse proxy reach the backend port?
- Does the backend firewall allow only the proxy?
- Does the ingress recognize the internal host?
- Are caches serving old content?
A Practical Troubleshooting Sequence
Use this order to isolate the problem efficiently.
Step 1: Test the Backend Directly
From the reverse proxy:
curl -I \
-H 'Host: wordpress.internal.example' \
http://wordpress.internal.example/
This verifies internal routing.
Step 2: Test the Public Endpoint
curl -skI https://shop.example-public.com/
Review status codes, redirects, and cookies.
Step 3: Inspect the Rendered HTML
curl -sk https://shop.example-public.com/ \
| grep -n 'wordpress.internal.example'
Step 4: Inspect Redirect Chains
curl -skIL https://shop.example-public.com/wp-admin/
Step 5: Check WordPress URL Values
wp option get home
wp option get siteurl
Remember that constants in wp-config.php can override database values.
Step 6: Search the Database
wp search-replace \
'wordpress.internal.example' \
'shop.example-public.com' \
--all-tables \
--precise \
--dry-run
Step 7: Clear Caches
Clear WordPress, object, page-builder, proxy, CDN, and browser caches.
Step 8: Test Functional Workflows
Test login, cart, checkout, account pages, AJAX, REST API, forms, emails, and file uploads.
Alternative Solution: Add the Public Hostname to the Backend Ingress
When possible, the cleanest infrastructure-level solution may be to configure the backend ingress to accept both hostnames:
spec:
rules:
- host: wordpress.internal.example
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
- host: shop.example-public.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wordpress
port:
number: 80
The reverse proxy can then send:
proxy_set_header Host $host;
This allows WordPress and the ingress to see the same public hostname.
Advantages include:
- simpler header handling;
- fewer WordPress-specific overrides;
- more natural virtual-host routing;
- easier troubleshooting.
However, this approach may not be possible when:
- the ingress configuration is shared;
- the public hostname must not exist internally;
- infrastructure changes are restricted;
- the backend application is managed separately;
- the public domain terminates only at the WAF;
- multiple proxy layers rewrite the hostname.
In those cases, preserving the internal host and passing a separate public host remains a practical solution.
Final Recommended Architecture
A reliable configuration should follow these principles:
- The public reverse proxy terminates TLS.
- The proxy forwards requests to the private WordPress service.
- The backend receives the hostname required for internal routing.
- WordPress receives a validated public hostname through a trusted header.
- WordPress recognizes the original HTTPS connection.
- Redirect headers are rewritten when necessary.
- Cookie domains are rewritten only when required.
- Stored internal URLs are identified with a dry-run search.
- Caches and generated assets are rebuilt.
- The backend is accessible only through trusted infrastructure.
Conclusion
Running WordPress behind an Nginx reverse proxy involves more than forwarding traffic from a public domain to an internal hostname.
The reverse proxy, internal routing layer, and WordPress application must agree on three separate pieces of information:
- where the request should be routed internally;
- which hostname the external visitor used;
- whether the original connection was secure.
The most common mistake is to preserve the internal Host header without informing WordPress about the public origin. WordPress then generates internal links, causing broken assets, invalid redirects, mixed content, and 404 errors.
A robust solution combines:
- correct Nginx proxy headers;
- explicit HTTPS detection;
- validated public-host handling;
- conditional
WP_HOMEandWP_SITEURLvalues; - redirect and cookie rewriting where necessary;
- database searches for stored absolute URLs;
- cache invalidation;
- functional testing of WordPress and WooCommerce workflows.
With these elements in place, an internal WordPress website can be published safely through a reverse proxy without exposing private hostnames or breaking frontend functionality.


