tva
← Insights

Stacking Reverse Proxies: A Production Architecture

Four reverse proxies in front of a single application looks, 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 labour, 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 are cacheable per-user, 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 working starting point for a WordPress or PHP application behind CloudPanel. It covers the common cases: bypass for logged-in users and non-idempotent requests, extended caching for static assets, tracking parameter normalisation, and a grace period to prevent cache stampedes.

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 normalisation in vcl_recv is easy to overlook and expensive to neglect. Cloudflare strips some parameters at the edge, but UTM values and fbclid tokens arrive at Varnish with the original URL intact. Without normalisation, 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, each request passes through to Nginx concurrently. With a 60-second grace period, Varnish serves the stale object to all but one of those requests while a single background fetch revalidates the cache. The result is that Nginx handles one request instead of hundreds at the moment of expiry.

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 default configuration uses 256MB. 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: TLS terminates at Cloudflare. In a Cloudflare-fronted setup, the Traefik-to-Varnish and Varnish-to-Nginx segments run over plain HTTP on the Docker internal bridge network. This is intentional. Adding TLS to internal container communication on the same host introduces CPU overhead without a meaningful security improvement — the threat model for Docker bridge traffic is categorically different from public internet traffic. 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 containerised 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 which middleware ran and what the upstream received; 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 routing, caching, application serving, and compute — four concerns that scale independently and fail independently. That boundary is worth the additional configuration surface.

Related Insights

Further Reading