Skip to main content

Dispatcher Configuration

Introduction

The AEM Dispatcher is an Apache HTTP Server (httpd) module that sits between the CDN/load balancer and the AEM Publish instances. It serves two critical functions:

  1. Caching — stores rendered pages and static assets on the web server's filesystem so that subsequent requests are served directly from disk, bypassing the publish instance entirely.
  2. Security filtering — acts as a request-level firewall that blocks access to internal paths, admin consoles, and unintended URL patterns before they ever reach AEM.

In an AEM as a Cloud Service (AEMaaCS) topology, every request to a Publish tier flows through the Dispatcher:

Client → CDN → Dispatcher (Apache httpd + mod_dispatcher) → AEM Publish

A well-tuned Dispatcher configuration can reduce publish-tier load by 80–95 %, dramatically improve response times, and harden the attack surface of your AEM installation.

tip

In AEMaaCS the Dispatcher configuration ships as part of your Git repository and is validated on every Cloud Manager pipeline run. Always test locally with the Dispatcher SDK before pushing.


Project Directory Structure

The AEMaaCS SDK ships a standard Dispatcher directory layout under dispatcher/src/. Understanding this tree is essential before making any changes.

dispatcher/src/
├── conf.d/ # Apache httpd configuration
│ ├── available_vhosts/
│ │ └── default.vhost # Default virtual host definition
│ ├── enabled_vhosts/
│ │ └── default.vhost -> ../available_vhosts/default.vhost
│ ├── dispatcher_vhost.conf # Main vhost include file
│ ├── rewrites/
│ │ ├── rewrite.rules # Custom Apache mod_rewrite rules
│ │ └── xforwarded_forcessl_rewrite.rules
│ └── variables/
│ ├── custom.vars # Project-specific variables
│ └── global.vars # Global environment variables
├── conf.dispatcher.d/ # Dispatcher module configuration
│ ├── available_farms/
│ │ └── default.farm # Default farm definition
│ ├── enabled_farms/
│ │ └── default.farm -> ../available_farms/default.farm
│ ├── cache/
│ │ ├── rules.any # Cache rules (what to cache)
│ │ └── invalidate.any # Auto-invalidation rules
│ ├── clientheaders/
│ │ └── clientheaders.any # Forwarded client headers
│ ├── filters/
│ │ └── filters.any # Request filter rules
│ ├── renders/
│ │ └── renders.any # Backend publish host definitions
│ └── virtualhosts/
│ └── virtualhosts.any # Dispatcher-level virtual host matching
└── opt-in/
└── USE_SOURCES_DIRECTLY # Marker file for SDK opt-in
DirectoryPurposeKey files
conf.d/Standard Apache httpd directives — virtual hosts, rewrite rules, environment variablesrewrite.rules, default.vhost, custom.vars
conf.dispatcher.d/Dispatcher module directives — farms, filters, cache, headersfilters.any, rules.any, default.farm
opt-in/Feature flags consumed by the Dispatcher SDK toolingUSE_SOURCES_DIRECTLY
warning

Do not rename or restructure the top-level directories. The Cloud Manager pipeline validator expects exactly this layout and will reject the deployment if files are missing or misplaced.


AEMaaCS vs AEM 6.5

Both delivery targets use the same mod_dispatcher binary, but the surrounding configuration contract differs.

AspectAEMaaCS (Cloud Service)AEM 6.5 (on-prem / AMS)
Source of truthGit repo, dispatcher/src//etc/httpd/conf.d on the web tier
ValidationCloud Manager pipeline + local SDK validatorManual apachectl configtest + mod_dispatcher reload
CDNAdobe-managed Fastly, configured via cdn.yamlCustomer-provided (Akamai / CloudFront / Varnish)
Flush agentsPre-wired by AdobeAuthor-side replication agents, manually configured
Environment variablesSet in Cloud Manager UI, read via ${ENV_VAR}Injected via httpd config or OS env
SDK versionVersioned module shipped with the AEM SDK zipdispatcher-apache*-linux-x86_64-*.tar.gz installed manually
Hostname in rendersReplaced at deploy — keep localhost:4503 in sourceReal publish hostnames
Opt-in flagsopt-in/USE_SOURCES_DIRECTLYNot applicable
tip

If you maintain a codebase that targets both AEMaaCS and 6.5, keep two farm files and use the USE_SOURCES_DIRECTLY marker only in the AEMaaCS profile. The validator will otherwise flag the 6.5-only constructs.


Farm Configuration

