initial commit

This commit is contained in:
Elijah Duffy
2025-12-06 22:25:03 -08:00
commit 377e13c972
9 changed files with 596 additions and 0 deletions

153
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: build
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
workflow_call:
inputs:
REGISTRY_USER:
required: true
type: string
secrets:
REGISTRY_TOKEN:
required: true
jobs:
plan:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.compute.outputs.matrix }}
should_build: ${{ steps.compute.outputs.should_build }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: compute
shell: bash
run: |
set -euo pipefail
# Discover lanes: immediate subdirs under docker/
mapfile -t ALL_LANES < <(find docker -mindepth 1 -maxdepth 1 -type d | sort)
if [[ ${#ALL_LANES[@]} -eq 0 ]]; then
echo "No lanes found under docker/. Nothing to do."
echo "should_build=false" >> $GITHUB_OUTPUT
echo 'matrix={"dir":[]}' >> $GITHUB_OUTPUT
exit 0
fi
# Determine changed files (PR vs push)
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_SHA="$(git rev-parse HEAD~1 || echo '')"
HEAD_SHA="$(git rev-parse HEAD)"
fi
if [[ -n "$BASE_SHA" ]]; then
CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
else
# First commit or shallow: treat everything as changed
CHANGED=$(git ls-files)
fi
echo "Changed files:"
echo "$CHANGED"
# If workflow changed, rebuild all lanes
if grep -qx ".github/workflows/build.yml" <<< "$CHANGED"; then
echo "Workflow changed; rebuilding all lanes."
TARGET_DIRS=("${ALL_LANES[@]}")
else
# Build only lanes with changes under their directories
TARGET_DIRS=()
for lane in "${ALL_LANES[@]}"; do
# Any change directly under lane dir counts; include Dockerfile or subpaths
if grep -q "^${lane}/" <<< "$CHANGED"; then
TARGET_DIRS+=("$lane")
fi
done
fi
# De-duplicate
mapfile -t TARGET_DIRS < <(printf "%s\n" "${TARGET_DIRS[@]}" | awk 'NF && !x[$0]++')
if [[ ${#TARGET_DIRS[@]} -eq 0 ]]; then
echo "No lane directories changed. Skipping build."
echo "should_build=false" >> $GITHUB_OUTPUT
echo 'matrix={"dir":[]}' >> $GITHUB_OUTPUT
exit 0
fi
# Produce JSON matrix
JSON=$(jq -nc --argjson arr "$(printf '%s\n' "${TARGET_DIRS[@]}" | jq -R . | jq -s .)" '{dir: $arr}')
echo "Matrix: $JSON"
echo "should_build=true" >> $GITHUB_OUTPUT
echo "matrix=$JSON" >> $GITHUB_OUTPUT
build:
needs: plan
if: needs.plan.outputs.should_build == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: gitea.auvem.com
username: ${{ inputs.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
DIR="${{ matrix.dir }}"
NAME="${DIR#docker/}" # e.g. '7.4' or 'nginx'
SHA=${GITHUB_SHA::7}
# Decide repository and tag scheme:
# - nginx lane -> gitea.auvem.com/auvem/wordpress-nginx:stable
# - other lanes (assumed php variants) -> gitea.auvem.com/auvem/wordpress-php-fpm:<version>-stable
if [[ "${NAME}" == "nginx" ]]; then
IMAGE="gitea.auvem.com/auvem/wordpress-nginx"
TAG="stable"
else
IMAGE="gitea.auvem.com/auvem/wordpress-php-fpm"
# Extract version like 7.4 or 8.1 from the lane name; otherwise use lane name
if [[ "${NAME}" =~ ([0-9]+\.[0-9]+) ]]; then
VERSION="${BASH_REMATCH[1]}"
TAG="${VERSION}-stable"
elif [[ "${NAME}" =~ ([0-9]+) ]]; then
VERSION="${BASH_REMATCH[1]}"
TAG="${VERSION}-stable"
else
TAG="${NAME}-stable"
fi
fi
echo "image=$IMAGE" >> $GITHUB_OUTPUT
echo "tags=$IMAGE:${TAG},$IMAGE:git-${SHA}" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dir }}/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64
cache-from: type=registry,ref=${{ steps.meta.outputs.image }}:cache
cache-to: type=registry,ref=${{ steps.meta.outputs.image }}:cache,mode=max

124
README.md Normal file
View File

@@ -0,0 +1,124 @@
# auvem/php-fpm-wordpress — multi-version PHP-FPM + nginx Docker images
This repository contains Dockerfiles and configuration for building PHP-FPM
images optimized for WordPress and a companion nginx image. It aims to make
adding and maintaining multiple PHP versions straightforward while keeping
builds reproducible and small.
## Repository Layout
- `docker/` — top-level directory containing per-image lanes
- `7.4/` — PHP 7.4 FPM lane (multi-stage, Alpine-based)
- `Dockerfile` — builds PHP + required extensions
- `nginx/` — nginx lane
- `Dockerfile` — nginx:alpine-slim image that ships `nginx.conf`
- `nginx.conf` — default server config that works with the php-fpm image
- `php-fpm/` — canonical shared files
- `www.conf` — canonical php-fpm pool config
- `entrypoint.sh` — optional guarded entrypoint to fix mounts at container start
### Note about shared files and builds
The CI workflow is configured to build with the repository root as the Docker
build context and to point Docker to lane Dockerfiles (for example,
`file: docker/7.4/Dockerfile`). That means Dockerfiles can safely `COPY`
shared files from `docker/php-fpm/` without requiring per-lane duplicates. This
reduces maintenance overhead — keep the canonical copy in
`docker/php-fpm/www.conf` and the CI will make it available to all lanes.
## Adding a new PHP version
1. Create `docker/<version>/` (e.g. `docker/8.1/`).
2. Copy `docker/7.4/Dockerfile` into the new directory and update `ARG BASE_TAG`
to the desired `php:<version>-fpm-alpine` tag.
3. Adjust `docker-php-ext-install`/build deps if needed.
4. Push — CI will detect the new lane and build it.
## Bind mounts and permissions
- Images use `/var/www/html` as the webroot. When you mount a host directory
over that path the mount replaces the image contents, including ownership.
- Recommended safe options:
- Pre-chown host files to UID/GID 1000 before starting containers:
```bash
sudo chown -R 1000:1000 ./wp_root
```
- Or enable the entrypoint-based fixup in php-fpm by setting
`CHOWN_ON_START=1` for the `php-fpm` service (the entrypoint is guarded — it
only runs when this env is explicitly enabled).
## Local Testing & Development
Use the provided `docker-compose.yml` in the repo root for local development —
it builds images from the repo (so shared files are available) and mounts
`./wp_root` for site content.
## Production Example
Below is an example `docker-compose.yml` for production deployments that pulls
images from your registry instead of building locally. Adjust image names and
secrets as appropriate.
```yaml
services:
db:
image: mariadb:10.11
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- db_data:/var/lib/mysql
php-fpm:
image: gitea.auvem.com/auvem/wordpress-php-fpm:7.4-stable
restart: unless-stopped
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD}
WORDPRESS_DB_NAME: wordpress
volumes:
- ./wp_root:/var/www/html:rw
nginx:
image: gitea.auvem.com/auvem/wordpress-nginx:stable
ports:
- "80:80"
depends_on:
- php-fpm
volumes:
- ./wp_root:/var/www/html:ro
volumes:
db_data: {}
```
## CI / build notes
- The GitHub Actions workflow at `.github/workflows/build.yml` discovers
immediate subdirectories of `docker/` and builds each as a lane. The workflow
has been updated to use the repository root as the Docker build context and
to set the `file` property to the lane Dockerfile (so shared files in
`docker/php-fpm/` are accessible during build).
- The workflow tags and pushes images using the pattern `gitea.auvem.com/auvem/wordpress-<component>:<tag>`.
- PHP lanes are pushed to `gitea.auvem.com/auvem/wordpress-php-fpm:<version>-stable` (for example `7.4-stable`).
- The nginx lane is pushed to `gitea.auvem.com/auvem/wordpress-nginx:stable`.
If you prefer a different naming convention, update the `meta` step in the workflow.
### Security & hardening
- Multi-stage builds keep final images minimal and reduce attack surface.
- PHP uses `php.ini-production` with opcache tuned. The php-fpm pool is
configured to drop workers to `app` (UID 1000) while the master runs as
`root` to avoid socket/permission surprises; workers remain unprivileged.
- The nginx config contains conservative security headers and blocking of
hidden files; review and extend headers (CSP, COEP, COOP) as needed per-site.
### Production deployment and archival
- For archival (quiesce + tar), stop services and `tar` the `./wp_root` and any
associated volumes (database dump + attachments). Ensure services are fully
stopped to avoid inconsistent state.

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
services:
db:
image: mariadb:10.11
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wp
MYSQL_PASSWORD: example
MYSQL_ROOT_PASSWORD: example_root
volumes:
- db_data:/var/lib/mysql
php-fpm:
build:
context: .
dockerfile: docker/7.4/Dockerfile
image: local/auvem-php:7.4
restart: unless-stopped
volumes:
- ./wp_root:/var/www/html:rw
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wp
WORDPRESS_DB_PASSWORD: example
WORDPRESS_DB_NAME: wordpress
# Enable chown-on-start if you want the container to fix host mounts
# CHOWN_ON_START=1
nginx:
build:
context: .
dockerfile: docker/nginx/Dockerfile
image: local/auvem-nginx:latest
ports:
- "8080:80"
depends_on:
- php-fpm
volumes:
- ./wp_root:/var/www/html:ro
volumes:
db_data:

135
docker/7.4/Dockerfile Normal file
View File

@@ -0,0 +1,135 @@
# Multi-stage Alpine-based PHP 7.4 FPM image optimized for WordPress
# - builds extensions in a builder stage (isolation)
# - copies only runtime bits into the final image
# - minimal runtime packages, production php.ini and opcache tuned
# - includes common WordPress extensions: pdo_mysql, mysqli, gd, zip, exif,
# intl, xml, xmlrpc (if compiled), bcmath, opcache, mbstring, curl, fileinfo
ARG BASE_TAG=7.4-fpm-alpine3.16
FROM php:${BASE_TAG} AS build
RUN set -eux; \
apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
autoconf \
gcc \
g++ \
make \
pkgconfig \
bash \
freetype-dev \
libjpeg-turbo-dev \
libpng-dev \
libxml2-dev \
zlib-dev \
icu-dev \
libzip-dev \
oniguruma-dev \
mariadb-dev \
; \
# Configure and build common extensions required by WordPress
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" \
gd \
mysqli \
pdo \
pdo_mysql \
zip \
exif \
intl \
bcmath \
opcache \
xml \
mbstring \
xmlrpc \
soap \
pcntl \
; \
pecl channel-update pecl.php.net; \
pecl install redis && docker-php-ext-enable redis; \
# Use production php.ini as a base
cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"; \
# Clean build deps (we'll copy only runtime artifacts to final image)
apk del .build-deps; \
rm -rf /var/cache/apk/* /tmp/*
FROM php:${BASE_TAG} AS runtime
# Install only runtime library packages (no -dev)
RUN set -eux; \
apk add --no-cache \
freetype \
libjpeg-turbo \
libpng \
libxml2 \
zlib \
icu-libs \
libzip \
mariadb-client \
openssl \
ca-certificates \
tzdata \
; \
update-ca-certificates || true
# Copy built PHP and extensions from the build stage
COPY --from=build /usr/local/lib/php /usr/local/lib/php
COPY --from=build /usr/local/etc/php /usr/local/etc/php
# Create a non-root application user and prepare webroot directory
RUN addgroup -g 1000 app || true; \
adduser -D -u 1000 -G app app || true; \
mkdir -p /var/www/html; \
chown -R app:app /var/www/html; \
# Ensure php-fpm runtime directories exist and are writable by the app user
mkdir -p /var/run/php /run/php /var/log/php; \
chown -R app:app /var/run/php /run/php /var/log/php
# Minimal security / production tuning for opcache and PHP
RUN set -eux; \
{ \
echo 'opcache.enable=1'; \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.interned_strings_buffer=8'; \
echo 'opcache.max_accelerated_files=10000'; \
echo 'opcache.revalidate_freq=2'; \
echo 'opcache.fast_shutdown=1'; \
echo 'opcache.enable_file_override=0'; \
} > /usr/local/etc/php/conf.d/zz-opcache.ini; \
{ \
echo 'expose_php = Off'; \
echo 'display_errors = Off'; \
echo 'log_errors = On'; \
echo 'error_log = /proc/self/fd/2'; \
} > /usr/local/etc/php/conf.d/zz-hardening.ini
# Expose FPM socket port (if using TCP); keep default unix socket when preferred
EXPOSE 9000
# Healthcheck: ensure php-fpm master process exists
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD pgrep -f "php-fpm" || exit 1
WORKDIR /var/www/html
# Copy pool configuration from build context (keep a copy in each lane to
# allow building with context set to the lane directory).
COPY --chown=root:root docker/php-fpm/www.conf /usr/local/etc/php-fpm.d/www.conf
# Copy a shared entrypoint that can optionally fix permissions on the mounted webroot
COPY --chown=root:root docker/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 755 /usr/local/bin/entrypoint.sh
# Use a small entrypoint to optionally chown mounted volumes at container start.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Run php-fpm master as root and have workers drop to the non-root `app` user.
# This avoids permission surprises while keeping workers unprivileged.
USER root
CMD ["php-fpm"]
# Notes:
# - The build stage compiles extensions and leaves a production php.ini in place.
# - The final image copies only the runtime artifacts and installs only runtime libs
# to keep it small.
# - If you want to run PHP-FPM as a non-root user in container, you will need to
# adjust the php-fpm pool user/group configuration in /usr/local/etc/php-fpm.d/www.conf

11
docker/nginx/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM nginx:alpine-slim
# Add a minimal, secure nginx config that works with the php-fpm service
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create directories for logs and ensure permissions (nginx user is nginx)
RUN mkdir -p /var/www/html /var/log/nginx && chown -R nginx:nginx /var/www/html /var/log/nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

69
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,69 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /var/www/html;
index index.php index.html index.htm;
# Basic security headers (can be extended per-site)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Deny access to hidden files and directories
location ~ (^|/)[.] {
deny all;
access_log off;
log_not_found off;
}
# Static files: long cache, immutable where appropriate
location ~* \.(?:css|js|gif|jpe?g|png|ico|svg|woff2?|ttf|eot)$ {
try_files $uri =404;
access_log off;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; allow all; }
# Main front controller; fall back to index.php
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
# PHP-FPM handling; pass to php-fpm:9000 (docker service name)
location ~ [^/]
\.php(/|$) {
# Prevent direct access to PHP files in uploads or other writable dirs if necessary
try_files $document_root$fastcgi_script_name =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# Use TCP FPM backend service name. Matches the php-fpm image we built.
fastcgi_pass php-fpm:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_read_timeout 300;
}
# Block access to .ht* files
location ~* /\.(?:ht|git) {
deny all;
}
# Optional: small buffer for large headers (WordPress with many cookies/plugins)
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
# Prevent clickjacking on all responses
add_header X-Frame-Options "SAMEORIGIN";
}
# Default server; allow override by mounting /etc/nginx/conf.d/default.conf

View File

@@ -0,0 +1,19 @@
#!/bin/sh
set -euo pipefail
# Entrypoint for php-fpm images.
# If CHOWN_ON_START is set to '1' or 'true', recursively chown the webroot
# to the 'app' user (UID 1000). This is optional and must be explicitly enabled
# via environment (safer for multi-tenant hosts).
: ${CHOWN_ON_START:=}
if [ "${CHOWN_ON_START}" = "1" ] || [ "${CHOWN_ON_START}" = "true" ]; then
echo "[entrypoint] CHOWN_ON_START enabled — fixing ownership of /var/www/html"
# Only run chown if the directory exists
if [ -d /var/www/html ]; then
chown -R 1000:1000 /var/www/html || true
fi
fi
# Exec the given command (php-fpm by default)
exec "$@"

43
docker/php-fpm/www.conf Normal file
View File

@@ -0,0 +1,43 @@
; Shared php-fpm pool configuration for containers
; Designed to be reused across php-fpm versions in this repo
[www]
; Listen on TCP to be container-friendly
listen = 0.0.0.0:9000
; Run workers as the unprivileged 'app' user
user = app
group = app
; Ensure socket ownership/mode if a socket is used
listen.owner = app
listen.group = app
listen.mode = 0660
; Process management
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
; Timeouts and logging
request_terminate_timeout = 300s
request_slowlog_timeout = 5s
slowlog = /var/log/php/www-slow.log
; Helpful logging for debugging worker crashes
catch_workers_output = yes
access.log = /var/log/php/www-access.log
; Keep environment variables (useful if you pass DB credentials via env)
clear_env = no
; Security and restart behaviour
emergency_restart_threshold = 10
emergency_restart_interval = 1m
process_control_timeout = 10s
; Ensure stdout/stderr are visible in container logs
; php-fpm will already write logs to paths above; ensure the directory exists in image

0
version.json Normal file
View File