Outsmarting Your CDN's Upload Cap
The problem
We run a WordPress site – call it foo.com – behind a CDN, with nginx in front of PHP-FPM at the origin. The CDN caps request bodies at 200MB. That’s fine for normal traffic, but admins occasionally need to upload 1–2 GB files: database exports, media archives, backups. Every one of those uploads died with a 413, no matter how high we raised client_max_body_size on the origin – the CDN never let the request through in the first place.
The tempting fixes were all bad trades:
- Ask the CDN for a limit exception. Slow, often plan-restricted, and weakens the protection the CDN provides for everyone else.
- Move uploads to SFTP or S3. Technically fine, but means retraining admins and touching WordPress’s upload flow.
- Drop the CDN for the whole domain. Throws away caching and edge protection for all traffic to solve a problem only a few admins hit.
What we actually needed was smaller: one extra, uncapped door into the same backend, reserved for admin work.
The architecture
We stood up a second nginx virtual host on a second domain – bar.com – that sits outside the CDN and proxies to the exact same WordPress origin foo.com already uses.
Public visitors keep hitting foo.com, which stays behind the CDN exactly as before. Admins use bar.com, which proxies straight to the origin, no CDN in the path. Both routes land on the same nginx + PHP-FPM backend – one WordPress install, one database, nothing forked or duplicated. The only structural difference is which path a request takes to get there, and that difference is precisely what removes the 200MB ceiling for uploads.
bar.com straight through unmodified, and the page loads at bar.com while quietly trying to pull every asset from foo.com. That mismatch is what sub_filter exists to fix.What sub_filter does
sub_filter (from nginx’s ngx_http_sub_module) rewrites text inside a response body as it streams through the proxy – after the upstream generates the page, before nginx sends it to the client. The backend has no idea this is happening.
A few properties worth knowing:
- Matching is case-insensitive, and both the search and replacement strings can use variables like
$host, so one rule works correctly no matter which domain hits that block. - Multiple rules can stack in the same
location(nginx ≥ 1.9.4), so you can coverhttp://,https://, and protocol-relative URLs in a single pass. sub_filter_once offreplaces every match on a line, not just the first – necessary when a hostname shows up repeatedly across a page.sub_filter_typescontrols which MIME types get scanned (text/htmlonly by default). Widen it if your hostname also leaks into CSS, JS, or JSON responses.sub_filter_last_modified onpreserves the originalLast-Modifiedheader, which nginx strips by default once it starts editing the body.- It only works on uncompressed responses. If the upstream gzips its output,
sub_filtercan’t see inside it, and rewriting silently fails. Fix:proxy_set_header Accept-Encoding "";so nginx asks the upstream for plain text.
For a fixed-string swap like ours, the built-in module is more than enough. If you ever need real regex with capture groups, the third-party ngx_http_substitutions_filter_module (subs_filter) goes further, at the cost of not shipping with stock nginx.
Example configuration
server {
listen 443 ssl http2;
server_name bar.com;
ssl_certificate /path/to/certificate;
ssl_certificate_key /path/to/certificate/key;
# No CDN in front of this domain, so this limit is the real one.
client_max_body_size 2048M;
location / {
proxy_pass http://origin_backend; # same upstream foo.com uses
proxy_http_version 1.1;
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;
# sub_filter can't rewrite compressed bodies.
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html text/css application/javascript application/json;
sub_filter 'foo.com' 'bar.com';
sub_filter 'https://foo.com' 'https://bar.com';
sub_filter 'http://foo.com' 'http://bar.com';
}
}
WordPress’s own configuration – siteurl, database, plugins – still points at foo.com as the canonical domain. bar.com is purely a proxy-layer alias that also fixes up outgoing HTML so nothing visibly breaks. We locked bar.com down further with IP allow-listing (or basic auth, depending on the environment), since it’s meant for trusted admin use, not public traffic.
Why this holds up architecturally
- Single source of truth. One WordPress install, one database, one deploy pipeline – nothing to keep in sync.
- Blast-radius isolation. The CDN’s caching and WAF protections stay fully intact for
foo.comand public traffic.bar.comcarries none of that traffic or risk profile. - No application changes. The fix lives entirely in the reverse-proxy layer – easy to reason about, easy to remove later.
- The fix matches the failure. The 200MB limit was an edge-layer constraint, so the fix stayed at the edge layer instead of leaking into the application.
Things to watch for
- Silent no-ops from gzip. If
sub_filter“isn’t working”, checkAccept-Encodingfirst – this is the most common cause. - Cache header assumptions. The body is modified in flight, so treat
ETag/Last-Modifiedon this path with some suspicion. - Partial coverage. A hostname hiding in an inline
<script>block or a JSON response needs its MIME type added tosub_filter_types, or you’ll get a page that’s mostly fine except for one broken call. - Access control. An uncapped second entry point is only as safe as who’s allowed to reach it.
Takeaway
CDNs are excellent at protecting the traffic they’re built for, and inflexible about everything else. Rather than fighting the CDN or forking the application, a second, narrowly-scoped nginx front end – combined with a few sub_filter rules to keep the HTML self-consistent – gave admins the large-upload path they needed, without weakening anything the CDN was already protecting.