A farm is the Dispatcher's primary configuration unit. It binds incoming requests to a set of backend renders, filters, cache rules, and headers. The entry point is the farm file (e.g., default.farm), which includes the other .any files.

# conf.dispatcher.d/available_farms/default.farm

/default {
/clientheaders {
$include "../clientheaders/clientheaders.any"
}

/virtualhosts {
$include "../virtualhosts/virtualhosts.any"
}

/renders {
$include "../renders/renders.any"
}

/filter {
$include "../filters/filters.any"
}

/cache {
/docroot "/mnt/var/www/html"
/statfileslevel "2"
/allowAuthorized "0"
/rules {
$include "../cache/rules.any"
}
/invalidate {
$include "../cache/invalidate.any"
}
}

/vanity_urls {
/url "/libs/granite/dispatcher/content/vanityUrls.html"
/file "/tmp/vanity_urls"
/delay 300
}
}

Section Reference

SectionPurpose
/clientheadersDefines which HTTP headers from the client are forwarded to the publish instance
/virtualhostsRestricts which Host header values this farm responds to
/rendersBackend publish hosts (hostname + port)
/filterRequest allow/deny rules evaluated top-to-bottom
/cacheCaching behaviour: document root, cache rules, invalidation, TTL
/vanity_urlsEnables AEM vanity URL resolution at the Dispatcher level

Renders

The /renders section tells the Dispatcher where to forward cache-miss requests:

# conf.dispatcher.d/renders/renders.any

/rend01 {
/hostname "localhost"
/port "4503"
/timeout "10000"
}

In AEMaaCS, the hostname and port are replaced at deployment time by environment-specific values — you should keep localhost:4503 for local SDK testing.


Virtual Host Matching

