Containers & CI#
A pragmatic, copy-pasteable approach to packaging Webrick with Docker and wiring a CI pipeline that builds, tests, warms caches, and ships images safely.
Image layout#
Use a multi-stage build to keep images small and production-only.
# ---- 1) Builder: install deps & build route cache
FROM php:8.4-cli-alpine AS builder
# System deps for composer (and optional zstd/br if you compile them here)
RUN apk add --no-cache git unzip
# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --prefer-dist --classmap-authoritative --no-interaction
# Copy source
COPY . .
# Build route cache (script should exist in repo)
RUN mkdir -p .route-cache && php ./webrick route:cache --cache=.route-cache --routes=routes.php
# ---- 2) Runtime: php-fpm + nginx
FROM php:8.4-fpm-alpine AS runtime
# Packages: nginx + supervisor + libs
RUN apk add --no-cache nginx supervisor bash zlib
# (Optional) Enable OPcache
RUN docker-php-ext-install opcache
# App
WORKDIR /app
COPY --from=builder /app /app
# Permissions: allow FPM user to write var/
RUN mkdir -p /app/var && chown -R www-data:www-data /app/var
# Nginx & Supervisor config
COPY ./.docker/nginx.conf /etc/nginx/http.d/default.conf
COPY ./.docker/supervisord.conf /etc/supervisord.conf
EXPOSE 80
CMD ["/usr/bin/supervisord","-c","/etc/supervisord.conf"]
.docker/nginx.conf (minimal):
server {
listen 80;
server_name _;
root /app/public;
index index.php;
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
fastcgi_read_timeout 120s;
}
}
.docker/supervisord.conf:
[supervisord]
nodaemon=true
[program:php-fpm]
command=php-fpm
autorestart=true
priority=10
[program:nginx]
command=nginx -g "daemon off;"
autorestart=true
priority=20
Why this layout?
Composer + cache build happen in the builder stage (faster CI, smaller runtime).
Runtime is clean: PHP-FPM + Nginx + your prebuilt route cache.
Environment & secrets#
Pass secrets as env vars at runtime (never bake into image):
WEBRICK_SIGN_KEY,WEBRICK_COOKIE_KEY, DB creds, etc.
In orchestrators (Kubernetes, ECS), mount from secret stores.
Keep the filesystem read-only except for
/app/var.
Docker run example:
docker run -p 8080:80 \
-e WEBRICK_SIGN_KEY="prod-..." \
-e WEBRICK_COOKIE_KEY="prod-..." \
-e PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 \
-v app-var:/app/var \
your/image:tag
CI pipeline (generic)#
A simple 5-stage pipeline (GitHub Actions/YAML-ish pseudocode):
name: build-and-deploy
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install deps
run: composer install --no-interaction
- name: Lint & tests
run: |
composer run phpcs || true
composer run phpstan || true
composer run test
- name: Build route cache
run: php ./webrick route:cache --cache=.route-cache --routes=routes.php
docker_build_push:
needs: build_test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
run: docker build -t ghcr.io/OWNER/webrick:$(git rev-parse --short HEAD) .
- name: Push
run: |
TAG=ghcr.io/OWNER/webrick:$(git rev-parse --short HEAD)
docker push $TAG
docker tag $TAG ghcr.io/OWNER/webrick:latest
docker push ghcr.io/OWNER/webrick:latest
deploy:
needs: docker_build_push
runs-on: ubuntu-latest
steps:
- name: Trigger deploy (example)
run: curl -X POST -H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" https://deploy.example.com/hooks/webrick
Adapt to your registry/orchestrator. If you use BuildKit cache, your build will be much faster on subsequent runs.
Kubernetes snippet#
Minimal Deployment + Service:
apiVersion: apps/v1
kind: Deployment
metadata:
name: webrick
spec:
replicas: 3
selector:
matchLabels: { app: webrick }
template:
metadata:
labels: { app: webrick }
spec:
containers:
- name: web
image: ghcr.io/OWNER/webrick:latest
ports: [{ containerPort: 80 }]
env:
- { name: WEBRICK_SIGN_KEY, valueFrom: { secretKeyRef: { name: webrick-secrets, key: sign_key } } }
- { name: WEBRICK_COOKIE_KEY, valueFrom: { secretKeyRef: { name: webrick-secrets, key: cookie_key } } }
volumeMounts:
- { name: var, mountPath: /app/var }
readinessProbe:
httpGet: { path: /health, port: 80 }
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet: { path: /health, port: 80 }
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: var
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: webrick
spec:
selector: { app: webrick }
ports:
- port: 80
targetPort: 80
Ingress: attach TLS + host routing via your cluster’s Ingress controller (Nginx, Traefik, etc.).
Caching & warmup in containers#
Route cache is baked by the builder stage (no rebuild at runtime).
OPcache warmup: hit a small “warm” endpoint after rolling—use a post-deploy job or readiness script.
If using Response Cache in-memory, prefer a shared backing store in cluster (Redis) to get cross-pod hits.
Logs & metrics#
Write access logs from Nginx to stdout; FPM logs to stdout/stderr with
catch_workers_output = yes.Export metrics via a sidecar or integrate with your APM/OTEL:
route durations, 5xx rates, cache hit/miss, throttle 429s.
Example Docker env for Nginx logs to stdout (Alpine default already does).
Security checklist#
Non-root user where feasible (Alpine PHP-FPM runs as
www-data).Read-only root FS (mount
/app/varwritable).Disable Xdebug, dev tools in runtime image.
Tight CORS/policy headers in app.
Keep image SBOM and enable vulnerability scanning in registry.
Troubleshooting#
Symptom |
Likely cause |
Fix |
|---|---|---|
Container boots but 404s |
Wrong |
Nginx |
502 between Nginx and FPM |
Socket/host mismatch |
Ensure |
Double compression |
Edge + app gzip |
Disable one side; prefer app or edge—not both |
Env vars not applied |
Missing secrets/values |
Check K8s |
Slow first requests |
Cold OPcache |
Warm after deploy; ensure |
Makefile (optional QoL)#
IMAGE ?= ghcr.io/OWNER/webrick
TAG ?= $(shell git rev-parse --short HEAD)
build:
\tdocker build -t $(IMAGE):$(TAG) .
push:
\tdocker push $(IMAGE):$(TAG)
run:
\tdocker run --rm -p 8080:80 -e WEBRICK_SIGN_KEY=dev -e WEBRICK_COOKIE_KEY=dev $(IMAGE):$(TAG)