Skip to main content
mod_pagespeed 1.15 — Cyclone Cache, modernized optimizations, IIS + .NET support, and six years of security updates
2.0 1.1

Deploy ModPageSpeed 2.0 to production: Docker, Helm, systemd

Run the ModPageSpeed 2.0 worker and nginx in production with Docker Compose, Kubernetes (Helm), or systemd — covering file permissions, logging, monitoring, and cache sizing.

On this page

ModPageSpeed 2.0 runs as two cooperating processes: a worker that optimizes content and an nginx front end that serves it from a shared cache. This guide covers the three ways to run that pair in production — Docker Compose, Kubernetes (Helm), and systemd — along with file permissions, logging, monitoring, and cache sizing.

Docker Compose

The recommended production setup uses two containers sharing a named volume: the worker and nginx with the PageSpeed module. Your origin server runs separately (on the same host, a different host, or behind a load balancer).

Environment File

Copy the example environment file and adjust for your setup:

cp deploy/.env.example deploy/.env

Edit deploy/.env:

# Image version tag
PAGESPEED_VERSION=latest

# License key — required for production use. The engine always optimizes;
# an unlicensed or expired install adds an 'X-PageSpeed-Warn: unlicensed' header.
# Licensed per site (registrable domain): every container serving the same
# site shares this one key.
PAGESPEED_LICENSE_KEY=your-license-token-here

# Your origin server
BACKEND_HOST=127.0.0.1
BACKEND_PORT=8081

# Cache size in bytes (512 MB)
CACHE_SIZE=536870912

# Exposed ports
NGINX_PORT=80
NGINX_SSL_PORT=443

Docker Compose File

The production deploy/docker-compose.yml runs the worker and nginx:

services:
  worker:
    image: ghcr.io/we-amp/pagespeed-worker:${PAGESPEED_VERSION:-2.0.21}
    volumes:
      - pagespeed-data:/data
    environment:
      - CACHE_SIZE=${CACHE_SIZE:-536870912}
      - PAGESPEED_LICENSE_KEY=${PAGESPEED_LICENSE_KEY:-}
    restart: unless-stopped
    healthcheck:
      test:
        [
          'CMD-SHELL',
          'python3 -c "import socket; s=socket.socket(socket.AF_UNIX); s.connect(''/data/pagespeed.sock.health''); d=s.recv(256); s.close(); exit(0 if d.startswith(b''OK'') else 1)"',
        ]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '3'

  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
    environment:
      - BACKEND_HOST=${BACKEND_HOST:-127.0.0.1}
      - BACKEND_PORT=${BACKEND_PORT:-8081}
      - PAGESPEED_LICENSE_KEY=${PAGESPEED_LICENSE_KEY:-}
    depends_on:
      worker:
        condition: service_healthy
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '5'

volumes:
  pagespeed-data:
    driver: local

Starting the Stack

# Build images (if not using pre-built)
docker/build-release.sh 2.0.42

# Start in detached mode
docker compose -f deploy/docker-compose.yml up -d

# Verify both containers are healthy
docker compose -f deploy/docker-compose.yml ps

SSL Configuration

To enable HTTPS, mount your certificate and key files into the nginx container and provide a custom nginx config:

nginx:
  volumes:
    - pagespeed-data:/data
    - ./nginx.conf:/etc/nginx/nginx.conf:ro
    - ./ssl/cert.pem:/etc/nginx/ssl/cert.pem:ro
    - ./ssl/key.pem:/etc/nginx/ssl/key.pem:ro

Updating

To upgrade to a new version:

# Pull new images
docker compose -f deploy/docker-compose.yml pull

# Restart with zero downtime
docker compose -f deploy/docker-compose.yml up -d

The cache volume is preserved across restarts. Previously optimized variants remain available immediately.

Kubernetes (Helm)

The official Helm chart deploys the worker and nginx as sidecar containers in a single pod, sharing an emptyDir volume for the Cyclone cache. It is published to a Helm repository hosted on modpagespeed.com. See the dedicated Helm deployment guide for the full configuration reference.

Install

helm repo add weamp https://modpagespeed.com/charts
helm repo update

helm install pagespeed weamp/pagespeed \
  --set backend.host=my-origin.default.svc.cluster.local \
  --set backend.port=8080

Common Overrides