Two layers of virtual host matching exist side-by-side: one at the Apache httpd layer (conf.d/available_vhosts/*.vhost) and one at the Dispatcher module layer (conf.dispatcher.d/virtualhosts/virtualhosts.any). Both must agree for a request to be routed into a farm.

Apache-level vhost

# conf.d/available_vhosts/mysite.vhost

<VirtualHost *:80>
ServerName www.mysite.com
ServerAlias mysite.com
ServerAlias *.preview.mysite.com

DocumentRoot /mnt/var/www/html

<Directory /mnt/var/www/html>
Require all granted
</Directory>

Include conf.d/rewrites/rewrite.rules
</VirtualHost>
  • ServerName is the canonical hostname; ServerAlias adds extra hostnames that resolve to the same vhost.
  • ServerName must match the incoming Host header exactly — wildcards only work in ServerAlias.
  • Enable the vhost by symlinking it into enabled_vhosts/:
ln -s ../available_vhosts/mysite.vhost conf.d/enabled_vhosts/mysite.vhost

Dispatcher-level vhost

The Dispatcher farm evaluates its own /virtualhosts glob list. A request that passed the Apache vhost still needs to match here or the farm will refuse it.

# conf.dispatcher.d/virtualhosts/virtualhosts.any

"www.mysite.com"
"mysite.com"
"*.preview.mysite.com"
"localhost"

Multiple farms

When multiple farms are active, each has its own virtualhosts.any. The Dispatcher evaluates farms in file-name order and picks the first farm whose glob list matches the incoming Host header. Name farms 00_api.farm, 10_publish.farm, etc., so the order is deterministic.

warning

Forgetting "localhost" in virtualhosts.any is a classic cause of local SDK requests returning 404 — the Apache side accepts the request, but the Dispatcher farm silently declines it.


Filter Rules

Filter rules are evaluated top-to-bottom. The first matching rule wins. Always start with a global deny and then selectively allow known paths.

# conf.dispatcher.d/filters/filters.any

# Deny everything by default
/0001 { /type "deny" /url "*" }

# Allow content pages
/0002 { /type "allow" /url "/content/*" }

# Allow client libraries
/0003 { /type "allow" /url "/etc.clientlibs/*" }

# Allow static media
/0004 { /type "allow" /url "/content/dam/*" }

# Allow GraphQL persisted queries
/0005 { /type "allow" /url "/graphql/execute.json/*" }

# Allow Experience Fragments
/0006 { /type "allow" /url "/content/experience-fragments/*" }

# Deny access to admin and system paths
/0100 { /type "deny" /url "/admin/*" }
/0101 { /type "deny" /url "/system/*" }
/0102 { /type "deny" /url "/apps/*" }
/0103 { /type "deny" /url "/bin/*" }
/0104 { /type "deny" /url "/crx/*" }
/0105 { /type "deny" /url "/libs/*" }
/0106 { /type "deny" /url "/etc/*" }

# Deny query strings that reveal selectors or suffixes
/0200 { /type "deny" /method "GET" /query "debug=*" }
/0201 { /type "deny" /method "GET" /query "wcmmode=*" }

Common Paths to Allow or Deny

PathActionReason
/content/*AllowPublished site content
/etc.clientlibs/*AllowVersioned client-side JS/CSS bundles
/content/dam/*AllowDigital assets (images, PDFs, videos)
/graphql/execute.json/*AllowContent Fragment GraphQL endpoint
/libs/*DenyInternal server-side libraries
/apps/*DenyApplication code — never exposed publicly
/system/*DenyAEM system servlets
/crx/*DenyCRX/DE repository browser
/bin/*DenyInternal servlets and workflow triggers
warning

Overly permissive filter rules (e.g., /0002 { /type "allow" /url "/*" }) are the number-one cause of Dispatcher security incidents. Always deny by default and only allow explicit, known paths.


Cache Rules

Cache rules determine which response types are stored on disk and served for subsequent requests.

What to Cache

# conf.dispatcher.d/cache/rules.any

# Cache HTML pages
/0000 { /glob "*" /type "deny" }
/0001 { /glob "*.html" /type "allow" }
/0002 { /glob "*.css" /type "allow" }
/0003 { /glob "*.js" /type "allow" }
/0004 { /glob "*.json" /type "allow" }
/0005 { /glob "*.png" /type "allow" }
/0006 { /glob "*.jpg" /type "allow" }
/0007 { /glob "*.jpeg" /type "allow" }
/0008 { /glob "*.gif" /type "allow" }
/0009 { /glob "*.svg" /type "allow" }
/0010 { /glob "*.ico" /type "allow" }
/0011 { /glob "*.woff2" /type "allow" }
/0012 { /glob "*.webp" /type "allow" }

Auto-Invalidation Rules

Auto-invalidation tells the Dispatcher which cached files to mark stale when a flush event is received from AEM:

# conf.dispatcher.d/cache/invalidate.any

/0000 { /glob "*" /type "deny" }
/0001 { /glob "*.html" /type "allow" }
/0002 { /glob "*.json" /type "allow" }

When AEM publishes a page, the replication agent sends a flush request. The Dispatcher then touches the .stat file in the corresponding directory, which marks all sibling cached files as stale (they will be re-fetched on the next request).

Grace Period

You can configure a grace period so that stale content is served while the Dispatcher re-fetches in the background:

/cache {
/gracePeriod "2"
/enableTTL "1"
}
tip

A grace period of 2 seconds is usually sufficient to prevent a thundering-herd effect when a popular page is invalidated and dozens of requests arrive simultaneously.


Rewrite Rules

Apache mod_rewrite lets you shorten internal AEM paths to clean, SEO-friendly URLs. These rules live in conf.d/rewrites/rewrite.rules.

Basic Content Path Shortening

Strip /content/mysite from URLs so that /content/mysite/en/products.html becomes /en/products.html:

# conf.d/rewrites/rewrite.rules

RewriteEngine On

# Shorten /content/mysite to root
RewriteRule ^/content/mysite/(.*)$ /$1 [PT,L]

# Redirect old URLs to shortened form (301)
RewriteCond %{REQUEST_URI} ^/content/mysite/(.*)$
RewriteRule ^/content/mysite/(.*)$ /$1 [R=301,L]

Append .html Extension

If your site omits .html in navigation links, add it transparently:

# If the request has no extension and is not a known asset path, append .html
RewriteCond %{REQUEST_URI} !\..*$
RewriteCond %{REQUEST_URI} !^/(etc|content/dam|libs|graphql)
RewriteRule ^/(.*)$ /content/mysite/$1.html [PT,L]

Trailing Slash Normalisation

# Remove trailing slash (except root)
RewriteCond %{REQUEST_URI} ^(.+)/$
RewriteRule ^(.+)/$ $1 [R=301,L]
tip

Always test rewrite rules locally with curl -v against your Docker-based Dispatcher before pushing to Cloud Manager. Redirect loops are surprisingly easy to create.


Cache Invalidation

Understanding the .stat file mechanism is critical for keeping content fresh without sacrificing performance.

How .stat Files Work

When the Dispatcher receives a flush request (typically from an AEM replication flush agent), it:

  1. Locates the cache directory for the flushed path.
  2. Creates or updates a hidden .stat file in that directory.
  3. On the next request, the Dispatcher compares the cached file's mtime against the .stat file's mtime. If the .stat file is newer, the cached file is considered stale and is re-fetched from publish.

statfileslevel

The statfileslevel setting controls how deep in the directory tree .stat files are created:

/cache {
/statfileslevel "2"
}
LevelBehaviourTrade-off
"0"Single .stat at the docroot — every flush invalidates the entire cacheSimple but aggressive
"1".stat per first-level directory (e.g., /content/mysite/)Balanced for single-site setups
"2".stat per second-level directory (e.g., /content/mysite/en/)Good for multi-language sites
"3"+Deeper granularity — only nearby cached files are invalidatedPrecise but more .stat file overhead
tip

For a typical multi-language site, statfileslevel "2" is a good starting point. It ensures that flushing an English page does not invalidate cached German pages.

Flush Agent Configuration

In AEMaaCS, flush agents are pre-configured. For on-premise or AMS environments, set up a flush agent on the Author instance:

Agent: Dispatcher Flush
Transport URI: http://dispatcher-host:80/dispatcher/invalidate.cache
HTTP Method: GET
HTTP Headers:
CQ-Action: {action}
CQ-Handle: {path}
CQ-Path: {path}
Triggers: On Receive (replication events)

Cache Warming

After a large invalidation — a site-wide publish, a CDN purge, or a fresh deployment — the Dispatcher cache is cold. The first request for each page pays the full publish round-trip. Cache warming pre-populates the cache before real traffic arrives.

Sitemap-driven pre-warm

Your sitemap.xml is the canonical list of URLs to warm. Pair it with xargs and curl:

#!/bin/sh
# warm-cache.sh -- fetch every URL in the sitemap once

SITEMAP="https://www.mysite.com/sitemap.xml"
DISPATCHER_HOST="https://dispatcher.internal"

curl -s "$SITEMAP" \
| grep -oP '(?<=<loc>)[^<]+' \
| sed "s|https://www.mysite.com|$DISPATCHER_HOST|" \
| xargs -P 8 -I {} curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" \
-H "Host: www.mysite.com" {}

The -P 8 flag runs eight parallel workers — enough to saturate the Dispatcher without overwhelming the publish tier.

Post-deploy warming

In a blue/green deploy, warm the new stack before flipping the load balancer:

# cloud-manager post-deploy step (example)
- name: warm-cache
run: ./ops/warm-cache.sh https://dispatcher-green.internal
timeout_minutes: 10

Targeted warming

Warming every URL is wasteful when only a section was invalidated. Track the invalidation source and warm only what changed:

# Warm only the "news" section after a flush
curl -s "https://www.mysite.com/news/sitemap.xml" \
| grep -oP '(?<=<loc>)[^<]+' \
| xargs -P 4 curl -s -o /dev/null
tip

Warming is not a substitute for a proper grace period. If you rely exclusively on warming, a missed cron run leaves users waiting for slow first-byte times. Combine both: short grace period + post-deploy warm.


TTL Caching

Beyond the .stat-based invalidation model, the Dispatcher supports time-to-live (TTL) caching driven by HTTP Cache-Control headers.

Enable TTL

/cache {
/enableTTL "1"
}

When TTL is enabled, the Dispatcher respects the max-age or s-maxage directive in the response's Cache-Control header. A cached file whose age exceeds the TTL is re-fetched regardless of the .stat file.

Setting Cache-Control Headers in AEM

Use a Sling Rewriter or an OSGi configuration to inject headers:

// Example: Sling Filter that sets Cache-Control
@Component(service = Filter.class,
property = {
"sling.filter.scope=REQUEST",
"service.ranking:Integer=700"
})
public class CacheControlFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
if (response instanceof HttpServletResponse httpResp) {
httpResp.setHeader("Cache-Control", "max-age=300, s-maxage=3600");
}
}
}
HeaderWho obeys itTypical value
max-ageBrowser + CDN + Dispatcher (if enableTTL "1")300 (5 min for HTML)
s-maxageCDN + Dispatcher only (overrides max-age)3600 (1 hour)
stale-while-revalidateCDN (serves stale while re-fetching)60

CDN Integration

In AEMaaCS, a CDN (Fastly) sits in front of the Dispatcher. The Dispatcher's Cache-Control headers flow through to the CDN, so a single header controls both layers:

Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=60
warning

Never set long TTLs on personalized or authenticated content. Doing so caches user-specific data and serves it to other visitors. Use Cache-Control: private, no-store for such responses.


Local Testing with Dispatcher SDK

Adobe provides a Docker-based Dispatcher SDK so you can validate your configuration before deploying.

Prerequisites

  • Docker Desktop
  • AEM as a Cloud Service SDK (download from the Software Distribution portal)
  • A running local AEM Publish instance on port 4503

Running the Dispatcher Locally

# Extract the SDK
unzip aem-sdk-dispatcher-tools-*.zip -d dispatcher-sdk

# Validate the configuration
cd dispatcher-sdk
./bin/validator full dispatcher/src

# Run Dispatcher in Docker (forwards to local Publish on port 4503)
./bin/docker_run.sh dispatcher/src host.docker.internal:4503 8080

After the container starts, your site is available at http://localhost:8080. All Dispatcher caching, filtering, and rewrite rules are active.

Validation Commands

CommandPurpose
./bin/validator full dispatcher/srcFull validation of all Dispatcher configs (same checks as Cloud Manager)
./bin/validator httpd dispatcher/srcValidate Apache httpd configs only
./bin/validator dispatcher dispatcher/srcValidate Dispatcher module configs only
./bin/docker_run.sh src host.docker.internal:4503 8080Start Dispatcher container on port 8080
docker logs -f <container_id>Tail Dispatcher/Apache logs in real time

Debugging Tips

Inspect the Dispatcher cache on disk inside the container:

# Enter the running container
docker exec -it <container_id> /bin/bash

# Check the cache directory
ls -la /mnt/var/www/html/content/mysite/en/

# View the .stat file timestamps
stat /mnt/var/www/html/content/mysite/.stat
tip

Run the validator as a Git pre-commit hook so that broken configurations never reach your remote branch:

#!/bin/sh
# .git/hooks/pre-commit
./dispatcher-sdk/bin/validator full dispatcher/src || exit 1

Debugging Toolkit

When requests don't behave as expected, the Dispatcher is usually the noisiest layer — but also the one with the most observable state.

Verbose logging with DISP_LOG_LEVEL

Set DISP_LOG_LEVEL=debug on the Dispatcher container (or vhost LogLevel) to make mod_dispatcher emit one log line per rule evaluation. Levels:

LevelWhat it logs
errorFailures only — default for prod
warnPlus warnings (cache write failures, missing renders)
infoPlus cache hits/misses and rule matches
debugPlus per-rule evaluation trace
trace1trace8Internal dispatcher state — use sparingly
# Inside the Dispatcher container
export DISP_LOG_LEVEL=debug
# Or in the vhost:
# LogLevel dispatcher:debug
tail -f /var/log/apache2/dispatcher.log

"Why didn't this cache?" decision tree

  1. Was the request a GET or HEAD? The Dispatcher only caches safe methods.
  2. Did the response carry a Set-Cookie header? Responses with cookies are never cached. Strip the cookie in a Sling filter or at the CDN.
  3. Did the response include Dispatcher: no-cache? That header is an opt-out — remove it or understand why it's set.
  4. Did the URL contain a query string? By default, any ? in the URL skips the cache. Use /ignoreUrlParams in the farm to allowlist known params.
  5. Was the status 200? Only 200 and (if enabled) 404 are cached.
  6. Did a filter rule deny the path? Check dispatcher.log for deny lines.
  7. Was the cache directory writable? ls -la /mnt/var/www/html inside the container — permission errors here are silent in older dispatcher versions.

Useful inspection commands

# Tail all dispatcher activity
docker exec -it <cid> tail -f /var/log/apache2/dispatcher.log

# Show the cache hit for one URL
curl -I -H "Host: www.mysite.com" http://localhost:8080/en/about.html
# Look for the X-Cache or X-Dispatcher-Info header

# Inspect what's cached on disk
docker exec -it <cid> find /mnt/var/www/html -name "*.html" | head

# Force-flush one path
docker exec -it <cid> rm -rf /mnt/var/www/html/content/mysite/en/about.html

# Watch .stat file changes in real time
docker exec -it <cid> sh -c "while true; do stat -c '%y %n' /mnt/var/www/html/.stat 2>/dev/null; sleep 1; done"

Security Headers

Security headers (Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy, etc.) can be set in three different layers. Choosing the right layer keeps headers consistent across error pages, redirects, and cache hits.

LayerSet headers here when…How
CDN (cdn.yaml)You run AEMaaCS and want headers on every response, including CDN-served errors and redirectsresponseTransformations rules — see the CDN section above
Apache vhostYou run on-prem/AMS, or need headers on responses the CDN bypassesHeader always set X-Content-Type-Options nosniff in the vhost
AEM (Sling filter)The header depends on the rendered content (per-page CSP nonces, per-component policies)OSGi Filter with sling.filter.scope=REQUEST
# conf.d/available_vhosts/default.vhost -- static, cache-independent
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
// Sling filter -- dynamic, per-request
response.setHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'nonce-" + nonce + "'");
warning

Don't set the same header in two layers. If both the CDN and Apache set X-Frame-Options, most CDNs emit a duplicate header which some browsers treat as a policy violation.


Health Check Endpoints

Load balancers and orchestrators (Kubernetes, AWS ALB, GCP NLB) need an endpoint that returns 200 OK only when the Dispatcher and its backing publish are both healthy.

Dispatcher-only check

A simple static file bypasses the publish tier entirely — use this for the load balancer's "is Apache up" check:

# conf.d/available_vhosts/default.vhost
Alias /healthcheck /var/www/healthcheck.html
<Location /healthcheck>
Require all granted
Header set Cache-Control "no-store"
</Location>
echo "ok" > /var/www/healthcheck.html

End-to-end check

An end-to-end check verifies that the Dispatcher can reach the publish tier and that the publish tier responds. The AEM out-of-the-box endpoint is /libs/granite/core/content/login.html — allow it through the filter for the health-check source IP only:

# filters.any
/0400 {
/type "allow"
/url "/libs/granite/core/content/login.html"
/clientheaders { "X-Health-Check" "dispatcher" }
}
# From the load balancer:
curl -H "X-Health-Check: dispatcher" http://dispatcher/libs/granite/core/content/login.html

Kubernetes probe example

livenessProbe:
httpGet:
path: /healthcheck
port: 80
initialDelaySeconds: 10
periodSeconds: 15

readinessProbe:
httpGet:
path: /healthcheck
port: 80
httpHeaders:
- name: Host
value: www.mysite.com
initialDelaySeconds: 20
periodSeconds: 10
tip

Keep the liveness probe Dispatcher-only and the readiness probe end-to-end. That way a brief publish outage marks the pod non-ready (traffic drains) without restarting the Dispatcher container.


CDN Configuration (cdn.yaml)

In AEMaaCS, an Adobe-managed CDN (Fastly) sits in front of the Dispatcher. You can configure its behaviour declaratively using a cdn.yaml file deployed via the Cloud Manager config pipeline. This gives you control over request/response transformations, server-side redirects, origin selection, and traffic filter rules - all at the CDN edge, before traffic even reaches the Dispatcher.

Setup

  1. Create a file named cdn.yaml under a top-level config/ folder in your Git repository
  2. All rules share a common envelope:
kind: "CDN"
version: "1"
data:
# rule sections go here
requestTransformations: ...
responseTransformations: ...
redirects: ...
originSelectors: ...
  1. Deploy via a Config Pipeline in Cloud Manager
  2. The cumulative file size (including traffic filter rules) must not exceed 100 KB

Evaluation Order

When a request arrives at the CDN, rules are evaluated in this order:


Request Transformations

Request transformations modify incoming requests before they reach the Dispatcher. You can set, unset, or transform headers, paths, query parameters, and cookies.

data:
requestTransformations:
removeMarketingParams: true
rules:
- name: set-custom-header
when:
reqProperty: path
like: /api/*
actions:
- type: set
reqHeader: x-api-version
value: "2"

- name: strip-html-extension
when:
reqProperty: path
like: "*.html"
actions:
- type: transform
reqProperty: path
op: replace
match: '\.html$'
replacement: ""

- name: lowercase-path
when:
reqProperty: path
matches: ".*[A-Z].*"
actions:
- type: transform
reqProperty: path
op: tolower

- name: unset-debug-params
when:
reqProperty: path
like: "*"
actions:
- type: unset
queryParamMatch: "^(debug|wcmmode|_=).*$"

removeMarketingParams: true automatically strips common tracking parameters (utm_*, fbclid, gclid, etc.) from the query string, improving cache hit ratios.

Available actions

ActionPropertiesDescription
setreqHeader / reqProperty / queryParam / reqCookie / var, valueSet a header, path, query param, cookie, or variable
unsetreqHeader / queryParam / reqCookie / queryParamMatchRemove a header, param, or cookie. queryParamMatch accepts a regex
transformop: replace, match, replacementRegex replace on a property
transformop: tolowerLowercase a property value

Variables

You can set variables during request transformations and reference them later in response transformations or redirects:

data:
requestTransformations:
rules:
- name: extract-country
when:
reqProperty: path
matches: "^/([a-zA-Z]{2})(/.*|$)"
actions:
- type: set
var: country-code
value:
reqProperty: path
- type: transform
var: country-code
op: replace
match: "^/([a-zA-Z]{2})(/.*|$)"
replacement: '\1'

responseTransformations:
rules:
- name: set-country-header
when:
var: country-code
equals: de
actions:
- type: set
respHeader: x-country
value: germany

Response Transformations

Response transformations modify outgoing responses before they reach the client. Set or remove headers, cookies, and even override the HTTP status code.

data:
responseTransformations:
rules:
- name: security-headers
when: "*"
actions:
- type: set
respHeader: X-Content-Type-Options
value: nosniff
- type: set
respHeader: X-Frame-Options
value: SAMEORIGIN
- type: set
respHeader: Referrer-Policy
value: strict-origin-when-cross-origin

- name: remove-server-header
when: "*"
actions:
- type: unset
respHeader: Server

- name: set-cache-for-static
when:
reqProperty: path
like: "/etc.clientlibs/*"
actions:
- type: set
respHeader: Cache-Control
value: "public, max-age=31536000, immutable"

- name: set-cookie-with-attributes
when:
reqProperty: path
like: /login
actions:
- type: set
respCookie: session-hint
value: active
attributes:
path: /
secure: true
httpOnly: true

Available actions

ActionPropertiesDescription
setrespHeader / respCookie / respProperty, valueSet a response header, cookie, or property (status)
unsetrespHeader / respCookieRemove a response header or cookie

Server-Side Redirects

Declare 301/302 redirects that are handled entirely at the CDN edge - no request reaches the Dispatcher or AEM:

data:
redirects:
rules:
- name: old-site-redirect
when:
reqProperty: path
equals: "/old-page.html"
action:
type: redirect
status: 301
location: https://www.example.com/new-page

- name: trailing-slash-redirect
when:
reqProperty: path
matches: "^(.+)/$"
action:
type: redirect
status: 301
location:
reqProperty: path
transform:
- op: replace
match: "^(.+)/$"
replacement: '\1'

- name: www-redirect
when:
reqProperty: domain
equals: "example.com"
action:
type: redirect
status: 301
location:
reqProperty: url
transform:
- op: replace
match: "^/(.*)$"
replacement: 'https://www.example.com/\1'

- name: country-redirect
when:
reqProperty: path
equals: "/"
action:
type: redirect
status: 302
location:
reqProperty: clientCountry
transform:
- op: replace
match: "^(.*)$"
replacement: 'https://www.example.com/\1/home'
- op: tolower

The location can be a static string or a dynamic value derived from request properties with optional transforms (regex replace, tolower).

PropertyDefaultDescription
status301HTTP status code. Allowed: 301, 302, 303, 307, 308
location(required)Absolute or relative redirect target

Origin Selectors

Origin selectors let you proxy traffic to different backends based on request properties. This is how you route paths to non-AEM services (external APIs, Edge Delivery Services, etc.) through the same CDN domain.

data:
originSelectors:
rules:
- name: api-backend
when:
reqProperty: path
like: /api/v2/*
action:
type: selectOrigin
originName: api-server
# skipCache: true

- name: edge-delivery-content
when:
allOf:
- reqProperty: tier
equals: publish
- reqProperty: path
matches: "^(/scripts/.*|/styles/.*|/fonts/.*|/blocks/.*|/icons/.*|.*/media_.*)"
action:
type: selectOrigin
originName: eds-origin

