tva
← Insights

Stacking Reverse Proxies: A Production Architecture

Four reverse proxies in front of a single application look, at first glance, like a system in need of simplification. Most tutorials covering production web infrastructure settle on one layer — Nginx, or Caddy, or Traefik — and treat anything more as over-engineering. But in reality, each of these tools solves a distinct problem, and when you understand the division of labor, the apparent redundancy resolves into something coherent.

This is the architecture we run for several client deployments: Cloudflare at the edge, Traefik as the container-aware entry point, Varnish for HTTP caching, Nginx as the application host, and the application itself at the end of the chain. The request flow is:

Cloudflare → Traefik → Varnish → Nginx → Application

What follows explains what each layer contributes, where the boundaries sit, and what the performance implications are — including the places where this stack creates overhead that a simpler setup would not.

What Each Layer Actually Does

Cloudflare operates at the edge of the public internet. It absorbs DDoS traffic, applies WAF rules, terminates TLS at globally distributed PoPs, and caches static assets close to end users. What it does not do well is make intelligent routing decisions about your containers, or cache application responses that require cookie inspection. That is someone else's job.

Traefik is the container-aware entry point inside your infrastructure. When you run a multi-tenant Docker stack, Traefik's value is that it reads container labels and routes accordingly — no manual configuration files that fall out of sync when containers restart or scale. It handles TLS certificates from Let's Encrypt for internal or staging domains, strips or rewrites headers, and enforces middleware chains such as IP allowlisting or basic auth. Traefik does not cache. It does not compress well under load. Its job is routing and middleware, and it does that job efficiently.

Varnish is the HTTP accelerator. It sits between the entry point and the application host specifically to absorb read traffic. A well-tuned Varnish instance can serve tens of thousands of requests per second from memory, returning full HTML pages in under a millisecond. Its VCL (Varnish Configuration Language) gives you precise control over what gets cached, for how long, and under what conditions — far more granular than anything Cloudflare's cache rules offer. This matters for authenticated applications where some responses could be cached per user with custom hashing (the example below does not do this; it passes logged-in traffic), or for pages that should carry different TTLs depending on query parameters.

Nginx, in this stack, plays the role it was originally designed for: serving files and proxying to an upstream application process. CloudPanel uses Nginx as its managed web server, meaning each vhost has an auto-generated configuration that handles PHP-FPM, static asset serving, and gzip compression. That configuration is maintained by CloudPanel and should not be edited directly. Nginx here is not a decision-making layer — it receives a request, proxies it to the application, and returns the response.

Traefik Configuration: Container Labels

Traefik routing for this stack is configured entirely via Docker Compose labels. The key insight when layering Varnish behind Traefik is that Traefik should forward to the Varnish container, not directly to Nginx. This is where most people get confused: the standard Traefik examples show a direct-to-app pattern, and grafting Varnish into that flow is not documented well.

services:
  varnish:
    image: varnish:7.5
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myapp.rule=Host(`www.example.com`)"
      - "traefik.http.routers.myapp.entrypoints=websecure"
      - "traefik.http.routers.myapp.tls=true"
      - "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
      - "traefik.http.services.myapp.loadbalancer.server.port=80"
      - "traefik.http.middlewares.myapp-proto.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.middlewares.myapp-proto.headers.customrequestheaders.X-Forwarded-Port=443"
      - "traefik.http.routers.myapp.middlewares=myapp-proto"
    networks:
      - traefik-public
      - internal

The X-Forwarded-Proto header is critical. Without it, your application sees each request as HTTP — because Varnish-to-Nginx communication runs over plain HTTP on the internal Docker network — which causes redirect loops in any application enforcing HTTPS. Traefik stamps the header before the request enters the internal network, Varnish passes it through untouched, and Nginx forwards it to the application. The X-Forwarded-Port companion header prevents the same class of redirect loop in applications that also inspect the port.

