Traefik Proxy, Frontend, Backend, Varnish container example#

This example is a very simple setup with one backend and data being persisted in a Docker volume.

Traefik Proxy in this example is used as a reverse proxy.

Varnish is used for caching.

A purger component is also used. This solves the problem of invalidating the cache in multiple Varnish servers, which could be desirable in containerized deployment.

Create a project space#

Create an empty project directory named traefik-volto-plone-varnish.

mkdir traefik-volto-plone-varnish

Change into your project directory.

cd traefik-volto-plone-varnish

Varnish configuration#

Create an empty directory named etc.

mkdir etc

Add there a file varnish.vcl that will be used by the Varnish image:

vcl 4.0;

import std;
import directors;

backend traefik_loadbalancer {
    .host = "webserver";
    .port = "80";
    .connect_timeout = 2s;
    .first_byte_timeout = 300s;
    .between_bytes_timeout  = 60s;
}

/* Only allow PURGE from localhost and API-Server */
acl purge {
  "localhost";
  "backend";
  "127.0.0.1";
  "172.16.0.0/12";
  "10.0.0.0/8";
  "192.168.0.0/16";
}

sub detect_protocol{
  unset req.http.X-Forwarded-Proto;
  set req.http.X-Forwarded-Proto = "http";
}

sub detect_debug{
  # Requests with X-Varnish-Debug will display additional
  # information about requests
  unset req.http.x-vcl-debug;
  # Should be changed after switch to live
  if (req.http.x-varnish-debug) {
      set req.http.x-vcl-debug = false;
  }
}

sub detect_auth{
  unset req.http.x-auth;
  if (
      (req.http.Cookie && (
        req.http.Cookie ~ "__ac(_(name|password|persistent))?=" || req.http.Cookie ~ "_ZopeId" || req.http.Cookie ~ "auth_token")) ||
      (req.http.Authenticate) ||
      (req.http.Authorization)
  ) {
    set req.http.x-auth = true;
  }
}

sub detect_requesttype{
  unset req.http.x-varnish-reqtype;
  set req.http.x-varnish-reqtype = "Default";
  if (req.http.x-auth){
    set req.http.x-varnish-reqtype = "auth";
  } elseif (req.url ~ "\/@@(images|download|)\/?(.*)?$"){
    set req.http.x-varnish-reqtype = "blob";
  } elseif (req.url ~ "\/\+\+api\+\+/?(.*)?$") {
    set req.http.x-varnish-reqtype = "api";
  } else {
    set req.http.x-varnish-reqtype = "express";
  }
}

sub process_redirects{
  // Add manual redurect
  if (req.url ~ "^/old-folder/(.*)") {
    set req.http.x-redirect-to = regsub(req.url, "^/old-folder/(.*)", "^/new-folder/\1");
  }

  if (req.http.x-redirect-to) {
    return (synth(301, req.http.x-redirect-to));
  }
}

sub vcl_init {
  new cluster_loadbalancer = directors.round_robin();
  cluster_loadbalancer.add_backend(traefik_loadbalancer);
}