origins:
- name: api-server
domain: api.example.com
forwardHost: true
timeout: 30

- name: eds-origin
domain: main--repo--owner.aem.live
forwardHost: true
forwardCookie: true

Proxying to Edge Delivery Services

A common pattern is serving some paths from traditional AEM Publish and others from Edge Delivery Services (EDS), all under the same domain:

origins:
- name: aem-live
domain: main--mysite--myorg.aem.live

rules:
- name: eds-static-assets
when:
allOf:
- reqProperty: tier
equals: publish
- reqProperty: domain
equals: www.example.com
- reqProperty: path
matches: "^(/scripts/.*|/styles/.*|/fonts/.*|/blocks/.*|/icons/.*)"
action:
type: selectOrigin
originName: aem-live

Proxying to AEM static tier

For static resources deployed via the front-end pipeline, use selectAemOrigin:

rules:
- name: static-assets
when:
reqProperty: domain
equals: static.example.com
action:
type: selectAemOrigin
originName: static

Origin properties

PropertyDefaultDescription
domain(required)Backend hostname (also used for SSL SNI)
ip(auto)Override DNS resolution with a fixed IP
forwardHostfalsePass the original Host header to the backend
forwardCookiefalsePass cookies to the backend
forwardAuthorizationfalsePass the Authorization header to the backend
timeout60Seconds to wait for the first response byte