A second consideration: Traefik's passHostHeader setting defaults to true, which means Varnish receives the original Host header. This is what you want — Varnish's VCL can use it for cache keying, and Nginx uses it for vhost selection. If you override this, both Varnish and Nginx will receive the wrong host and fail silently in ways that are difficult to trace.

Varnish VCL: Cache Logic

The VCL below is a starting point for a WordPress or PHP application behind CloudPanel, not a finished production policy. It covers the common cases: bypass for logged-in users and non-idempotent requests, extended caching for static assets, tracking parameter normalization, and a grace period that serves slightly stale pages while Varnish refreshes them. Before production, also pass WooCommerce cart, checkout and account pages and the wp_woocommerce_session_ cookie. Because this vcl_backend_response ends with return(deliver), Varnish's built-in logic that makes responses with Set-Cookie or Cache-Control: private/no-store uncacheable does not run, so add equivalent checks.

vcl 4.1;

backend default {
    .host = "nginx";  # service name in docker-compose
    .port = "80";
    .connect_timeout = 5s;
    .first_byte_timeout = 30s;
    .between_bytes_timeout = 10s;
}

sub vcl_recv {
    # Pass admin and authenticated requests
    if (req.url ~ "^/wp-(admin|login|cron|json)" ||
        req.http.Cookie ~ "wordpress_logged_in_" ||
        req.http.Cookie ~ "woocommerce_items_in_cart") {
        return(pass);
    }

    # Only cache GET and HEAD
    if (req.method != "GET" && req.method != "HEAD") {
        return(pass);
    }

    # Normalise tracking parameters out of the cache key
    if (req.url ~ "[?&](utm_source|utm_medium|utm_campaign|fbclid|gclid)=") {
        set req.url = regsuball(req.url,
            "&(utm_source|utm_medium|utm_campaign|fbclid|gclid)=[^&]+", "");
        set req.url = regsuball(req.url,
            "\?(utm_source|utm_medium|utm_campaign|fbclid|gclid)=[^&]+&", "?");
        set req.url = regsub(req.url,
            "\?(utm_source|utm_medium|utm_campaign|fbclid|gclid)=[^&]+$", "");
    }

    # Remove cookies for static assets — allows caching
    if (req.url ~ "\.(css|js|png|jpg|jpeg|gif|ico|woff2?|svg|webp)(\?.*)?$") {
        unset req.http.Cookie;
    }

    return(hash);
}

sub vcl_backend_response {
    # Cache static assets for 7 days
    if (bereq.url ~ "\.(css|js|png|jpg|jpeg|gif|ico|woff2?|svg|webp)(\?.*)?$") {
        set beresp.ttl = 7d;
        set beresp.grace = 24h;
        unset beresp.http.Set-Cookie;
        return(deliver);
    }

    # Cache HTML pages for 5 minutes with a 60-second grace period
    if (beresp.status == 200 && beresp.http.Content-Type ~ "text/html") {
        set beresp.ttl = 300s;
        set beresp.grace = 60s;
    }

    # Never cache error responses
    if (beresp.status >= 500) {
        set beresp.ttl = 0s;
    }

    return(deliver);
}

sub vcl_deliver {
    # Expose cache status for debugging
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
        set resp.http.X-Cache-Hits = obj.hits;
    } else {
        set resp.http.X-Cache = "MISS";
    }

    # Remove server identity headers
    unset resp.http.Via;
    unset resp.http.X-Varnish;

    return(deliver);
}

The tracking parameter normalization in vcl_recv is easy to overlook and expensive to neglect. Unless you configure URL rewriting at Cloudflare, UTM values and fbclid tokens arrive at Varnish with the original URL intact. Without normalization, every Facebook ad click generates a unique cache entry for what is, from the application's perspective, the same page. Over a campaign period, this fragments the Varnish cache into thousands of cold entries and eliminates the hit ratio benefit entirely.