sub vcl_recv {
  set req.backend_hint = cluster_loadbalancer.backend();
  set req.http.X-Varnish-Routed = "1";

  # Annotate request with x-forwarded-proto
  # We always serve requests over https, but talk to Traefik
  # and then to Volto and Plone using http.
  call detect_protocol;

  # Annotate request with x-vcl-debug
  call detect_debug;

  # Annotate request with x-auth indicating if request is authenticated or not
  call detect_auth;

  # Annotate request with x-varnish-reqtype with a classification for the request
  call detect_requesttype;

  # Process redirects
  call process_redirects;

  # Sanitize cookies so they do not needlessly destroy cacheability for anonymous pages
  if (req.http.Cookie) {
    set req.http.Cookie = ";" + req.http.Cookie;
    set req.http.Cookie = regsuball(req.http.Cookie, "; +", ";");
    set req.http.Cookie = regsuball(req.http.Cookie, ";(sticky|I18N_LANGUAGE|statusmessages|__ac|_ZopeId|__cp|beaker\.session|authomatic|serverid|__rf|auth_token)=", "; \1=");
    set req.http.Cookie = regsuball(req.http.Cookie, ";[^ ][^;]*", "");
    set req.http.Cookie = regsuball(req.http.Cookie, "^[; ]+|[; ]+$", "");

    if (req.http.Cookie == "") {
        unset req.http.Cookie;
    }
  }

  if (req.http.x-auth) {
    return(pass);
  }

  if (req.method == "PURGE") {
      if (!client.ip ~ purge) {
          return (synth(405, "Not allowed."));
      } else {
          ban("req.url == " + req.url);
          return (synth(200, "Purged."));
      }

  } elseif (req.method == "BAN") {
      # Same ACL check as above:
      if (!client.ip ~ purge) {
          return (synth(405, "Not allowed."));
      }
      ban("req.http.host == " + req.http.host + "&& req.url == " + req.url);
      # Throw a synthetic page so the
      # request won't go to the backend.
      return (synth(200, "Ban added"));

  } elseif (req.method != "GET" &&
      req.method != "HEAD" &&
      req.method != "PUT" &&
      req.method != "POST" &&
      req.method != "PATCH" &&
      req.method != "TRACE" &&
      req.method != "OPTIONS" &&
      req.method != "DELETE") {
      /* Non-RFC2616 or CONNECT which is weird. */
      return (pipe);
  } elseif (req.method != "GET" &&
      req.method != "HEAD" &&
      req.method != "OPTIONS") {
      /* POST, PUT, PATCH will pass, always */
      return(pass);
  }

  return(hash);
}

sub vcl_pipe {
  /* This is not necessary if you do not do any request rewriting. */
  set req.http.connection = "close";
}

sub vcl_purge {
  return (synth(200, "PURGE: " + req.url + " - " + req.hash));
}

sub vcl_synth {
  if (resp.status == 301) {
    set resp.http.location = resp.reason;
    set resp.reason = "Moved";
    return (deliver);
  }
}

sub vcl_hit {
  if (obj.ttl >= 0s) {
    // A pure unadulterated hit, deliver it
    return (deliver);
  } elsif (obj.ttl + obj.grace > 0s) {
    // Object is in grace, deliver it
    // Automatically triggers a background fetch
    return (deliver);
  } else {
    return (restart);
  }
}


sub vcl_backend_response {

  # Don't allow static files to set cookies.
  # (?i) denotes case insensitive in PCRE (perl compatible regular expressions).
  # make sure you edit both and keep them equal.
  if (bereq.url ~ "(?i)\.(pdf|asc|dat|txt|doc|xls|ppt|tgz|png|gif|jpeg|jpg|ico|swf|css|js)(\?.*)?$") {
    unset beresp.http.set-cookie;
  }
  if (beresp.http.Set-Cookie) {
    set beresp.http.x-varnish-action = "FETCH (pass - response sets cookie)";
    set beresp.uncacheable = true;
    set beresp.ttl = 120s;
    return(deliver);
  }
  if (beresp.http.Cache-Control ~ "(private|no-cache|no-store)") {
    set beresp.http.x-varnish-action = "FETCH (pass - cache control disallows)";
    set beresp.uncacheable = true;
    set beresp.ttl = 120s;
    return(deliver);
  }

  # if (beresp.http.Authorization && !beresp.http.Cache-Control ~ "public") {
  # Do NOT cache if there is an "Authorization" header
  # beresp never has an Authorization header in beresp, right?
  if (beresp.http.Authorization) {
    set beresp.http.x-varnish-action = "FETCH (pass - authorized and no public cache control)";
    set beresp.uncacheable = true;
    set beresp.ttl = 120s;
    return(deliver);
  }

  # Use this rule IF no cache-control (SSR content)
  if ((bereq.http.x-varnish-reqtype ~ "express") && (!beresp.http.Cache-Control)) {
    set beresp.http.x-varnish-action = "INSERT (30s caching / 60s grace)";
    set beresp.uncacheable = false;
    set beresp.ttl = 30s;
    set beresp.grace = 60s;
    return(deliver);
  }

  if (!beresp.http.Cache-Control) {
    set beresp.http.x-varnish-action = "FETCH (override - backend not setting cache control)";
    set beresp.uncacheable = true;
    set beresp.ttl = 120s;
    return (deliver);
  }

  if (beresp.http.X-Anonymous && !beresp.http.Cache-Control) {
    set beresp.http.x-varnish-action = "FETCH (override - anonymous backend not setting cache control)";
    set beresp.ttl = 600s;
    return (deliver);
  }

  set beresp.http.x-varnish-action = "FETCH (insert)";
  return (deliver);
}