Custom Log Properties

Add custom fields to your CDN logs for observability:

data:
requestTransformations:
rules:
- name: log-forwarded-host
when: "*"
actions:
- type: set
logProperty: forwarded_host
value:
reqHeader: x-forwarded-host

responseTransformations:
rules:
- name: log-cache-status
when: "*"
actions:
- type: set
logProperty: cache_control
value:
respHeader: cache-control

The custom fields appear in your CDN log entries alongside the standard fields (timestamp, url, status, cache, pop, etc.).


Complete Example

Here is a production-ready cdn.yaml that combines several patterns:

kind: "CDN"
version: "1"
data:
requestTransformations:
removeMarketingParams: true
rules:
- name: strip-html-extension
when:
reqProperty: path
matches: "^/content/.*\\.html$"
actions:
- type: transform
reqProperty: path
op: replace
match: '\.html$'
replacement: ""

- name: log-real-ip
when: "*"
actions:
- type: set
logProperty: real_ip
value:
reqHeader: x-forwarded-for

responseTransformations:
rules:
- name: security-headers
when: "*"
actions:
- type: set
respHeader: X-Content-Type-Options
value: nosniff
- type: set
respHeader: X-Frame-Options
value: SAMEORIGIN
- type: set
respHeader: Strict-Transport-Security
value: "max-age=63072000; includeSubDomains; preload"