helm install pagespeed weamp/pagespeed \
  --set backend.host=my-origin \
  --set backend.port=8080 \
  --set worker.cacheSize=1073741824 \
  --set replicaCount=3 \
  --set ingress.enabled=true \
  --set ingress.hosts[0].host=cdn.example.com \
  --set ingress.hosts[0].paths[0].path=/ \
  --set ingress.hosts[0].paths[0].pathType=Prefix

License Key

Pass the license key as a secret:

helm install pagespeed weamp/pagespeed \
  --set licenseKey=your-license-token-here

Or reference an existing secret:

helm install pagespeed weamp/pagespeed \
  --set existingSecret=my-license-secret \
  --set existingSecretKey=license-key

Licensing is per site (registrable domain). Replicas and pods serving the same site share one license; scaling out needs no additional licenses.

Architecture

Each pod contains two containers:

  • worker — Reads/writes the shared cache, processes notifications
  • nginx — Serves traffic, sends notifications to the worker

Both containers mount the same emptyDir volume at /data. The worker creates the cache file and Unix sockets; nginx reads the cache and connects to the sockets.

Autoscaling

Enable HPA-based autoscaling:

helm install pagespeed weamp/pagespeed \
  --set autoscaling.enabled=true \
  --set autoscaling.minReplicas=2 \
  --set autoscaling.maxReplicas=10

systemd Service

For bare-metal or VM deployments without Docker, run the worker as a systemd service.

Service File

Install the service file at /etc/systemd/system/pagespeed-worker.service:

[Unit]
Description=ModPageSpeed 2.0 Factory Worker
Documentation=https://modpagespeed.com/docs/configuration/
After=network.target

[Service]
Type=simple
# Secrets (PAGESPEED_API_TOKEN / PAGESPEED_LICENSE_KEY) ride the env file,
# never the command line -- /proc/<pid>/cmdline is world-readable.
EnvironmentFile=-/etc/pagespeed-optimizer/daemon.env
ExecStart=/usr/local/bin/factory_worker \
    --cache-dir /var/cache/pagespeed-optimizer/v1 \
    --socket /run/pagespeed-optimizer/notify.sock \
    --cache-size 536870912 \
    --log-format json \
    --log-level info

# Identity: the daemon runs UNPRIVILEGED. The pagespeed user/group come
# from the packaged sysusers.d drop-in; the nginx worker user joins group
# pagespeed (the module package's postinst does that join).
User=pagespeed
Group=pagespeed
CapabilityBoundingSet=
NoNewPrivileges=yes

# Backstop only -- every shared-file mode is set explicitly in code.
UMask=0007
RuntimeDirectory=pagespeed-optimizer
RuntimeDirectoryMode=0750

# Security hardening
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/cache/pagespeed-optimizer

# Resource limits
LimitNOFILE=65536
LimitNPROC=4096

# Restart behavior
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=60

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=pagespeed-worker

[Install]
WantedBy=multi-user.target

Enable and Start

sudo systemctl daemon-reload
sudo systemctl enable pagespeed-worker
sudo systemctl start pagespeed-worker
sudo systemctl status pagespeed-worker

Key systemd Settings

  • User=pagespeed / Group=pagespeed — The daemon runs unprivileged. The serving module’s web-server user joins group pagespeed; that shared group is the entire access boundary for the cache and sockets.
  • UMask=0007 — Backstop only. Every security-relevant mode (sockets 0660, volume 0660, shared config 0640, serve-stats 0660) is set with an explicit chmod in the daemon, so nothing depends on the umask.
  • ProtectSystem=strict / ReadWritePaths — The worker can only write to /var/cache/pagespeed-optimizer, so a worker vulnerability cannot write outside that directory.
  • LimitNOFILE=65536 — Ensures the worker can open enough file descriptors for large caches and many simultaneous connections.
  • Restart=on-failure with StartLimitBurst=5 — Restarts on crash but gives up after 5 failures in 60 seconds to prevent restart loops.

Do not expose the worker HTTP API

The worker’s HTTP API (cache inspection, purge, workbench WebSocket) is disabled by default, and when you enable it the worker enforces one rule while parsing its configuration: remote is never unauthenticated, and unauthenticated is never remote. Any combination that breaks it is a refusal to start, not a warning.

Prefer the unix socket: --api-socket serves the API on /run/pagespeed-optimizer/api.sock, mode 0660 pagespeed:pagespeed. Your web server reaches it through group membership — the same boundary that already governs the cache — so there is no credential to distribute, and no TCP port is bound at all.

