ModPageSpeed 2.0 with Docker Compose
ModPageSpeed 2.0 ships as two cooperating containers. One is a worker that transcodes images and minifies assets; the other is an nginx module that serves the optimized variants. Both processes share a single Cyclone cache volume via memory-mapped I/O. This post is the shortest path from a fresh host to a working install.
Prerequisites
- Docker 24+ with Compose v2 (
docker compose, notdocker-compose). - An origin (your existing app) reachable from the nginx container. By
default the compose file assumes
host.docker.internal:8081. - Optional: a license token. Without one, the worker still optimizes
(unlicensed installs keep optimizing and add an
X-PageSpeed-Warn: unlicensedheader). Production use requires a commercial license — but the software never locks you out. See the pricing page for terms.
docker-compose.yml
This is the production compose file from the ModPageSpeed 2.0 repo, trimmed for readability:
services:
worker:
image: ghcr.io/we-amp/pagespeed-worker:${PAGESPEED_VERSION:-2.0.21}
volumes:
- pagespeed-data:/data
environment:
- CACHE_SIZE=${CACHE_SIZE:-1073741824} # 1 GiB
- PAGESPEED_LICENSE_KEY=${PAGESPEED_LICENSE_KEY:-}
restart: unless-stopped
healthcheck:
test:
['CMD-SHELL', "echo '' | socat - UNIX-CONNECT:/data/pagespeed.sock.health | grep -q '^OK'"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
nginx:
image: ghcr.io/we-amp/pagespeed-nginx:${PAGESPEED_VERSION:-2.0.21}
ports:
- '${NGINX_PORT:-80}:80'
- '${NGINX_SSL_PORT:-443}:443'
volumes:
- pagespeed-data:/data
# Custom config: - ./nginx.conf:/etc/nginx/nginx.conf:ro
# TLS: - ./ssl/cert.pem:/etc/nginx/ssl/cert.pem:ro
# - ./ssl/key.pem:/etc/nginx/ssl/key.pem:ro
environment:
- BACKEND_HOST=${BACKEND_HOST:-host.docker.internal}
- BACKEND_PORT=${BACKEND_PORT:-8081}
- PAGESPEED_LICENSE_KEY=${PAGESPEED_LICENSE_KEY:-}
extra_hosts:
- 'host.docker.internal:host-gateway'
depends_on:
worker:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ['CMD', 'curl', '-sf', 'http://localhost/health']
interval: 10s
timeout: 5s
retries: 3
start_period: 3s
volumes:
pagespeed-data:
driver: local
Note:
- The shared named volume
pagespeed-datais mounted at/datain both containers. That is where the Cyclone cache file lives. Both processes open it viammap, so writes from the worker show up in the nginx module’s address space immediately. No IPC, no socket round-trips for cache reads. extra_hosts: host.docker.internal:host-gatewayis the magic that lets nginx in Docker reach an origin on the host on Linux. macOS and Windows Docker resolve this hostname by default; the explicit mapping is for Linux parity.
.env
Drop a .env next to the compose file:
# Origin server (your app)
BACKEND_HOST=host.docker.internal
BACKEND_PORT=8081
# Cache size in bytes (1 GiB by default)
CACHE_SIZE=1073741824
# Image tag (pin in production)
PAGESPEED_VERSION=latest
# License token (optional; leave empty to run unlicensed — still optimizes,
# adds an X-PageSpeed-Warn: unlicensed header. Required for production use.)
# PAGESPEED_LICENSE_KEY=eyJ...
Start it
$ docker compose up -d
[+] Running 3/3
✔ Network modpagespeed_default Created
✔ Container modpagespeed-worker-1 Healthy
✔ Container modpagespeed-nginx-1 Started
$ docker compose ps
NAME STATUS PORTS
modpagespeed-nginx-1 Up 12s (healthy) 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
modpagespeed-worker-1 Up 22s (healthy)
The depends_on: condition: service_healthy clause means nginx will not
start until the worker reports OK on its Unix-domain health socket. If
nginx never starts, check docker compose logs worker first.
Verify
The fastest check is the response header. ModPageSpeed 2.0 emits
X-PageSpeed: on optimized responses (the 1.x lineage uses
X-Mod-Pagespeed:, same project family, different header):
$ curl -sI http://localhost/ | grep -i pagespeed
x-pagespeed: HIT
For more diagnostic depth, hit the nginx health endpoint and read the worker socket:
$ curl -s http://localhost/health
OK
$ docker compose exec worker socat - UNIX-CONNECT:/data/pagespeed.sock.health
OK 0/128
0/128 is queue_depth/queue_capacity. A worker that is constantly at
128/128 is bottlenecked on transcoding, usually a sign the cache volume
is too small and variants are being evicted faster than they’re being
generated.
Applying a license token
An unlicensed install runs the optimization pipeline with the same code
path as a licensed one; there is no feature gate — it just adds an
X-PageSpeed-Warn: unlicensed header. When you have a token, drop it
into .env:
PAGESPEED_LICENSE_KEY=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ5b3UtY29ycCIs...
Then recreate the containers so the entrypoint picks up the new env:
$ docker compose up -d --force-recreate
The token is read by both the worker (validates and enforces it) and the nginx module (forwards license state in admin-console responses). Apply it in both env blocks; the compose file above does this.
To confirm the token landed:
$ docker compose logs worker 2>&1 | grep -i license
worker | license: validated, subject=you-corp, exp=2027-04-22
If you see license: unlicensed instead, the env var didn’t reach the
container, usually a .env typo or docker compose up without
--force-recreate.
Custom nginx.conf
The default nginx.conf baked into the image proxies everything to
$BACKEND_HOST:$BACKEND_PORT. To override it (multiple vhosts, TLS, more
specific pagespeed_disallow rules), mount your own:
services:
nginx:
volumes:
- pagespeed-data:/data
- ./nginx.conf:/etc/nginx/nginx.conf:ro
A minimal nginx.conf with PageSpeed enabled:
load_module /usr/lib/nginx/modules/ngx_pagespeed_module.so;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
upstream backend {
server host.docker.internal:8081;
keepalive 32;
}
server {
listen 80;
pagespeed on;
pagespeed_cache_path /data/cache.vol;
pagespeed_enable_mmap_directory on;
# Don't optimize these
pagespeed_disallow /api/;
pagespeed_disallow /admin/;
pagespeed_disallow "*.woff2";
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header Accept-Encoding ""; # PageSpeed needs raw bytes
proxy_http_version 1.1;
}
location /health {
access_log off;
return 200 "OK\n";
}
}
}
Two non-obvious config items worth flagging:
proxy_set_header Accept-Encoding "";(the origin must return uncompressed bytes). PageSpeed caches raw HTML/CSS/JS and applies brotli/gzip at serve time. If your origin gzips eagerly, PageSpeed sees compressed bytes and the HTML pipeline silently skips.pagespeed_enable_mmap_directory on;(required for shared-cache mode). Without it, the nginx module falls back to socket round-trips for every read, which is functional but slow.
Production checklist
Before pointing real traffic at this:
- Pin the image tag (
ghcr.io/we-amp/pagespeed-worker:2.0.16, not:latest). - Set
CACHE_SIZEto ~10% of the variants you expect (rule of thumb: 1 GiB per 5000 unique image URLs). - Add a logging driver.
json-filewith rotation is the compose-file default but consider shipping to your aggregator. - Set up TLS termination. The compose file leaves it off; mount your
certs and uncomment the
ssl_*directives innginx.conf. - Wire
/healthinto your uptime monitor. - Consider the full installation walkthrough for production deploy detail.
What next
- Install & run: it optimizes out of the box. Production use requires a
commercial license — but the software never locks you out (unlicensed
installs keep optimizing and add an
X-PageSpeed-Warn: unlicensedheader). Buy a license: see pricing and license terms. - Getting started — full installation overview including non-Docker paths.
- Installation: Docker — production-grade Docker setup with volume sizing and tuning.
- ASP.NET Core middleware if you’d rather embed the optimizer than run nginx as a reverse proxy.
- Critical CSS without a headless browser for how the worker decides which CSS to inline.
- mod_pagespeed alternatives — short form on what changed between 1.x and 2.0.
- vs imgproxy — comparison if you’re evaluating image pipelines.
Read next
-
Air-Gapped Headless Rendering: SSRF Protection With Pinned, Out-of-Process Fetches
SSRF protection for headless browser rendering: ModPageSpeed 2.0 forces Chrome offline, routing subresources through an IP-pinned fetch re-checked per redirect.
-
Stopping Cache Fragmentation: Stripping Tracking Params and Normalizing URLs
Strip tracking parameters to stop cache fragmentation: ModPageSpeed normalizes the URL before keying, dropping UTM params, sorting the query, aliasing hosts.
-
Default Cache TTL: Heuristic Freshness When the Origin Sends No Cache-Control
Default cache TTL when no Cache-Control: per-content-type heuristic TTLs, RFC 9111 Age adjustment at insert, and the shared-vs-private cache split in MPS 2.0.