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 grouppagespeed; 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-failurewithStartLimitBurst=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
| Path | Owner | Mode | Reason |
|---|---|---|---|
/var/cache/pagespeed-optimizer/v1/ | pagespeed:pagespeed | 3770 | Cache 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:pagespeed | 0660 | Both processes read and write cache entries |
pagespeed-shared.conf | pagespeed:pagespeed | 0640 | nginx reads socket path / sizing / toggles |
.pagespeed-serve-stats | pagespeed:pagespeed | 0660 | Shared serve-stats mmap |
/run/pagespeed-optimizer/notify.sock | pagespeed:pagespeed | 0660 | nginx sends notifications to the worker |
notify.sock.health | pagespeed:pagespeed | 0660 | Health check access |
notify.sock.mgmt | pagespeed:pagespeed | 0660 | Management socket access (PURGE) |
/etc/pagespeed-optimizer/daemon.env | root:pagespeed | 0640 | API 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 countsnotifications— Total received and skipped (dedup) countsvariants— Total written and proactively generated countserrors— Processing error countcache— Current entry count and size in bytesby_type— Per content type counts and cumulative processing timeby_format— Per image format generation countstiming_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
errorsincreasing steadily indicates processing failures. Check worker logs for details.cache.sizeapproaching--cache-sizemeans LRU eviction is active. Consider increasing the cache size.notifications.skipped_dedupbeing a large fraction ofnotifications.receivedis normal and healthy — it means the worker is avoiding redundant work.connections.activenearconnections.maxmeans 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 Type | Variant Multiplier | Notes |
|---|---|---|
| HTML | 2x | Original + critical CSS variant |
| CSS / JS | 2x | Original + minified |
| Images (default) | 10-20x | Multiple formats, viewports, density, Save-Data |
| Images (minimal) | 3-4x | With all proactive flags disabled |
Recommended Sizes
| Deployment Size | Cache Size | Flag Value |
|---|---|---|
| Small (< 100 images) | 256 MB | 268435456 |
| Medium (100-1000 images) | 1 GB | 1073741824 |
| Large (1000-10000 images) | 4 GB | 4294967296 |
| Very large (10000+ images) | 8+ GB | 8589934592 |
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
MISSresponses for previously-seen URLs (evicted content) cache.entriesin STATS is stable whilenotifications.receivedkeeps 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.sizeis 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
- Configuration Reference — All worker flags and nginx directives
- Troubleshooting — Common issues and solutions
- API Reference — IPC protocol, health endpoint, and management socket details
- HTTP API Reference — REST and WebSocket endpoints
- Web Console — Visual cache inspector and real-time monitoring
- Helm Deployment — Kubernetes-specific deployment guide