If you do bind TCP, it defaults to 127.0.0.1; keep it that way. A non-loopback bind requires both --api-allow-remote and a token (PAGESPEED_API_TOKEN), and even then belongs behind a reverse proxy with its own authentication. Package installs generate the token into /etc/pagespeed-optimizer/daemon.env (0640 root:pagespeed), so enabling the API does not mean inventing a credential. See HTTP API Reference for the auth model.

File Permissions

Cross-process cache sharing between nginx and the worker is scoped to one shared group. The worker runs as the unprivileged pagespeed user and owns every file it creates; nginx worker processes (nobody, www-data, apache, …) reach those files through membership in group pagespeed. Nothing is world-accessible, and no mode is umask-derived — the daemon sets each one explicitly after creating the file.

Required Permissions

PathOwner
ModeReason
/var/cache/pagespeed-optimizer/v1/pagespeed:pagespeed3770Cache dir (tmpfiles.d) — setgid so the group is inherited by everything created inside, sticky so one group member cannot delete another’s files
volume files (cache-*)pagespeed:pagespeed0660Both processes read and write cache entries
pagespeed-shared.confpagespeed:pagespeed0640nginx reads socket path / sizing / toggles
.pagespeed-serve-statspagespeed:pagespeed0660Shared serve-stats mmap
/run/pagespeed-optimizer/notify.sockpagespeed:pagespeed0660nginx sends notifications to the worker
notify.sock.healthpagespeed:pagespeed0660Health check access
notify.sock.mgmtpagespeed:pagespeed0660Management socket access (PURGE)
/etc/pagespeed-optimizer/daemon.envroot:pagespeed0640API token / license key, off the command line

Ensuring Correct Permissions

The packaged pagespeed-optimizer deb/rpm does all of this: sysusers.d creates the identity, tmpfiles.d creates the versioned cache directory, the unit drops privileges, and the module package’s postinst adds the web-server user to group pagespeed. For a hand-rolled install, mirror the packaged unit (deploy/pagespeed-optimizer.service) and the tmpfiles.d drop-in rather than recreating the modes by hand.

Upgrading from an earlier release

The worker’s default cache and socket paths moved to /var/cache/pagespeed-optimizer/v1/cache and /run/pagespeed-optimizer/notify.sock. Your web-server configuration does not move with them: after upgrading, update pagespeed_cache_path (nginx) or ModPagespeedDaemonVolumePath / ModPagespeedDaemonSocketPath to the new paths and restart the web server.

Until you do, in-place optimization stays off and the log reports the socket as absent — as though the daemon were not running — because the module is still looking at the old location. The upgrade prints the same reminder.

The worker refuses to start — with a log line naming the cause — when its cache directory is missing, unwritable, or holds content owned by another uid. That is deliberate: no install or startup path ever chowns or migrates pre-existing content (a root-owned cache from an older release is abandoned in place; the daemon cold-starts into the fresh directory). Delete or move the old content yourself when you are ready to reclaim the disk.

Verifying Permissions

If you see persistent MISS responses or worker processing errors, check permissions:

ls -la /var/cache/pagespeed-optimizer/v1/
# Expected:
# drwxrws---  pagespeed pagespeed  .
# -rw-rw----  pagespeed pagespeed  cache-1-....dat
# -rw-r-----  pagespeed pagespeed  pagespeed-shared.conf
# -rw-rw----  pagespeed pagespeed  .pagespeed-serve-stats

ls -la /run/pagespeed-optimizer/
# Expected:
# srw-rw----  pagespeed pagespeed  notify.sock
# srw-rw----  pagespeed pagespeed  notify.sock.health
# srw-rw----  pagespeed pagespeed  notify.sock.mgmt

# and the web side must be in the group:
id www-data   # (or apache / nginx) — must list "pagespeed"

Log Rotation

systemd (journald)

When using --log-format json with systemd, logs go to journald by default. Journald manages its own rotation. View logs with:

# Follow worker logs
sudo journalctl -u pagespeed-worker -f

# Last 100 lines
sudo journalctl -u pagespeed-worker -n 100

# Since last hour
sudo journalctl -u pagespeed-worker --since "1 hour ago"

# JSON output for parsing
sudo journalctl -u pagespeed-worker -o cat | jq .

nginx Log Rotation

Install the logrotate configuration at /etc/logrotate.d/pagespeed:

/var/log/nginx/pagespeed*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0644 www-data adm
    sharedscripts
    postrotate
        [ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid) || true
    endscript
}

This rotates PageSpeed-related nginx logs daily, keeping 14 days of compressed history.

