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 runs in trial mode. The trial is card-at-start via FastSpring. 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: modpagespeed/worker:${PAGESPEED_VERSION:-latest}
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: modpagespeed/nginx:${PAGESPEED_VERSION:-latest}
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 for trial mode)
# 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: 2.0.0-beta.20
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
Trial mode runs the optimization pipeline with the same code path as a
paid license; there is no feature gate. 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: trial mode 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 (
modpagespeed/worker:2.0.0-beta.20, 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
- Activate the 14-day trial: see pricing. Card-at-start via FastSpring.
- 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
-
ASP.NET Core CLS: pre-size async holes
How to fix CLS on ASP.NET Core: pre-size async view components, persist image dimensions, reserve banner space, rewrite img tags via middleware.
-
nginx CLS via insert_image_dimensions
How to fix CLS on nginx: rewrite img tags to add width/height, reserve container space, use aspect-ratio, ship dimensions at build time.
-
Magento CLS starts with Fotorama
How to fix CLS on Magento: tame Fotorama gallery jumps, pre-size mini-cart and private-content blocks, rewrite img dimensions at the server layer.