From 377e13c972190701f3d257c0a1f48c533aee0721 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Sat, 6 Dec 2025 22:25:03 -0800 Subject: [PATCH] initial commit --- .github/workflows/build.yml | 153 +++++++++++++++++++++++++++++++++++ README.md | 124 ++++++++++++++++++++++++++++ docker-compose.yml | 42 ++++++++++ docker/7.4/Dockerfile | 135 +++++++++++++++++++++++++++++++ docker/nginx/Dockerfile | 11 +++ docker/nginx/nginx.conf | 69 ++++++++++++++++ docker/php-fpm/entrypoint.sh | 19 +++++ docker/php-fpm/www.conf | 43 ++++++++++ version.json | 0 9 files changed, 596 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docker/7.4/Dockerfile create mode 100644 docker/nginx/Dockerfile create mode 100644 docker/nginx/nginx.conf create mode 100644 docker/php-fpm/entrypoint.sh create mode 100644 docker/php-fpm/www.conf create mode 100644 version.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7fa1489 --- /dev/null +++ b/.github/workflows/build.yml @@ -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:-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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aff2a12 --- /dev/null +++ b/README.md @@ -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//` (e.g. `docker/8.1/`). +2. Copy `docker/7.4/Dockerfile` into the new directory and update `ARG BASE_TAG` + to the desired `php:-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-:`. + - PHP lanes are pushed to `gitea.auvem.com/auvem/wordpress-php-fpm:-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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..46f5122 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/7.4/Dockerfile b/docker/7.4/Dockerfile new file mode 100644 index 0000000..dae1d90 --- /dev/null +++ b/docker/7.4/Dockerfile @@ -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 diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..037b098 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -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;"] diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..06e4610 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -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 diff --git a/docker/php-fpm/entrypoint.sh b/docker/php-fpm/entrypoint.sh new file mode 100644 index 0000000..7c2215f --- /dev/null +++ b/docker/php-fpm/entrypoint.sh @@ -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 "$@" diff --git a/docker/php-fpm/www.conf b/docker/php-fpm/www.conf new file mode 100644 index 0000000..a7bb646 --- /dev/null +++ b/docker/php-fpm/www.conf @@ -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 diff --git a/version.json b/version.json new file mode 100644 index 0000000..e69de29