sub vcl_deliver {

  if (req.http.x-vcl-debug) {
    set resp.http.x-varnish-ttl = obj.ttl;
    set resp.http.x-varnish-grace = obj.grace;
    set resp.http.x-hits = obj.hits;
    set resp.http.x-varnish-reqtype = req.http.x-varnish-reqtype;
    if (req.http.x-auth) {
      set resp.http.x-auth = "Logged-in";
    } else {
      set resp.http.x-auth = "Anon";
    }
    if (obj.hits > 0) {
      set resp.http.x-cache = "HIT";
    } else {
      set resp.http.x-cache = "MISS";
    }
  } else {
    unset resp.http.x-varnish-action;
    unset resp.http.x-cache-operation;
    unset resp.http.x-cache-rule;
    unset resp.http.x-powered-by;
  }
}

Note

http://plone.localhost/ is the URL you will be using to access the website. You can either use localhost, or add it in your /etc/hosts file or DNS to point to the Docker host IP.

Service configuration with Docker Compose#

Now let's create a docker-compose.yml file:

version: "3"
services:
  webserver:
    image: traefik

    ports:
      - 80:80

    labels:
      - traefik.enable=true
      - traefik.constraint-label=public

      # GENERIC MIDDLEWARES
      # - traefik.http.middlewares.https-redirect.redirectscheme.scheme=https
      # - traefik.http.middlewares.https-redirect.redirectscheme.permanent=true
      - traefik.http.middlewares.gzip.compress=true
      - traefik.http.middlewares.gzip.compress.excludedcontenttypes=image/png, image/jpeg, font/woff2

      # GENERIC ROUTERS
      # - traefik.http.routers.generic-https-redirect.entrypoints=http
      # - traefik.http.routers.generic-https-redirect.rule=HostRegexp(`{host:.*}`)
      # - traefik.http.routers.generic-https-redirect.priority=1
      # - traefik.http.routers.generic-https-redirect.middlewares=https-redirect

    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

    command:
      - --providers.docker
      - --providers.docker.constraints=Label(`traefik.constraint-label`, `public`)
      - --providers.docker.exposedbydefault=false
      - --entrypoints.http.address=:80
      # - --entrypoints.https.address=:443
      - --accesslog
      - --accesslog.format=json
      - --accesslog.fields.headers.names.X-Varnish-Routed=keep
      - --accesslog.fields.headers.names.RequestHost=keep
      - --log
      - --log.level=DEBUG
      - --api

  frontend:
    image: plone/plone-frontend:latest
    environment:
      RAZZLE_INTERNAL_API_PATH: http://backend:8080/Plone
      RAZZLE_API_PATH: http://plone.localhost
      DEBUG: superagent
    labels:
      - traefik.enable=true
      - traefik.constraint-label=public
      # Service
      - traefik.http.services.svc-frontend.loadbalancer.server.port=3000
      # Router: Varnish Public
      - traefik.http.routers.rt-frontend-public.rule=Host(`plone.localhost`)
      - traefik.http.routers.rt-frontend-public.entrypoints=http
      - traefik.http.routers.rt-frontend-public.service=svc-varnish
      - traefik.http.routers.rt-frontend-public.middlewares=gzip
      # Router: Internal
      - traefik.http.routers.rt-frontend-internal.rule=Host(`plone.localhost`) && Headers(`X-Varnish-Routed`, `1`)
      - traefik.http.routers.rt-frontend-internal.entrypoints=http
      - traefik.http.routers.rt-frontend-internal.service=svc-frontend
    depends_on:
      - backend
    ports:
      - "3000:3000"

  backend:
    image: plone/plone-backend:6.0
    environment:
      SITE: Plone
      PROFILES: "plone.app.caching:with-caching-proxy"
    labels:
      - traefik.enable=true
      - traefik.constraint-label=public
      # Service
      - traefik.http.services.svc-backend.loadbalancer.server.port=8080
      # Middleware
      ## Virtual Host Monster for /++api++/
      - "traefik.http.middlewares.mw-backend-vhm-api.replacepathregex.regex=^/\\+\\+api\\+\\+($$|/.*)"
      - "traefik.http.middlewares.mw-backend-vhm-api.replacepathregex.replacement=/VirtualHostBase/http/plone.localhost/Plone/++api++/VirtualHostRoot$$1"
      ## Virtual Host Monster for /ClassicUI/
      - "traefik.http.middlewares.mw-backend-vhm-ui.replacepathregex.regex=^/ClassicUI($$|/.*)"
      - "traefik.http.middlewares.mw-backend-vhm-ui.replacepathregex.replacement=/VirtualHostBase/http/plone.localhost/Plone/VirtualHostRoot/_vh_ClassicUI$$1"
      # Router: Varnish Public
      ## /++api++/
      - traefik.http.routers.rt-backend-api-public.rule=Host(`plone.localhost`) && PathPrefix(`/++api++`)
      - traefik.http.routers.rt-backend-api-public.entrypoints=http
      - traefik.http.routers.rt-backend-api-public.service=svc-varnish
      - traefik.http.routers.rt-backend-api-public.middlewares=gzip
      # Router: Internal
      ## /++api++/
      - traefik.http.routers.rt-backend-api-internal.rule=Host(`plone.localhost`) && PathPrefix(`/++api++`) && Headers(`X-Varnish-Routed`, `1`)
      - traefik.http.routers.rt-backend-api-internal.entrypoints=http
      - traefik.http.routers.rt-backend-api-internal.service=svc-backend
      - traefik.http.routers.rt-backend-api-internal.middlewares=gzip,mw-backend-vhm-api
      ## /ClassicUI/
      - traefik.http.routers.rt-backend-ui-internal.rule=Host(`plone.localhost`) && PathPrefix(`/ClassicUI`) && Headers(`X-Varnish-Routed`, `1`)
      - traefik.http.routers.rt-backend-ui-internal.entrypoints=http
      - traefik.http.routers.rt-backend-ui-internal.service=svc-backend
      - traefik.http.routers.rt-backend-ui-internal.middlewares=gzip,mw-backend-vhm-ui
    ports:
      - "8080:8080"

  purger:
    image: ghcr.io/kitconcept/cluster-purger:latest
    platform: linux/amd64
    environment:
      PURGER_SERVICE_NAME: varnish
      PURGER_SERVICE_PORT: 80
      PURGER_MODE: "compose"
      PURGER_PUBLIC_SITES: "['plone.localhost']"

  varnish:
    image: varnish
    volumes:
      - ./etc/varnish.vcl:/etc/varnish/default.vcl
    labels:
      - traefik.enable=true
      - traefik.constraint-label=public
      # SERVICE
      - traefik.http.services.svc-varnish.loadbalancer.server.port=80
    networks:
      default:
        aliases:
          - plone.localhost
    ports:
      - "8000-8001:80"
    depends_on:
      - backend

Build the project#

Start the stack with docker compose.

docker compose up -d

This pulls the needed images and starts Plone.

Access Plone via Browser#

After startup, go to http://plone.localhost/ and you should see the site.

Shutdown and cleanup#

The command docker compose down removes the containers and default network, but preserves the Plone database.

The command docker compose down --volumes removes the containers, default network, and the Plone database.