Skip to main content
ModPageSpeed 2.0 and mod_pagespeed 1.1 — Now available

ModPageSpeed 2.0 with Docker Compose

By Otto van der Schaaf

installation docker

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, not docker-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-data is mounted at /data in both containers. That is where the Cyclone cache file lives. Both processes open it via mmap, 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-gateway is 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_SIZE to ~10% of the variants you expect (rule of thumb: 1 GiB per 5000 unique image URLs).
  • Add a logging driver. json-file with 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 in nginx.conf.
  • Wire /health into your uptime monitor.
  • Consider the full installation walkthrough for production deploy detail.

What next

Read next