- name: long-cache-clientlibs
when:
reqProperty: path
like: "/etc.clientlibs/*"
actions:
- type: set
respHeader: Cache-Control
value: "public, max-age=31536000, immutable"

redirects:
rules:
- name: enforce-www
when:
reqProperty: domain
equals: example.com
action:
type: redirect
status: 301
location:
reqProperty: url
transform:
- op: replace
match: "^/(.*)$"
replacement: 'https://www.example.com/\1'

- name: legacy-pages
when:
reqProperty: path
equals: "/old-about.html"
action:
type: redirect
status: 301
location: /about

CDN Configuration Best Practices

Keep the file under 100 KB. The CDN config file has a hard size limit. For large redirect lists, consider moving them into a Sling redirect map or the Dispatcher rewrite rules instead.

Use removeMarketingParams: true. This single setting strips utm_*, fbclid, gclid, and similar tracking parameters, significantly improving CDN cache hit ratios.

Add security headers at the CDN. Headers like Strict-Transport-Security, X-Content-Type-Options, and X-Frame-Options are best set at the edge so they apply to all responses, including error pages and redirects.

Test with the config pipeline. Deploy CDN config changes through a dedicated config pipeline in Cloud Manager. Changes take effect within minutes.

Use variables for complex logic. Extract values in request transformations and reference them in response transformations to avoid duplicating match logic.