The grace setting in vcl_backend_response is the other mechanism worth understanding in detail. When a cached object's TTL expires, the next request would normally wait for Nginx to return a fresh response before Varnish delivers anything. Under a traffic spike — a newsletter send, a social media mention — multiple requests arrive simultaneously for the expired entry. Without grace, Varnish's request coalescing still sends a single fetch to Nginx for a cacheable object, but the other requests wait until that fetch completes. With a 60-second grace period, Varnish serves the stale object to those requests immediately while a single background fetch refreshes the cache. The result is that Nginx handles one request instead of hundreds at the moment of expiry, and visitors do not wait for it.

Performance Implications

The performance story for this stack is not uniform. There are places where it excels and places where it adds measurable overhead.

Where it performs well: any cacheable page served from Varnish's memory returns in under 2ms at the Traefik level. A WordPress homepage that takes 400ms to generate from PHP gets served in 1–2ms for every subsequent request until the TTL expires. The throughput improvement is not incremental — it is an order of magnitude. Static asset serving through Nginx is fast regardless, and Cloudflare caches those assets at the edge in any case, so Nginx rarely handles them in production.

Where it adds overhead: cache misses now traverse four hops instead of one. A PHP request that cannot be cached — a logged-in user session, a POST submission, a WooCommerce cart page — passes through Traefik, Varnish, and Nginx before reaching PHP-FPM. Each Docker network hop on the same host adds approximately 0.1–0.3ms of latency. For a 50ms PHP response, this is acceptable. For an API endpoint that should respond in under 5ms, it is not. In that case, the correct approach is to route the API path directly to the application container via a separate Traefik router rule, bypassing Varnish entirely.

Memory sizing matters more than most parameters: Varnish allocates its object store from RAM. The official Docker image defaults to 100MB. For a site with many unique URLs, this fills quickly, the eviction rate climbs, and the cache hit ratio stays low regardless of TTL settings. The first performance lever to pull after deployment is increasing the allocation — the -s malloc,2G startup flag, or the VARNISH_SIZE=2G environment variable in Docker — and monitoring the result with varnishstat -1 | grep hit.

SSL termination: Public TLS terminates at Cloudflare. With Cloudflare's Full (strict) SSL mode, Cloudflare then connects to Traefik over HTTPS using the router's Let's Encrypt certificate shown above. Behind Traefik, the Traefik-to-Varnish and Varnish-to-Nginx segments run over plain HTTP on the Docker internal bridge network. This is intentional. On a single host where you control every container, adding TLS to internal container communication introduces CPU overhead for limited security gain, because bridge traffic does not leave the host; it would not protect you against a compromised container or host. If your threat model requires encrypted inter-container communication, that is a different architectural conversation.

Where This Stack Makes Sense

This is not a general-purpose recommendation. For a single containerized application with no caching requirements and no CloudPanel constraint, Traefik routing directly to the app is simpler and correct. The four-layer stack earns its complexity in specific scenarios: CloudPanel-managed environments where Nginx is not negotiable, CMS-backed sites where full-page caching provides order-of-magnitude throughput improvements, or multi-service deployments where Traefik is already the routing layer for a dozen containers and adding Varnish for one of them is incremental cost, not a new system.

The separation of concerns is what makes the complexity manageable over time. Traefik configuration lives in Docker Compose labels. Varnish logic lives in VCL files version-controlled alongside the compose stack. Nginx configuration is owned by CloudPanel. None of these systems reach into the others' domain. When something behaves unexpectedly, the diagnostic path is clear: the X-Cache header tells you whether Varnish served the response; Traefik's access log records the router, the backend service and the status codes returned by the upstream and sent to the client; Nginx's error log records what PHP-FPM reported. Each layer produces its own observable signal, and failures do not compound silently across layers.

What looks like redundancy is, in practice, a clear boundary between edge protection, routing, caching, and application serving — four concerns that scale independently and fail independently. That boundary is worth the additional configuration surface.

Related Insights

Further Reading