Docker Log Rotation

The Docker Compose configuration includes built-in log rotation:

logging:
  driver: json-file
  options:
    max-size: '10m' # Rotate at 10 MB
    max-file: '3' # Keep 3 rotated files

View Docker logs with:

docker compose -f deploy/docker-compose.yml logs -f worker
docker compose -f deploy/docker-compose.yml logs -f nginx

Monitoring

Health Check

The worker exposes a health check socket at {socket_path}.health. Connect to get a one-line status:

python3 -c "
import socket
s = socket.socket(socket.AF_UNIX)
s.connect('/run/pagespeed-optimizer/notify.sock.health')
print(s.recv(256).decode())
s.close()
"

Response: OK 5/128 notifs=1542 variants=986 proactive=724 errors=3 cache_entries=2048

Use this for load balancer health checks and basic uptime monitoring.

Management Socket STATS

For detailed metrics, connect to the management socket at {socket_path}.mgmt:

echo "STATS" | socat - UNIX-CONNECT:/run/pagespeed-optimizer/notify.sock.mgmt

The response is a JSON object containing:

  • connections — Active and max connection counts
  • notifications — Total received and skipped (dedup) counts
  • variants — Total written and proactively generated counts
  • errors — Processing error count
  • cache — Current entry count and size in bytes
  • by_type — Per content type counts and cumulative processing time
  • by_format — Per image format generation counts
  • timing_us — Total cumulative processing time in microseconds

Prometheus Metrics

The management socket also supports a METRICS command that returns stats in Prometheus text exposition format:

echo "METRICS" | socat -t 5 - UNIX-CONNECT:/run/pagespeed-optimizer/notify.sock.mgmt

This outputs # HELP, # TYPE, and metric lines for all counters and gauges. See the API Reference for the full metric list.

For Prometheus scraping, create a simple exporter that connects to the management socket and exposes the output on an HTTP endpoint, or use a cron-based approach to push metrics to Pushgateway.

Monitoring Script

A simple monitoring script that collects stats periodically:

#!/bin/bash
MGMT_SOCK="/run/pagespeed-optimizer/notify.sock.mgmt"

while true; do
  STATS=$(echo "STATS" | socat -t 5 - UNIX-CONNECT:$MGMT_SOCK 2>/dev/null)
  if [ $? -eq 0 ]; then
    echo "$(date -Iseconds) $STATS"
  else
    echo "$(date -Iseconds) ERROR: cannot connect to management socket"
  fi
  sleep 60
done

Key Metrics to Watch

  • errors increasing steadily indicates processing failures. Check worker logs for details.
  • cache.size approaching --cache-size means LRU eviction is active. Consider increasing the cache size.
  • notifications.skipped_dedup being a large fraction of notifications.received is normal and healthy — it means the worker is avoiding redundant work.
  • connections.active near connections.max means the worker is connection-limited. Increase --max-connections.

Cache Sizing Recommendations

The cache stores both original content and optimized variants. With proactive variant generation enabled, each image can produce many variants.

Estimating Cache Size

A rough formula:

cache_size = num_unique_urls * avg_original_size * variant_multiplier

Where variant_multiplier depends on your content mix:

Content TypeVariant MultiplierNotes
HTML2xOriginal + critical CSS variant
CSS / JS2xOriginal + minified
Images (default)10-20xMultiple formats, viewports, density, Save-Data
Images (minimal)3-4xWith all proactive flags disabled
Deployment SizeCache SizeFlag Value
Small (< 100 images)256 MB268435456
Medium (100-1000 images)1 GB1073741824
Large (1000-10000 images)4 GB4294967296
Very large (10000+ images)8+ GB8589934592

Keep the cache size within physical RAM where you can. Cyclone’s memory-mapped reads stay fast while the volume file is resident in memory; a cache sized far past RAM pages to disk instead. A head-to-head benchmark of Cyclone against the file-per-entry cache measures that effect and the concurrency and eviction gains.

Signs the Cache Is Too Small

  • Frequent MISS responses for previously-seen URLs (evicted content)
  • cache.entries in STATS is stable while notifications.received keeps growing (cache is full, new entries evict old ones)
  • Worker processing the same URL repeatedly (check logs for duplicate URLs)

Signs the Cache Is Oversized

  • cache.size is consistently well below --cache-size — you are allocating disk space that is never used
  • Not a problem per se, but the pre-allocated cache file consumes disk space

Next Steps