Don't duplicate Dispatcher rules. If a redirect or rewrite can be handled at the CDN edge, do it there. Reserve Dispatcher rewrite rules for backend-specific transformations that need access to the AEM response.

tip

For the full specification, see the Adobe documentation on CDN traffic configuration.


Common Pitfalls

1. Overly Broad Allow Rules

# BAD — allows everything, defeating the purpose of the filter
/0001 { /type "deny" /url "*" }
/0002 { /type "allow" /url "/*" }

Instead, enumerate only the paths your site actually needs.

2. Caching Personalised Content

If a page contains user-specific components (shopping cart, logged-in greeting), it must not be cached, or you risk serving one user's data to another:

# Exclude personalised paths from caching
/nocache01 { /glob "/content/mysite/*/user-dashboard*" /type "deny" }

Also set Cache-Control: private, no-store in the response headers.

3. Missing /etc.clientlibs Rule

Forgetting to allow /etc.clientlibs/* causes all JavaScript and CSS to fail on the published site. This is one of the most common first-time Dispatcher issues:

# Always include this
/0003 { /type "allow" /url "/etc.clientlibs/*" }

4. Forgetting to Invalidate

After deploying content changes, verify that your flush agents are active and that .stat files are being updated. A stale cache is invisible — the site looks fine but shows old content.

5. Not Testing with the Dispatcher During Development

Many issues (broken links, missing assets, redirect loops) only surface when the Dispatcher is in the request path. Run the Docker-based Dispatcher locally as part of your daily development workflow, not just before deployment.

6. Wrong statfileslevel

Setting statfileslevel "0" in a multi-site installation means every flush nukes the entire cache across all sites. Review the level carefully based on your content tree depth.

warning

Always run ./bin/validator full before committing. Cloud Manager will reject deployments with invalid Dispatcher configs, and a broken pipeline blocks all teams from deploying.


See also