Traefik, Frontend, Backend, ZEO, Varnish container example#

This example is a basic setup with one backend accessing a ZEO server 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:

services:
  webserver:
    image: traefik:${STACK_TRAEFIK_TAG:?Set STACK_TRAEFIK_TAG}

    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:${STACK_FRONTEND_TAG:?Set STACK_FRONTEND_TAG}
    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`) && Header(`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:${STACK_BACKEND_TAG:?Set STACK_BACKEND_TAG}
    environment:
      SITE: Plone
      PROFILES: "plone.app.caching:with-caching-proxy"
      ZEO_ADDRESS: db:8100
      ZEO_SHARED_BLOB_DIR: on  # otherwise the backend will create its own blob storage
    volumes:
      - vol-site-data:/data     # the backend and database need access to the same volume
    depends_on:
      - db
#   If the Docker container is run with a UID other than the UID which owns the local file system persistent storage,
#   explicitly make the container run with the correct UID. For example, `user: 1000:1000`.
#   In that case, also make sure that the `db` service gets the same `user: 1000:1000` configuration,
#   as they both need access to the file system.


    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++`) && Header(`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`) && Header(`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

  db:
    image: plone/plone-zeo:${STACK_ZEO_TAG:?Set STACK_ZEO_TAG}
    volumes:
      - vol-site-data:/data

volumes:
  vol-site-data: {}

Environment variables#

The docker-compose.yml file reads the tags of its images from the following environment variables. All of them are required, and docker compose stops with an error if one of them is missing.

Variable

Description

Default value

Example

STACK_FRONTEND_TAG

Tag (version) of the image for the frontend

19

STACK_BACKEND_TAG

Tag (version) of the image for the backend

6.2

STACK_ZEO_TAG

Tag (version) of the image for the ZEO server

6

STACK_TRAEFIK_TAG

Tag (version) of the image for Traefik

v3.7

Create a .env file in your project directory with your values. Docker Compose reads it automatically when you run docker compose from that directory.

STACK_FRONTEND_TAG=19
STACK_BACKEND_TAG=6.2
STACK_ZEO_TAG=6
STACK_TRAEFIK_TAG=v3.7

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.