diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index eab96d2ea..000000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: "npm"
- directory: "/docs"
- schedule:
- interval: "daily"
- allow:
- - dependency-name: "@serversideup/project-switcher-bar"
- - package-ecosystem: "github-actions"
- directory: "/"
- schedule:
- interval: "monthly"
- groups:
- github-actions:
- patterns:
- - "*"
diff --git a/.github/workflows/action_lint-docs.yml b/.github/workflows/action_lint-docs.yml
new file mode 100644
index 000000000..3f4f05905
--- /dev/null
+++ b/.github/workflows/action_lint-docs.yml
@@ -0,0 +1,25 @@
+name: Lint Docs
+
+# The PR image workflow runs the full lint, but it skips pull requests that only change docs.
+# This runs the checks that compare the docs reference with src/ for those pull requests.
+on:
+ pull_request:
+ paths:
+ - docs/content/**
+
+permissions: {}
+
+jobs:
+ docs-match-src:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - name: Check out code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Check the docs match src/
+ run: bash scripts/lint.sh scripts
diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml
index 79fb16ff7..11de8353f 100644
--- a/.github/workflows/action_publish-images-beta.yml
+++ b/.github/workflows/action_publish-images-beta.yml
@@ -55,8 +55,18 @@ jobs:
with:
ref: ${{ needs.release.outputs.tag }}
- build:
+ base:
needs: [release, setup]
+ uses: ./.github/workflows/service_build-php-base.yml
+ permissions:
+ contents: read
+ id-token: write
+ with:
+ matrix: ${{ needs.setup.outputs.base-matrix }}
+ ref: ${{ needs.release.outputs.tag }}
+
+ build:
+ needs: [release, setup, base]
name: build ${{ matrix.variation }}
strategy:
fail-fast: false
@@ -73,10 +83,9 @@ jobs:
registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php"
tag-prefix: "beta"
release-type: "testing"
- secrets: inherit
test:
- needs: [release, setup, build]
+ needs: [release, setup, base, build]
if: always() && needs.setup.result == 'success'
uses: ./.github/workflows/service_test-images.yml
permissions:
@@ -96,7 +105,9 @@ jobs:
id-token: write
with:
ref: ${{ needs.release.outputs.tag }}
- secrets: inherit
+ secrets:
+ DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
+ DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
report:
needs: [release, setup, build, test, publish]
@@ -106,4 +117,3 @@ jobs:
contents: read
with:
matrix: ${{ needs.setup.outputs.matrix }}
- ref: ${{ needs.release.outputs.tag }}
diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml
index e411c5353..34ba5f7ca 100644
--- a/.github/workflows/action_publish-images-production.yml
+++ b/.github/workflows/action_publish-images-production.yml
@@ -3,11 +3,17 @@ run-name: >-
Production images (${{
github.event.release.tag_name ||
(github.event_name == 'schedule' && 'weekly rebuild') ||
+ inputs.reason ||
'manual rebuild'
}})
on:
workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Shown in the run title (example: PHP 8.5.13 release). Left empty, the run is a "manual rebuild".'
+ type: string
+ required: false
release:
types: [released]
schedule:
@@ -55,8 +61,18 @@ jobs:
with:
ref: ${{ needs.release.outputs.tag }}
- build:
+ base:
needs: [release, setup]
+ uses: ./.github/workflows/service_build-php-base.yml
+ permissions:
+ contents: read
+ id-token: write
+ with:
+ matrix: ${{ needs.setup.outputs.base-matrix }}
+ ref: ${{ needs.release.outputs.tag }}
+
+ build:
+ needs: [release, setup, base]
name: build ${{ matrix.variation }}
strategy:
fail-fast: false
@@ -73,10 +89,9 @@ jobs:
registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php"
tag-prefix: ''
release-type: "latest"
- secrets: inherit
test:
- needs: [release, setup, build]
+ needs: [release, setup, base, build]
if: always() && needs.setup.result == 'success'
uses: ./.github/workflows/service_test-images.yml
permissions:
@@ -96,7 +111,19 @@ jobs:
id-token: write
with:
ref: ${{ needs.release.outputs.tag }}
- secrets: inherit
+ secrets:
+ DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
+ DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+ # Scans what was just published: counts in the run summary, fixable findings as code scanning alerts.
+ scan:
+ needs: [release, publish]
+ if: needs.publish.result == 'success'
+ uses: ./.github/workflows/service_scan-images.yml
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
report:
needs: [release, setup, build, test, publish]
@@ -106,4 +133,3 @@ jobs:
contents: read
with:
matrix: ${{ needs.setup.outputs.matrix }}
- ref: ${{ needs.release.outputs.tag }}
diff --git a/.github/workflows/action_publish-images-prs.yml b/.github/workflows/action_publish-images-prs.yml
index 35b5f765e..aceca9f84 100644
--- a/.github/workflows/action_publish-images-prs.yml
+++ b/.github/workflows/action_publish-images-prs.yml
@@ -38,8 +38,26 @@ jobs:
with:
ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
- build:
+ # Pull requests from forks skip the base phase and build on the official images.
+ base:
needs: [setup]
+ uses: ./.github/workflows/service_build-php-base.yml
+ permissions:
+ contents: read
+ id-token: write
+ with:
+ matrix: ${{ needs.setup.outputs.base-matrix }}
+ ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
+ publish: >-
+ ${{
+ github.event_name == 'workflow_dispatch' ||
+ github.event.pull_request.head.repo.full_name == github.repository
+ }}
+
+ build:
+ needs: [setup, base]
+ # Forks skip every job in the base phase, so build when it was skipped or succeeded.
+ if: ${{ !cancelled() && needs.setup.result == 'success' && (needs.base.result == 'success' || needs.base.result == 'skipped') }}
name: build ${{ matrix.variation }}
strategy:
fail-fast: false
@@ -57,20 +75,15 @@ jobs:
tag-prefix: ${{ inputs.pr_number || github.event.pull_request.number }}
release-type: testing
# Only save and publish when the code comes from this repository or a maintainer asked for it.
- # Pull requests from forks are built but never leave Depot's isolated builders. Dependabot
- # runs have no access to the registry secrets, so they are treated the same way.
+ # Pull requests from forks are built but never leave Depot's isolated builders.
publish: >-
${{
github.event_name == 'workflow_dispatch' ||
- (
- github.event.pull_request.head.repo.full_name == github.repository &&
- github.actor != 'dependabot[bot]'
- )
+ github.event.pull_request.head.repo.full_name == github.repository
}}
- secrets: inherit
test:
- needs: [setup, build]
+ needs: [setup, base, build]
if: always() && needs.setup.result == 'success'
uses: ./.github/workflows/service_test-images.yml
permissions:
@@ -91,7 +104,12 @@ jobs:
with:
ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
authenticate_with_ghcr: false
- secrets: inherit
+ # A manual run signs as main, the identity users verify production images against,
+ # so the code a maintainer dispatches for a fork's pull request is never signed.
+ sign: ${{ github.event_name == 'pull_request' }}
+ secrets:
+ DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
+ DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
report:
needs: [setup, build, test, publish]
@@ -101,18 +119,14 @@ jobs:
contents: read
with:
matrix: ${{ needs.setup.outputs.matrix }}
- ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
comment:
- needs: [build, test, publish, report]
+ needs: [base, build, test, publish, report]
if: >-
always() &&
(
github.event_name == 'workflow_dispatch' ||
- (
- github.event.pull_request.head.repo.full_name == github.repository &&
- github.actor != 'dependabot[bot]'
- )
+ github.event.pull_request.head.repo.full_name == github.repository
)
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -122,6 +136,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}
+ BASE_RESULT: ${{ needs.base.result }}
BUILD_RESULT: ${{ needs.build.result }}
TEST_RESULT: ${{ needs.test.result }}
PUBLISH_RESULT: ${{ needs.publish.result }}
@@ -141,6 +156,11 @@ jobs:
failure) TEST_CELL="❌ Failed" ;;
*) TEST_CELL="⏭️ Skipped" ;;
esac
+ # A failed base skips every build, which leaves the test phase nothing to test.
+ if [ "$BASE_RESULT" = "failure" ] || [ "$BASE_RESULT" = "cancelled" ]; then
+ BUILD_CELL="❌ PHP base images failed to compile"
+ TEST_CELL="⏭️ Skipped"
+ fi
case "$PUBLISH_RESULT" in
success) PUBLISH_CELL="✅ Published to serversideup/php-dev" ;;
failure) PUBLISH_CELL="❌ Failed" ;;
diff --git a/.github/workflows/action_update-dockerhub-readme.yml b/.github/workflows/action_update-dockerhub-readme.yml
index 10fca4846..3ff986ef8 100644
--- a/.github/workflows/action_update-dockerhub-readme.yml
+++ b/.github/workflows/action_update-dockerhub-readme.yml
@@ -8,18 +8,23 @@ on:
paths:
- README.md
+permissions: {}
+
jobs:
update_container_readme:
runs-on: ubuntu-24.04
name: Push README to Docker Hub
+ permissions:
+ contents: read
steps:
- name: git checkout
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
ref: main
- name: push README to Dockerhub
- uses: peter-evans/dockerhub-description@v5
+ uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.DOCKER_HUB_README_USERNAME }}
password: ${{ secrets.DOCKER_HUB_README_PASSWORD }}
diff --git a/.github/workflows/scheduled-task_scorecard.yml b/.github/workflows/scheduled-task_scorecard.yml
new file mode 100644
index 000000000..7b40d0bcc
--- /dev/null
+++ b/.github/workflows/scheduled-task_scorecard.yml
@@ -0,0 +1,46 @@
+name: OpenSSF Scorecard
+
+# Scores the repository's supply chain practices (pinned dependencies, token permissions,
+# signed releases, branch protection, and more) and publishes the result to
+# scorecard.dev, which serves the badge in the README. Publishing only works while this
+# workflow follows Scorecard's rules: no workflow-level env, defaults, or write
+# permissions, and only Scorecard's approved actions in the job.
+on:
+ workflow_dispatch:
+ branch_protection_rule:
+ push:
+ branches:
+ - main
+ schedule:
+ - cron: '41 6 * * 3'
+
+permissions: {}
+
+jobs:
+ analysis:
+ # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream.
+ if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ actions: read
+ security-events: write
+ id-token: write
+ steps:
+ - name: Check out code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run Scorecard
+ uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload the results to the Security tab
+ uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
+ with:
+ sarif_file: results.sarif
diff --git a/.github/workflows/scheduled-task_update-readme.yml b/.github/workflows/scheduled-task_update-readme.yml
index c389c3b29..fc48fa722 100644
--- a/.github/workflows/scheduled-task_update-readme.yml
+++ b/.github/workflows/scheduled-task_update-readme.yml
@@ -7,6 +7,6 @@ jobs:
update-readme:
# Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream.
if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php'
- uses: serversideup/.github/.github/workflows/update-readme.yml@master
+ uses: serversideup/.github/.github/workflows/update-readme.yml@0cc5de42120438871a6c36a91b6a7dc2a15dbd7f # v1.0.1
permissions:
contents: write
diff --git a/.github/workflows/scheduled-task_watch-php-releases.yml b/.github/workflows/scheduled-task_watch-php-releases.yml
new file mode 100644
index 000000000..09196aa8c
--- /dev/null
+++ b/.github/workflows/scheduled-task_watch-php-releases.yml
@@ -0,0 +1,77 @@
+name: Watch for PHP releases
+
+# PHP is compiled from php.net, so a new patch can ship as soon as php.net lists it.
+# Every hour this resolves the active patches the same way a build does, and starts a
+# production run when one of them has no image on Docker Hub yet. Like the weekly
+# rebuild, that run rebuilds the latest release and only moves the floating tags.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '17 * * * *'
+
+permissions: {}
+
+jobs:
+ watch:
+ # Forks inherit the cron schedule. Their weekly rebuilds always fail and email whoever last edited the schedule upstream.
+ if: github.event_name != 'schedule' || github.repository == 'serversideup/docker-php'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ actions: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PRODUCTION_WORKFLOW: action_publish-images-production.yml
+ DOCKER_HUB_REPOSITORY: serversideup/php
+ # A run that started this recently either is still going or already opened a ci-failure issue.
+ COOLDOWN_HOURS: 6
+ steps:
+ - name: Check out code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Resolve the active PHP patches
+ run: ./scripts/get-php-versions.sh
+
+ # Each patch is looked up as its cli tag on the first OS it builds for. Anything other
+ # than "found" or "not found" is skipped, so a Docker Hub outage never starts a run.
+ # A minor with no base OS (a new PHP minor php.net lists before the base config does)
+ # is not built, so starting a run for it would only rebuild everything else.
+ - name: Find patches that have no image yet
+ id: missing
+ run: |
+ MISSING=()
+ while IFS=$'\t' read -r PATCH OS; do
+ TAG="${PATCH}-cli-${OS}"
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 \
+ "https://hub.docker.com/v2/namespaces/${DOCKER_HUB_REPOSITORY%/*}/repositories/${DOCKER_HUB_REPOSITORY#*/}/tags/${TAG}")
+ case "$STATUS" in
+ 200) echo "$PATCH is published ($TAG)" ;;
+ 404) echo "$PATCH has no image yet ($TAG)"; MISSING+=("$PATCH") ;;
+ *) echo "::warning title=Docker Hub lookup failed::$TAG returned HTTP $STATUS, skipping $PATCH this hour" ;;
+ esac
+ done < <(yq -o=json scripts/conf/php-versions.yml | jq -r '
+ .php_versions[].minor_versions[]
+ | select((.base_os // []) | length > 0)
+ | .base_os[0].name as $os
+ | .patch_versions[] | select(test("rc") | not)
+ | [., $os] | @tsv')
+ echo "patches=${MISSING[*]}" >> "$GITHUB_OUTPUT"
+
+ - name: Start a production run
+ if: steps.missing.outputs.patches != ''
+ env:
+ PATCHES: ${{ steps.missing.outputs.patches }}
+ run: |
+ RECENT=$(gh run list --workflow "$PRODUCTION_WORKFLOW" --limit 10 --json status,createdAt,url \
+ | jq -r --argjson hours "$COOLDOWN_HOURS" \
+ '[.[] | select(.status != "completed" or ((.createdAt | fromdateiso8601) > (now - $hours * 3600)))][0].url // ""')
+ if [ -n "$RECENT" ]; then
+ echo "::notice title=Already building::$RECENT started in the last ${COOLDOWN_HOURS} hours, so no new run was started for PHP $PATCHES."
+ exit 0
+ fi
+ gh workflow run "$PRODUCTION_WORKFLOW" -f reason="PHP ${PATCHES// /, } release"
+ echo "::notice title=Production run started::PHP $PATCHES has no published image yet."
diff --git a/.github/workflows/service_build-images.yml b/.github/workflows/service_build-images.yml
index fa382312c..4f1cb2804 100644
--- a/.github/workflows/service_build-images.yml
+++ b/.github/workflows/service_build-images.yml
@@ -1,11 +1,12 @@
name: Build Images
-# Phase 1 of 3: build every image for one variation. Nothing is pushed to a public
-# registry here. When publishing is requested, each image is saved to the Depot
-# Registry so the image tests can run against it and, if they pass,
-# service_publish-images.yml promotes the exact same image to Docker Hub and GHCR.
-# When it is not (pull requests from forks), the amd64 image is loaded onto the
-# runner and tested right here instead, so a green build still means a tested image.
+# Phase 1 of 3: build every image for one variation on the PHP base images that
+# service_build-php-base.yml compiled. Nothing is pushed to a public registry here.
+# When publishing is requested, each image is saved to the Depot Registry so the
+# image tests can run against it and, if they pass, service_publish-images.yml
+# promotes the exact same image to Docker Hub and GHCR. When it is not (pull requests
+# from forks), the amd64 image is loaded onto the runner and tested right here
+# instead, so a green build still means a tested image.
on:
workflow_call:
inputs:
@@ -58,21 +59,24 @@ jobs:
steps:
- name: Check out code.
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
ref: ${{ inputs.ref }}
- name: Download PHP Versions file
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: php-versions.yml
path: ./artifacts
- name: Move PHP Versions file
- run: mv ./artifacts/php-versions.yml ${{ inputs.php-versions-file }}
+ env:
+ PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }}
+ run: mv ./artifacts/php-versions.yml "$PHP_VERSIONS_FILE"
- name: Set up Depot CLI
- uses: depot/setup-action@v1
+ uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
- name: "📦 Assemble the Docker Tags"
run: |
@@ -91,39 +95,66 @@ jobs:
- name: Set REPOSITORY_BUILD_VERSION
id: set_version
+ env:
+ REF_TYPE: ${{ github.ref_type }}
+ REF_NAME: ${{ github.ref_name }}
+ INPUT_REF: ${{ inputs.ref }}
+ RUN_ID: ${{ github.run_id }}
run: |
- if [[ "${{ github.ref_type }}" == "tag" || "${{ inputs.ref }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ if [[ "$REF_TYPE" == "tag" || "$INPUT_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Building from a tag (release or scheduled task)
echo "🚀 Setting REPOSITORY_BUILD_VERSION to Tag + Run ID"
- if [[ "${{ github.ref_type }}" == "tag" ]]; then
- VERSION="${{ github.ref_name }}"
+ if [[ "$REF_TYPE" == "tag" ]]; then
+ VERSION="$REF_NAME"
else
- VERSION="${{ inputs.ref }}"
+ VERSION="$INPUT_REF"
fi
- echo "REPOSITORY_BUILD_VERSION=${VERSION}-${{ github.run_id }}" >> "$GITHUB_ENV"
+ echo "REPOSITORY_BUILD_VERSION=${VERSION}-${RUN_ID}" >> "$GITHUB_ENV"
else
# Regular git build (PR, branch push, etc). Read the SHA from the checkout so
# builds dispatched for a PR number report the PR's commit, not the dispatched branch.
echo "👨🔬 Setting REPOSITORY_BUILD_VERSION to Git SHA + Run ID"
SHORT_SHA=$(git rev-parse --short=7 HEAD)
- echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${{ github.run_id }}" >> "$GITHUB_ENV"
+ echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${RUN_ID}" >> "$GITHUB_ENV"
fi
- name: Compute NGINX build-arg (only for fpm-nginx)
id: compute_nginx
if: ${{ matrix.php_variation == 'fpm-nginx' }}
+ env:
+ BASE_OS: ${{ matrix.base_os }}
+ PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }}
run: |
if command -v yq >/dev/null 2>&1; then
- VERSION=$(yq -r '.operating_systems[].versions[] | select(.version == "${{ matrix.base_os }}") | .nginx_version' '${{ inputs.php-versions-file }}' | head -n1)
+ VERSION=$(yq -r '.operating_systems[].versions[] | select(.version == strenv(BASE_OS)) | .nginx_version' "$PHP_VERSIONS_FILE" | head -n1)
else
- VERSION=$(awk -v key="${{ matrix.base_os }}" 'BEGIN{found=0} $1=="version:" && $2==key {found=1} found && $1=="nginx_version:" {print $2; exit}' "${{ inputs.php-versions-file }}")
+ VERSION=$(awk -v key="$BASE_OS" 'BEGIN{found=0} $1=="version:" && $2==key {found=1} found && $1=="nginx_version:" {print $2; exit}' "$PHP_VERSIONS_FILE")
fi
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
- echo "Unable to determine NGINX version for OS ${{ matrix.base_os }}" 1>&2
+ echo "Unable to determine NGINX version for OS $BASE_OS" 1>&2
exit 1
fi
echo "nginx_arg=NGINX_VERSION=$VERSION" >> "$GITHUB_OUTPUT"
+ # Every pinned dependency version from the base config, plus the PHP base image that
+ # service_build-php-base.yml compiled for this image. Builds that are not saved (forks)
+ # have no compiled base and use the Dockerfile's default, the official image.
+ - name: Collect build args
+ id: build_args
+ env:
+ PUBLISH: ${{ inputs.publish }}
+ PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }}
+ PHP_BASE_TAG: ${{ matrix.php_base_tag }}
+ run: |
+ {
+ echo "args<> "$GITHUB_OUTPUT"
+ echo "count=$COUNT" >> "$GITHUB_OUTPUT"
+ echo "$COUNT images to scan"
+
+ scan:
+ needs: plan
+ if: needs.plan.outputs.count > 0
+ name: scan ${{ matrix.variation }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ permissions: {}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
+ steps:
+ # The database goes in a volume so every image in the group is scanned against the
+ # same one, downloaded once.
+ - name: Download the vulnerability database
+ run: docker run --rm -v grype-db:/db -e GRYPE_DB_CACHE_DIR=/db "anchore/grype:${GRYPE_VERSION}" db update
+
+ # Grype names every image's findings after the same file (php//lib/apk/db/installed),
+ # which code scanning would merge into one alert across all images. Each finding is
+ # moved under its image, keyed by PHP minor version so an alert stays the same alert
+ # when a new PHP patch ships.
+ - name: Scan every image in the group
+ env:
+ IMAGES: ${{ matrix.images }}
+ VARIATION: ${{ matrix.variation }}
+ SCAN_ATTEMPTS: 3
+ run: |
+ mkdir -p raw fixable
+ chmod 777 raw
+
+ scan_image() {
+ local image="$1" tag="$2" digest
+ digest=$(docker buildx imagetools inspect "$tag" --format '{{json .Manifest.Digest}}' | jq -r .) || return 1
+ docker run --rm -v grype-db:/db -v "$PWD/raw:/out" -e GRYPE_DB_CACHE_DIR=/db -e GRYPE_DB_AUTO_UPDATE=false \
+ "anchore/grype:${GRYPE_VERSION}" "registry:${SCAN_REPOSITORY}@${digest}" --platform "$SCAN_PLATFORM" --quiet \
+ --output "json=/out/${image}.json" --output "sarif=/out/${image}.sarif"
+ }
+
+ FAILED=()
+ {
+ echo "## ${VARIATION}: vulnerabilities by image"
+ echo ""
+ echo "| Image | Critical | High | Medium | Fixable |"
+ echo "|---|---|---|---|---|"
+ } >> "$GITHUB_STEP_SUMMARY"
+ while IFS=$'\t' read -r IMAGE PHP OS TAG; do
+ echo "::group::${IMAGE}"
+ attempt=1
+ until scan_image "$IMAGE" "$TAG"; do
+ if [ "$attempt" -ge "$SCAN_ATTEMPTS" ]; then
+ echo "::error title=Scan failed::${IMAGE} could not be scanned after ${SCAN_ATTEMPTS} attempts"
+ FAILED+=("$IMAGE")
+ break
+ fi
+ sleep $(( 10 * attempt ))
+ attempt=$(( attempt + 1 ))
+ done
+ if [ -s "raw/${IMAGE}.json" ] && [ -s "raw/${IMAGE}.sarif" ]; then
+ case "$PHP" in
+ *-rc*) KEY="${PHP}-${OS}" ;;
+ *) KEY="${PHP%.*}-${OS}" ;;
+ esac
+ # shellcheck disable=SC2016 # the backticks are Markdown
+ jq -r --arg image "$IMAGE" '[.matches[].vulnerability] as $v
+ | def count(s): [$v[] | select(.severity == s)] | length;
+ "| `\($image)` | \(count("Critical")) | \(count("High")) | \(count("Medium")) | \([$v[] | select(.fix.state == "fixed")] | length) |"' \
+ "raw/${IMAGE}.json" | tee -a "$GITHUB_STEP_SUMMARY"
+ jq --slurpfile scan "raw/${IMAGE}.json" --arg key "$KEY" '
+ ([$scan[0].matches[] | select(.vulnerability.fix.state == "fixed") | "\(.vulnerability.id)-\(.artifact.name)"] | unique) as $fixable
+ | .runs[0]
+ | {
+ rules: [.tool.driver.rules[] | select(.id as $id | $fixable | index($id))],
+ results: [.results[] | select(.ruleId as $id | $fixable | index($id))
+ | .locations |= map(.physicalLocation.artifactLocation.uri |= ($key + "/" + sub("^[^/]*//"; "")))
+ | .partialFingerprints = {primaryLocationLineHash: "\($key):\(.ruleId):1"}]
+ }' "raw/${IMAGE}.sarif" > "fixable/${IMAGE}.json"
+ fi
+ echo "::endgroup::"
+ done < <(echo "$IMAGES" | jq -r '.[] | [.image, .php, .os, .tag] | @tsv')
+
+ if [ "${#FAILED[@]}" -gt 0 ]; then
+ echo "::error title=Scan failed::${FAILED[*]} were not scanned"
+ exit 1
+ fi
+
+ # One run for the whole variation keeps the upload within code scanning's limit of 20 runs per file.
+ TEMPLATE=$(find raw -name '*.sarif' | head -n1)
+ jq -s --slurpfile template "$TEMPLATE" '. as $images | {
+ "$schema": $template[0]["$schema"],
+ version: $template[0].version,
+ runs: [{
+ tool: {driver: ($template[0].runs[0].tool.driver | .rules = ([$images[].rules[]] | unique_by(.id)))},
+ results: [$images[].results[]]
+ }]
+ }' fixable/*.json > "${VARIATION}.sarif"
+ echo "$(jq '.runs[0].results | length' "${VARIATION}.sarif") fixable findings across $(find fixable -name '*.json' | wc -l) images"
+
+ - name: Upload the findings
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: sarif-${{ matrix.variation }}
+ path: ${{ matrix.variation }}.sarif
+ retention-days: 7
+
+ alerts:
+ needs: [plan, scan]
+ if: needs.scan.result == 'success'
+ name: alerts ${{ matrix.variation }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
+ steps:
+ - name: Download the findings
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
+ with:
+ name: sarif-${{ matrix.variation }}
+
+ - name: Update the code scanning alerts
+ uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
+ with:
+ sarif_file: ${{ matrix.variation }}.sarif
+ category: grype/${{ matrix.variation }}
+ ref: refs/heads/main
+ sha: ${{ needs.plan.outputs.main-sha }}
diff --git a/.github/workflows/service_setup-matrix.yml b/.github/workflows/service_setup-matrix.yml
index 4f301bc11..cdaced6f4 100644
--- a/.github/workflows/service_setup-matrix.yml
+++ b/.github/workflows/service_setup-matrix.yml
@@ -18,6 +18,9 @@ on:
variations:
description: 'JSON array of variation names, e.g. ["cli","fpm"]'
value: ${{ jobs.matrix.outputs.variations }}
+ base-matrix:
+ description: 'One entry per PHP base image the variations build on, e.g. {"include": [{"php_base_tag": "php-base-8.4.27-fpm-trixie", ...}]}'
+ value: ${{ jobs.matrix.outputs.base-matrix }}
jobs:
matrix:
@@ -28,10 +31,12 @@ jobs:
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
variations: ${{ steps.matrix.outputs.variations }}
+ base-matrix: ${{ steps.matrix.outputs.base-matrix }}
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
ref: ${{ inputs.ref }}
- name: Prepare PHP versions for the matrix. 😎
@@ -42,17 +47,21 @@ jobs:
SKIP_DOWNLOAD: false
- name: Ensure our PHP Versions file exists.
+ env:
+ PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }}
run: |
- if [ ! -f "${{ inputs.php-versions-file }}" ]; then
- echo "::error::PHP Versions file does not exist at ${{ inputs.php-versions-file }}"
+ if [ ! -f "$PHP_VERSIONS_FILE" ]; then
+ echo "::error::PHP Versions file does not exist at $PHP_VERSIONS_FILE"
exit 1
fi
- cat "${{ inputs.php-versions-file }}"
+ cat "$PHP_VERSIONS_FILE"
- name: Assemble PHP versions into the matrix. 😎
id: matrix
+ env:
+ PHP_VERSIONS_FILE: ${{ inputs.php-versions-file }}
run: |
- FULL_MATRIX=$(bash ./scripts/generate-matrix.sh '${{ inputs.php-versions-file }}')
+ FULL_MATRIX=$(bash ./scripts/generate-matrix.sh "$PHP_VERSIONS_FILE")
# Group the matrix by variation so each variation becomes its own job group in the GitHub UI.
MATRIX_BY_VARIATION=$(echo "$FULL_MATRIX" | jq -c '
@@ -62,23 +71,27 @@ jobs:
| from_entries
')
VARIATIONS=$(echo "$MATRIX_BY_VARIATION" | jq -c 'keys')
+ BASE_MATRIX=$(echo "$FULL_MATRIX" | jq -c '{ include: (.include | unique_by(.php_base_tag) | map({ patch_version, base_os, php_base, php_base_tag, docker_library_ref })) }')
- echo "matrix=${MATRIX_BY_VARIATION}" >> "$GITHUB_OUTPUT"
- echo "variations=${VARIATIONS}" >> "$GITHUB_OUTPUT"
+ {
+ echo "matrix=${MATRIX_BY_VARIATION}"
+ echo "variations=${VARIATIONS}"
+ echo "base-matrix=${BASE_MATRIX}"
+ } >> "$GITHUB_OUTPUT"
echo "$MATRIX_BY_VARIATION" | jq '.'
{
- echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations"
+ echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations, on $(echo "$BASE_MATRIX" | jq '.include | length') PHP base images"
echo ""
echo "| PHP | Resolved patch version | Base OS |"
echo "|---|---|---|"
- yq -o=json '${{ inputs.php-versions-file }}' | jq -r '
+ yq -o=json "$PHP_VERSIONS_FILE" | jq -r '
.php_versions[].minor_versions[]
| "| \(.minor) | \(.patch_versions | join(", ")) | \(.base_os | map(.name) | join(", ")) |"'
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload the php-versions.yml file
- uses: actions/upload-artifact@v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: php-versions.yml
path: ${{ inputs.php-versions-file }}
diff --git a/.github/workflows/service_test-images.yml b/.github/workflows/service_test-images.yml
index cd00ea316..bd2cd9038 100644
--- a/.github/workflows/service_test-images.yml
+++ b/.github/workflows/service_test-images.yml
@@ -26,7 +26,7 @@ jobs:
count: ${{ steps.list.outputs.count }}
steps:
- name: Download image details from every build job
- uses: actions/download-artifact@v8
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: image-details-*
path: image-details
@@ -65,12 +65,13 @@ jobs:
matrix: ${{ fromJson(needs.list.outputs.matrix) }}
steps:
- name: Check out code
- uses: actions/checkout@v7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
ref: ${{ inputs.ref }}
- name: Set up Depot CLI
- uses: depot/setup-action@v1
+ uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2
with:
oidc: true
@@ -83,10 +84,12 @@ jobs:
env:
IMAGES: ${{ matrix.images }}
PHP: ${{ matrix.php }}
+ OS: ${{ matrix.os }}
+ ARCH: ${{ matrix.arch }}
run: |
FAILED=()
while IFS=$'\t' read -r VARIATION SAVED; do
- echo "::group::${VARIATION} ${PHP}-${{ matrix.os }} ${{ matrix.arch }}"
+ echo "::group::${VARIATION} ${PHP}-${OS} ${ARCH}"
if ! { docker pull --quiet "$SAVED" && bash scripts/test-image.sh "$SAVED" "$PHP"; }; then
FAILED+=("$VARIATION")
fi
@@ -94,6 +97,6 @@ jobs:
done < <(echo "$IMAGES" | jq -r '.[] | [.variation, .saved] | @tsv')
if [ "${#FAILED[@]}" -gt 0 ]; then
- echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${{ matrix.os }} on ${{ matrix.arch }}"
+ echo "::error title=Image tests failed::${FAILED[*]} failed for ${PHP}-${OS} on ${ARCH}"
exit 1
fi
diff --git a/.github/workflows/trigger_auto-retry-failed-builds.yml b/.github/workflows/trigger_auto-retry-failed-builds.yml
index 330a7b8c6..e82ed8cf8 100644
--- a/.github/workflows/trigger_auto-retry-failed-builds.yml
+++ b/.github/workflows/trigger_auto-retry-failed-builds.yml
@@ -5,8 +5,14 @@ name: Auto-retry failed image builds
# the failure looks transient. When a run is still red after that, or was never worth
# retrying, it opens an issue for a human. Pull request runs skip the issue because the
# PR comment already carries the result.
+#
+# Runs are found by workflow file, because `workflow_run.name` holds the run's title
+# (`run-name:`), not the workflow's name.
+#
+# workflow_run is safe here: this never checks out or runs code from the run it reacts
+# to, and only reads that run's metadata and logs.
on:
- workflow_run:
+ workflow_run: # zizmor: ignore[dangerous-triggers]
workflows:
- "Docker Publish (Production Images)"
- "Docker Publish (Beta Images)"
@@ -17,7 +23,8 @@ permissions: {}
jobs:
triage:
- if: github.event.workflow_run.conclusion == 'failure'
+ # A Depot runner that never connects leaves its job failed or cancelled, which can end the whole run as cancelled.
+ if: contains(fromJSON('["failure", "cancelled"]'), github.event.workflow_run.conclusion)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
@@ -30,7 +37,7 @@ jobs:
RUN_URL: ${{ github.event.workflow_run.html_url }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
RUN_TITLE: ${{ github.event.workflow_run.display_title }}
- WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
+ RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
WORKFLOW_PATH: ${{ github.event.workflow_run.path }}
TRIGGER: ${{ github.event.workflow_run.event }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
@@ -48,15 +55,27 @@ jobs:
sleep 120
fi
- LATEST=$(gh run list --workflow "$WORKFLOW_NAME" --branch "$HEAD_BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId')
+ LATEST=$(gh run list --workflow "$(basename "$WORKFLOW_PATH")" --branch "$HEAD_BRANCH" --limit 1 --json databaseId --jq '.[0].databaseId')
if [ "$LATEST" != "$RUN_ID" ]; then
echo "::notice title=Skipped::A newer run already exists for $HEAD_BRANCH, so this one is stale."
echo "action=none" >> "$GITHUB_OUTPUT"
exit 0
fi
- FAILED=$(gh run view "$RUN_ID" --json jobs --jq '[.jobs[] | select(.conclusion == "failure")] | length')
- echo "Failed jobs in $RUN_URL (attempt $RUN_ATTEMPT): $FAILED"
+ JOBS=$(gh run view "$RUN_ID" --json jobs --jq '.jobs')
+ FAILED=$(echo "$JOBS" | jq '[.[] | select(.conclusion == "failure")] | length')
+ CANCELLED=$(echo "$JOBS" | jq '[.[] | select(.conclusion == "cancelled")] | length')
+
+ # A run someone cancelled by hand has no failed jobs. A runner that never connects
+ # fails one job and cancels others, so that case is retried like any other failure.
+ if [ "$RUN_CONCLUSION" = "cancelled" ] && [ "$FAILED" = "0" ]; then
+ echo "::notice title=Skipped::The run was cancelled and no job failed, so it was most likely cancelled on purpose."
+ echo "action=none" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ FAILED=$((FAILED + CANCELLED))
+ echo "Failed or cancelled jobs in $RUN_URL (attempt $RUN_ATTEMPT): $FAILED"
if [ "$RUN_ATTEMPT" = "1" ] && [ "$FAILED" -le "$MAX_FAILED_JOBS" ]; then
echo "action=retry" >> "$GITHUB_OUTPUT"
@@ -76,7 +95,7 @@ jobs:
echo "::notice title=Retry started::Re-running failed jobs for $RUN_URL. If this attempt also fails, an issue is opened."
- name: Open an issue for a human
- if: steps.check.outputs.action == 'issue' && !contains(env.WORKFLOW_NAME, 'PR Images')
+ if: steps.check.outputs.action == 'issue' && !endsWith(env.WORKFLOW_PATH, 'action_publish-images-prs.yml')
env:
REASON: ${{ steps.check.outputs.reason }}
MAX_DETAILED_JOBS: 8
@@ -84,7 +103,7 @@ jobs:
run: |
JOBS=$(gh run view "$RUN_ID" --json jobs --jq '.jobs')
TOTAL=$(echo "$JOBS" | jq 'length')
- FAILED_JOBS=$(echo "$JOBS" | jq -c '[.[] | select(.conclusion == "failure")
+ FAILED_JOBS=$(echo "$JOBS" | jq -c '[.[] | select(.conclusion == "failure" or .conclusion == "cancelled")
| { id: .databaseId, name, url, step: ((.steps[]? | select(.conclusion == "failure") | .name) // "-") }]')
FAILED=$(echo "$FAILED_JOBS" | jq 'length')
WORKFLOW_URL="https://github.com/${GH_REPO}/actions/workflows/$(basename "$WORKFLOW_PATH")"
@@ -107,7 +126,7 @@ jobs:
{
echo "**[Re-run failed jobs]($RUN_URL)** · [Run summary]($RUN_URL#summary) · [Workflow history]($WORKFLOW_URL) · attempt $RUN_ATTEMPT"
echo ""
- echo "$FAILED of $TOTAL jobs failed on \`$HEAD_BRANCH\` at [\`$SHORT_SHA\`](https://github.com/${GH_REPO}/commit/${HEAD_SHA}), triggered by \`$TRIGGER\`. $REASON"
+ echo "$FAILED of $TOTAL jobs failed or were cancelled on \`$HEAD_BRANCH\` at [\`$SHORT_SHA\`](https://github.com/${GH_REPO}/commit/${HEAD_SHA}), triggered by \`$TRIGGER\`. $REASON"
echo ""
echo "### Failed jobs"
echo ""
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
new file mode 100644
index 000000000..d96283311
--- /dev/null
+++ b/.github/zizmor.yml
@@ -0,0 +1,6 @@
+# zizmor configuration for .github/workflows
+# Each disabled rule is a deliberate project decision, not an oversight.
+rules:
+ # Its fix, `uses: $/...`, is rejected by actionlint 1.7.12, which also lints these workflows.
+ self-repository:
+ disable: true
diff --git a/.gitignore b/.gitignore
index 310c9d1bb..8f973cbe0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ node_modules
php-versions.yml
*.tmp
/docs/_OLD_
-.playwright-mcp
\ No newline at end of file
+.playwright-mcp
+.php-base/
diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md
index 2d1b81435..859b17404 100644
--- a/.oss-scanner/threat_model.md
+++ b/.oss-scanner/threat_model.md
@@ -1,12 +1,16 @@
# Threat model
## What this project does and where untrusted input enters
-serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp`) on Debian and Alpine. We build on the official `php` images and add entrypoint scripts, S6 Overlay services, and web server configs. All of it is configured through environment variables. The images run as `www-data` and listen on 8080 and 8443. See `SECURITY.md` for our full scope.
+serversideup/php publishes PHP Docker images (`cli`, `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp`) on Debian and Alpine. We compile PHP with the official docker-library/php Dockerfiles at a pinned commit and add entrypoint scripts, S6 Overlay services, and web server configs. All of it is configured through environment variables. The images run as `www-data` and listen on 8080 and 8443. See `SECURITY.md` for our full scope.
Untrusted input:
- **HTTP requests** to the NGINX, Apache, and Caddy (FrankenPHP) configs we ship. Assume any client on the network can send them. This includes headers that reach PHP or our real-IP handling (`TRUSTED_PROXY`), requests for `/healthcheck`, PHP-FPM status paths, `/storage/*.php`, dotfiles such as `.env` and `.git`, path info after `index.php`, and plain-HTTP requests when `SSL_MODE` is `mixed` or `full`.
- **Pull requests from forks** against `.github/workflows/`. A fork can control the PR title, branch name, and file contents. It must never reach repository secrets, the Depot OIDC trust, or any publish to Docker Hub or GHCR.
-- **Downloads during image builds** (s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures.
+- **Workflows that start without a pull request.** None of these run fork code:
+ - `scheduled-task_watch-php-releases.yml` runs hourly on `schedule` and on `workflow_dispatch`. It reads php.net's release feed and Docker Hub's tag API, and when a patch has no image it starts the production workflow with `actions: write`. The patch version from php.net reaches the run title through `gh workflow run -f reason=...`.
+ - `service_scan-images.yml` runs on `workflow_call` after every production publish and on `workflow_dispatch`. Grype, its vulnerability database, and the images it scans are third-party input. The `scan` job has no token and no registry credentials, and runs Grype in its own container. The `alerts` job holds only `security-events: write`, never runs Grype, and uploads the SARIF the scan jobs produce. Text from that SARIF (CVE IDs, package names, versions) ends up in code scanning alerts.
+ - `scheduled-task_scorecard.yml` runs on `push` to `main`, `schedule`, `branch_protection_rule`, and `workflow_dispatch`.
+- **Downloads during image builds** (the PHP tarball from php.net, the docker-library/php Dockerfiles, phpredis and liblzf source, s6-overlay, NGINX signing keys, PHP extension installer) through `docker-php-serversideup-download`. Tampering in transit or at the source must be caught by checksums or signatures. The PHP tarball is checked against php.net's SHA256 and its GPG signature, docker-library/php and liblzf are pinned to commits, and the other downloads are pinned to release tags in `scripts/conf/php-versions-base-config.yml`.
Trusted (operators control these):
- Environment variables
@@ -23,6 +27,7 @@ Most:
- Web server configs in `src/variations/*/etc/` and PHP-FPM pools in `src/php-fpm.d/`
- The unprivileged model. A path from `www-data` to root, or a default that runs something as root, matters.
- `.github/workflows/` and `scripts/` used by CI (what gets built, tested, and published)
+- The signing identities. cosign signs images keylessly from `.github/workflows/service_publish-images.yml`, at `refs/heads/main` or a `v*` tag, with GitHub's OIDC issuer. Users trust anything signed by that identity, so a way to run other code inside that workflow matters.
Least, or out of scope:
- The `docs/` site
@@ -47,8 +52,8 @@ kill %1 # stops it, like docke
`KEY=VALUE` arguments override the image's environment, like `docker run -e`. Every variation listens on 8080 and 8443, so run one at a time or change the port variables. Scripts in `src/` must run under both Debian `dash` and Alpine BusyBox `sh`, so check behavior on `fpm-nginx-alpine` as well. `scripts/tests/run.sh` tests the CI helper scripts.
## How you rate severity
-- **Critical:** remote, unauthenticated code execution or arbitrary file read (for example, `.env` or application source) through a config we ship with default settings. Also a fork pull request that can publish an image or read a secret.
-- **High:** escalation from `www-data` to root. A default that exposes secrets, the PHP-FPM status page, or FastCGI to the network. A fork pull request that can run code with write access to the repository or with Depot or registry credentials. A downloaded artifact that is used without verification.
+- **Critical:** remote, unauthenticated code execution or arbitrary file read (for example, `.env` or application source) through a config we ship with default settings. Also a fork pull request that can publish an image, sign as one of our workflows, or read a secret.
+- **High:** escalation from `www-data` to root. A default that exposes secrets, the PHP-FPM status page, or FastCGI to the network. A fork pull request that can run code with write access to the repository or with Depot or registry credentials. Scanner output that can run code in the `alerts` job or write anything beyond code scanning alerts. A downloaded artifact that is used without verification.
- **Medium:** client IP spoofing past `TRUSTED_PROXY`, so that IP-based controls are bypassed. Plain HTTP served when the docs say SSL is enforced. Denial of service from a single unauthenticated request.
- **Low:** version banners and other information leaks that give no access. Hardening gaps with no demonstrated impact.
@@ -58,4 +63,5 @@ When a finding only applies with a non-default environment variable, rate it one
- `COMPOSER_ALLOW_SUPERUSER=1` is intentional for builds that run as root.
- `PHP_DISPLAY_ERRORS` and similar settings that operators can turn on for development are not findings.
- Images run as `www-data` and can be switched to root with `USER root`. That design is documented and intentional.
-- Packages that are not pinned, and floating base image tags, are intentional. Weekly rebuilds pick up security fixes.
+- Packages that are not pinned, and floating base image tags, are intentional. Weekly rebuilds pick up security fixes, and the release watcher picks up new PHP patches within the hour.
+- Public scan results that list CVEs in our images are intentional. They are already disclosed upstream CVEs that anyone can find by scanning a public image.
diff --git a/AGENTS.md b/AGENTS.md
index b691fe74f..772ba86ed 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,145 +1,83 @@
# AI Agent Guidelines
-This project maintains open source PHP Docker images (`serversideup/php`) for Laravel and other PHP applications. Images are published to Docker Hub and GitHub Packages. These images build on top of the official PHP Docker images with production-grade defaults, security hardening, and a superior developer experience through environment-variable-driven configuration. See `docs/content/docs/1.getting-started/4.these-images-vs-others.md` for the full philosophy.
+This repo builds the `serversideup/php` Docker images: official PHP plus production defaults, hardening, and configuration through environment variables. Images publish to Docker Hub and GHCR. The docs site lives in `docs/` and has its own `docs/AGENTS.md`.
-## Project Structure
+This file lists only the rules that are easy to break. The code, config comments, and workflow files in `.github/workflows/` are the source of truth. When this file disagrees with them, trust the code and fix this file in the same change.
-```
-src/
- variations/ # One Dockerfile per image variation (cli, fpm, fpm-apache, fpm-nginx, frankenphp)
- common/ # Shared scripts and configs copied into ALL variations
- usr/local/bin/ # Entrypoint and helper scripts (POSIX /bin/sh)
- etc/entrypoint.d/# Numbered priority entrypoint scripts (00-*, 50-*, etc.)
- s6/ # Shared S6 Overlay service definitions and install script
- php-fpm.d/ # Shared PHP-FPM pool configuration templates
- utilities-webservers/ # Shared web server entrypoint utilities (SSL, etc.)
-scripts/ # Build tooling (Bash, not POSIX)
- dev.sh # Local image builds
- conf/ # PHP version matrix and base config (YAML)
- generate-matrix.sh # Generates CI build matrix from YAML config
- assemble-docker-tags.sh
-docs/ # Nuxt 4 documentation site (see docs/AGENTS.md for docs-specific guidelines)
-.github/workflows/ # CI/CD with GitHub Actions + Depot for multi-arch builds
-```
-
-## Shell Script Rules
-
-**IMPORTANT:** There are two distinct shell environments in this project. Getting this wrong breaks images.
+## Shell scripts
-- **`src/` scripts** (entrypoint, healthcheck, helper scripts): MUST be POSIX-compliant `/bin/sh`. These run inside Docker containers on both **Debian** and **Alpine** Linux. No bashisms (`[[ ]]`, arrays, `local -n`, process substitution, etc.).
-- **`scripts/` directory** (build tooling): Bash (`/bin/bash`). Must work on macOS, Linux, and WSL2.
+There are two shell environments. Mixing them up breaks images.
-### Common gotchas
-- Alpine uses BusyBox `sh`, not `bash`. Commands like `readlink -f`, `sed -i` (without backup extension), and `which` behave differently.
-- Use `command -v` instead of `which` in POSIX scripts.
-- Use `$(...)` not backticks for command substitution.
-- Test OS detection with `[ -f /etc/alpine-release ]` (Alpine) or `[ -f /etc/debian_version ]` (Debian).
+- **`src/`** runs inside containers on Debian and Alpine (BusyBox): POSIX `/bin/sh` only. Shellcheck in `scripts/lint.sh` catches bashisms through the `#!/bin/sh` shebang, but not `which` or backticks: use `command -v` and `$(...)`. Detect the OS with `[ -f /etc/alpine-release ]` or `[ -f /etc/debian_version ]`.
+- **`scripts/`** is Bash and must work on macOS, Linux, and WSL2.
-## Naming Conventions
+Naming: container helpers are `docker-php-serversideup-*`, health checks are `healthcheck-*`, and `entrypoint.d/` scripts have a number prefix that sets their order (`sort -V`).
-- Container scripts in `src/common/usr/local/bin/` follow the prefix pattern: `docker-php-serversideup-*` (e.g., `docker-php-serversideup-entrypoint`, `docker-php-serversideup-set-file-permissions`).
-- Healthcheck scripts use: `healthcheck-*` (e.g., `healthcheck-horizon`, `healthcheck-queue`).
-- Entrypoint.d scripts use numbered prefixes for execution order: `0-container-info.sh`, `1-log-output-level.sh`, `50-laravel-automations.sh`.
+## Image rules
-## Image Architecture
+- **One Dockerfile per variation**, shared by Debian and Alpine. OS differences go in helper scripts (`docker-php-serversideup-dep-install-debian`, `-alpine`), never in duplicate Dockerfiles.
+- **Dockerfiles download with `docker-php-serversideup-download [output-file]`**, never `curl`, because it retries. `scripts/lint.sh` fails on a `curl` call. Download archives to a file before extracting.
+- **Dependency versions live only in `scripts/conf/php-versions-base-config.yml`.** The comments at the top of that file explain how they reach the Dockerfiles. To add one: a config line with a `# renovate:` comment, plus an `ARG` with no default in each stage that uses it.
+- **Don't edit `scripts/conf/php-versions.yml`.** It is generated. A minor's `patch_versions` in the base config pins or holds back a release.
+- **Keep docker-library/php's Dockerfile untouched** apart from what `scripts/prepare-php-base.sh` already changes. Matching the official images is the point. The `redis` extension builds from GitHub source so no default step depends on PECL.
+- **S6 services register** with an empty file in `etc/s6-overlay/user-bundles.d/user/contents.d/`. The older `s6-rc.d/user/contents.d/` makes rc.init write to `/etc` and fails as the unprivileged user.
+- Every setting is an environment variable; no runtime config editing. Justify any new package or extension: image size matters.
-**There is exactly one Dockerfile per variation.** Each Dockerfile must work across all supported OS bases (Debian and Alpine). OS-specific logic is pushed into shared helper scripts (e.g., `docker-php-serversideup-dep-install-debian`, `docker-php-serversideup-dep-install-alpine`) rather than duplicating Dockerfiles. This keeps maintenance manageable across 8,000+ image tags.
+## PHP versions and releases
-**Never call `curl` directly in a Dockerfile or build script.** Use `docker-php-serversideup-download [output-file]` from `src/common/`, which retries with backoff. The whole matrix starts at once and unretried downloads fail on transient 5xx responses from GitHub and other hosts. Download archives to a file before extracting them so a retry never feeds a partial stream to `tar`.
+- **Release candidates** (`8.6-rc`) resolve through `scripts/get-php-release-candidate.sh` and ship with the weekly rebuild or a release, never the hourly check, which looks only for stable patches. When php.net lists no RC, the run skips it with a warning. Remove the `-rc` minor once the final release is out.
+- **When docker-library changes or drops a version**, set `php_base_overrides` or `docker_library_ref` on that minor in the base config. Its comments show the current uses.
-Services register with s6-overlay by adding an empty file to `etc/s6-overlay/user-bundles.d/user/contents.d/` (s6-overlay 3.2.3+ layout). The older `s6-rc.d/user/contents.d/` location makes rc.init try to write to `/etc` at startup, which fails as the unprivileged user and leaves the container unhealthy.
+## Adding an environment variable
-Each variation Dockerfile uses multi-stage builds:
-1. Shared assets are `COPY`ed from `src/common/`, `src/s6/`, `src/php-fpm.d/`, and `src/utilities-webservers/`
-2. Variation-specific configs live in `src/variations//etc/`
-3. The build context is the **project root** (not `src/`), so COPY paths are relative to root: `COPY --chmod=755 src/common/ /`
+`docs/content/data/env-vars.yml` is the only place a variable is documented. `scripts/check-env-var-docs.sh` fails CI when it and `src/` disagree, so add or change both in the same change.
-### Build args used across Dockerfiles
-- `PHP_VERSION`, `BASE_OS_VERSION`, `PHP_VARIATION` -- set by CI matrix or `scripts/dev.sh`
-- `NGINX_VERSION` -- resolved per-OS from `scripts/conf/php-versions-base-config.yml`
-- `REPOSITORY_BUILD_VERSION` -- image version label
+1. Set the default where the code reads it: an `ENV` line in the final stage of each variation that uses it, `: "${NAME:=default}"` in a script, or `{$NAME:default}` in a FrankenPHP Caddyfile.
+2. Add the variable to `env-vars.yml`, sorted A-Z. The comment at the top of that file explains each field. Leave out `variations` when every variation reads it, and use `variation_defaults` when one variation's default differs.
+3. A variable only build steps or internal scripts read, never users, goes in `INTERNAL_VARIABLES` in `scripts/check-env-var-docs.sh` instead.
+4. Run `bash scripts/check-env-var-docs.sh`, and add an image test for the new behavior.
-### Variations
-| Variation | Web Server | Process Manager | S6 Overlay |
-|-----------|-----------|----------------|------------|
-| cli | None | None | No |
-| fpm | None | PHP-FPM | No |
-| fpm-apache | Apache | PHP-FPM | Yes |
-| fpm-nginx | NGINX | PHP-FPM | Yes |
-| frankenphp | Caddy (FrankenPHP) | Built-in | No |
+Helper commands in `/usr/local/bin` work the same way with `docs/content/data/commands.yml` and `scripts/check-command-docs.sh`. Docs pages link to the reference instead of repeating a variable's description. `docs/AGENTS.md` covers when a page may show a default.
-## Building Locally
-There is a helper script in the `scripts/` directory that will build the image locally. If you attempt to build the image and Docker is not running, tell the user to start Docker Desktop or ensure that the Docker daemon is running before trying again.
+## CI rules
-```sh
-# Requires: Docker with buildx, yq (for fpm-nginx NGINX version resolution)
-scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm
-
-# Other examples
-scripts/dev.sh --variation cli --version 8.5 --os alpine3.22
-scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm --no-cache
-scripts/dev.sh --variation frankenphp --version 8.5 --os bookworm --push
-```
+- **Publishing is all or nothing**, and users pull exactly the digest that was tested. Don't add a path that publishes an untested image.
+- **Don't raise `MAX_PARALLEL`** in `service_publish-images.yml`. GHCR allows 2,000 requests a minute per token and `depot push` doesn't retry a 429.
+- **Don't rename or move `service_publish-images.yml`.** Users verify signatures against that path (`docs/content/docs/8.security/2.verifying-images.md`). Manual PR image runs pass `sign: false`.
+- **Keep the scanner isolated.** In `service_scan-images.yml`, only the `alerts` job has `security-events: write`, and it never runs Grype. The scan job has no token or registry credentials.
+- **`scheduled-task_scorecard.yml`** only publishes while it follows Scorecard's rules: no workflow-level `env`, `defaults`, or write permissions, and only Scorecard's approved actions.
+- **New linters go in `scripts/lint.sh`**, with a job in `service_lint.yml` that runs them. Project decisions for zizmor and hadolint are in `.github/zizmor.yml` and `.hadolint.yaml`. Actions are pinned to a full commit SHA with the version in a comment.
+- `trigger_auto-retry-failed-builds.yml` finds runs by workflow file, because `workflow_run.name` is the run title.
+- Fork PRs build on the official `php:` images, so they never exercise changes to how PHP is compiled.
-## PHP Version Pipeline
+## Tests
-PHP versions are NOT hardcoded. The pipeline works like this:
-1. `scripts/get-php-versions.sh` fetches the latest active PHP releases from `https://www.php.net/releases/active.php`
-2. It validates each version actually exists on DockerHub (with automatic fallback to previous patch if not yet published)
-3. The fetched versions are merged with the base config (`scripts/conf/php-versions-base-config.yml`) which defines OS bases, variations, and NGINX versions
-4. The merged result is written to `scripts/conf/php-versions.yml` -- this is the source of truth for CI builds
-5. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON
+- **Image tests:** `scripts/test-image.sh [php-version]` runs `scripts/tests/image/`, one file per feature area, one `test_*` function per test, helpers in `lib.sh`, mounted files in `fixtures/`. Add tests to the matching area file (or a new file), never to `test-image.sh`. Keep tests generic across variations with `requires` (`healthcheck`, `web_server`, `https`, `frankenphp`, `fpm`). `TEST_ONLY=` runs one area.
+- **`scripts/build-summary.sh`** is tested by `scripts/tests/run.sh`; add an assertion with new behavior. Write jq with explicit parentheses (`a + b as $x | ...` parses differently across jq versions, and runners ship an older jq).
-When modifying the version pipeline, the base config (`php-versions-base-config.yml`) is the file you edit. Never edit `php-versions.yml` directly -- it's generated.
+## OSS Scanner drift
-## CI/CD
+`.oss-scanner/Dockerfile` mirrors the variation Dockerfiles, and nothing fails when they drift. Update it in the same change as:
-- Builds run via GitHub Actions using **Depot** (`depot/build-push-action`) for multi-arch (`linux/amd64` + `linux/arm64/v8`). Depot builds both architectures natively on its own builders; the GitHub runner only orchestrates, so runner size and architecture do not affect build speed.
-- Publishing is three phases, each a reusable workflow the callers (`action_publish-images-*.yml`) chain: `service_setup-matrix.yml` generates the matrix once; `service_build-images.yml` builds one variation and saves every image to the Depot Registry (nothing public yet), recording the Depot build ID and digest; `service_test-images.yml` pulls the saved images on native amd64 and arm64 Depot runners (Depot sponsors the project, so they are free here and have no concurrency cap), one job per PHP version and base OS so the variations that share base layers share one pull, and runs `scripts/test-image.sh` against each; `service_publish-images.yml` promotes the tested builds with `depot push`, which copies the saved image to every Docker Hub and GHCR tag server-side (no image bytes touch the runner), one job per variation, and fails if the published digest is not the tested one. GHCR allows 2,000 requests a minute per token and `depot push` does not retry a 429, so the publish job pushes only two images at a time and retries a failed push with a growing pause; raising `MAX_PARALLEL` gets every variation's job rate limited at once. `service_report.yml` renders the run table last. Jobs render as `build cli / 8.4.25-bookworm`, `test / 8.4.25-bookworm arm64`, `publish / cli`.
-- A broken image can never reach a public tag: what users pull is byte-for-byte what the tests ran. Publishing is all or nothing: if any image fails to build or fails its test, no image from that run is published, including on the weekly rebuilds. Saved images live in the Depot Registry until the project's retention setting removes them.
-- The build matrix is generated from the PHP version pipeline described above.
-- Image tags follow the pattern: `serversideup/php:{version}-{variation}` (Debian default) or `serversideup/php:{version}-{variation}-{os}` (Alpine/specific OS).
-- Depot authentication: the project ID lives in `depot.json` (not a secret). Same-repo runs authenticate through a Depot OIDC trust relationship (`id-token: write`). Pull requests from forks have no OIDC token, so Depot falls back to its open-source pull request flow (https://depot.dev/blog/github-actions-oss-fork-builds): the full matrix builds on ephemeral builders without the project cache, nothing is saved, and each build job loads its amd64 image onto the runner and runs `scripts/test-image.sh` there instead, so the build check still means a tested image. A maintainer publishes a fork's images to `serversideup/php-dev`, and gets the arm64 tests, by running the "Docker Publish (PR Images)" workflow manually with the PR number.
-- The CI helper script `scripts/build-summary.sh` is checked by `scripts/tests/run.sh`, a single file of plain assertions over sample images. Run it after changing the script and add an assertion when you add behavior. Keep jq programs explicit with parentheses: `a + b as $x | ...` parses differently across jq versions, and runners ship an older jq than most laptops.
-- The project is enrolled in Anthropic's OSS Scanner (`projects/serversideup-php` in anthropics/oss-scanner), which builds `.oss-scanner/Dockerfile` from `main` and audits it offline. That Dockerfile copies `src/` over each published web server image with the same `COPY` lines as `src/variations/*/Dockerfile`, and `.oss-scanner/run-variation` starts each one the way `docker run` would. Nothing fails when these drift, so update them in the same change as:
- - a variation's `COPY` layout or post-copy permission step: the matching overlay and runtime stages in `.oss-scanner/Dockerfile`
- - a new or removed web server variation: its stages in `.oss-scanner/Dockerfile`, and its default command in `run-variation` if it is not `/init`
- - a new default PHP version: `PHP_VERSION` in `.oss-scanner/Dockerfile`
- - a new way for untrusted input to reach the image (a web server location, a healthcheck, a workflow trigger), a change to `SECURITY.md` scope, or a new intentional default the scanner should not report: `.oss-scanner/threat_model.md`
+- a variation's `COPY` layout or post-copy permission step: the matching stages in `.oss-scanner/Dockerfile`
+- a new or removed web server variation: its stages, and its command in `.oss-scanner/run-variation` if it isn't `/init`
+- a new default PHP version: `PHP_VERSION` in `.oss-scanner/Dockerfile`
+- a new way for untrusted input to reach the image, a `SECURITY.md` scope change, or a new intentional default: `.oss-scanner/threat_model.md`
- After changing any of these, build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, then confirm the affected variations start with no network: `docker run --rm -it --privileged --network none --user root oss-scanner bash`, then `.oss-scanner/run-variation &` and `curl localhost:8080/healthcheck`.
-- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/Dockerfile` and `.oss-scanner/Dockerfile` with project decisions recorded in `.hadolint.yaml`, ShellCheck at warning severity for `scripts/*.sh`, `src/common/usr/local/bin/*`, `src/s6/usr/local/bin/*`, `.oss-scanner/run-variation`, and `entrypoint.d/*.sh`) before any image builds. New linters go there. The local commands are in the contributing docs.
-- `scripts/test-image.sh` checks that an image starts, runs as an unprivileged user, has no world-writable files or directories (outside sticky ones like `/tmp`), reports the expected PHP version, loads the default extensions, reaches a healthy HEALTHCHECK, and (for images with a web server) serves a mounted `index.php` through NGINX, Apache, or Caddy. Run it locally against any image before opening a PR that touches startup behavior. Keep the checks generic across variations: it detects the web server from the image's `*_HTTP_PORT` environment variables rather than the image name.
-- Every build job records its image details (tags, Depot Registry reference, build ID, digest) as an `image-details-*` artifact; the publish job adds a `.published.json` record with the compressed size per architecture. `service_report.yml` merges them with `scripts/build-summary.sh` (the published record wins) into one table on the run summary and in the PR comment.
-- `fail-fast` is off, so one failed image never cancels the others. `trigger_auto-retry-failed-builds.yml` re-runs the failed jobs of a production, beta, or PR run once when only a few jobs failed and no newer run exists. If that also fails, or too many jobs failed to look transient, it opens a `ci-failure` issue assigned to the maintainer with the failed jobs, their first error annotation, the log tail, and a link to re-run. Only production and beta runs open issues; PR runs have the PR comment. GitHub's own failure emails do not cover this case reliably: scheduled runs notify whoever last edited the cron line, and re-run attempts are triggered by the Actions bot.
+Then build it with `docker build -f .oss-scanner/Dockerfile -t oss-scanner .`, run `docker run --rm -it --privileged --network none --user root oss-scanner bash`, start `.oss-scanner/run-variation &`, and check `curl localhost:8080/healthcheck`.
-## Verification
+## Build and verify
-There is no automated test suite for image logic. To verify changes:
-1. Build the affected variation locally with `scripts/dev.sh`
-2. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh`
-3. For entrypoint script changes, test on both Debian and Alpine builds
-4. Run `shellcheck` on any modified shell scripts when available
+Requires Docker with buildx, and `yq`. If Docker isn't running, ask the user to start it.
-## Key Design Decisions
-
-- **Unprivileged by default**: Images run as `www-data`, not root. Web servers listen on `8080`/`8443` (unprivileged ports).
-- **Lightweight images**: Only install dependencies that are truly necessary. See `docs/content/docs/1.getting-started/6.default-configurations.md` for what's included and why. When adding packages or extensions, justify the inclusion and keep image size minimal.
-- **Environment-variable-driven**: All PHP/FPM/web server configuration is controlled via env vars -- no config file editing at runtime.
-- **S6 Overlay** manages multiple processes in web server variations (FPM + web server).
-- **Laravel automations** (migrations, caching, etc.) are opt-in via `AUTORUN_ENABLED`.
-- **SSL support** is built-in with `SSL_MODE` (off/full) and self-signed cert generation.
-- **One Dockerfile per variation**: OS-specific logic belongs in helper scripts, not Dockerfile conditionals or duplicate files.
-
-## Documentation
-
-The documentation and marketing site lives in `docs/` and has its own `docs/AGENTS.md` with guidelines specific to the Nuxt 4 content site. When working in `docs/`, follow that file instead of this one.
-
-## Reference
+```sh
+scripts/dev.sh --variation fpm-nginx --version 8.5 --os bookworm
+scripts/dev.sh --variation cli --version 8.5 --os alpine3.24 --no-cache
+bash scripts/test-image.sh serversideup/php:8.5-fpm-nginx-bookworm
+```
-When you need details beyond what's in this file, read these local sources rather than guessing:
+Pick `--os` from that minor's `base_os` list in the base config.
-- **Environment variables** (the canonical reference for ALL env vars, their defaults, and which variations they apply to): `docs/content/docs/8.reference/1.environment-variable-specification.md`
-- **Default configurations** (what packages, extensions, and settings ship with each image): `docs/content/docs/1.getting-started/6.default-configurations.md`
-- **All documentation**: `docs/content/docs/` contains the full docs in markdown. Browse this directory for guides on image variations, framework integrations, deployment, customization, and troubleshooting. There is also a dedicated `docs/AGENTS.md` file for the documentation site itself
-- **LLM-optimized docs** (for AI tools that can fetch URLs): https://serversideup.net/open-source/docker-php/llms.txt and https://serversideup.net/open-source/docker-php/llms-full.txt to view the latest stable versions of the documentation.
+1. Build the affected variation and run `scripts/test-image.sh` against it. Add a test for new behavior.
+2. Test entrypoint changes on both a Debian and an Alpine build.
+3. Run `bash scripts/lint.sh`, the same checks CI runs before it builds anything.
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 120000
index 47dc3e3d8..000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..43c994c2d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/README.md b/README.md
index a1594ef33..5cba4c6ea 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
+
@@ -13,7 +14,7 @@
## Introduction
Production-ready PHP Docker images built on official PHP. Optimized for Laravel, WordPress, and modern PHP applications.
-### What Makes These Images Different?
+### What makes these images different
**serversideup/php** takes the official PHP Docker images and adds everything you need for real-world production use:
@@ -36,22 +37,22 @@ Production-ready PHP Docker images built on official PHP. Optimized for Laravel,
-## Getting Started
+## Getting started
-### Try it in 2 minutes ⚡
+### Try it in two minutes
-Want to see how easy it is? Our installation guide walks you through creating your first PHP app with Docker:
+The [quick start guide](https://serversideup.net/open-source/docker-php/docs/getting-started/installation) walks you through your first PHP app with Docker:
1. ✅ Run `phpinfo()` in your browser
-2. ✅ Upgrade PHP versions by changing one line
-3. ✅ Switch between variations (FPM, FrankenPHP, etc.)
-4. ✅ See environment variables in action
+2. ✅ Change PHP settings with environment variables
+3. ✅ Upgrade or downgrade PHP by changing one line
+4. ✅ Switch variations (FPM, FrankenPHP, and more)
-**[👉 Follow the quick start guide](https://serversideup.net/open-source/docker-php/docs/getting-started/installation)**
+**[Follow the quick start guide →](https://serversideup.net/open-source/docker-php/docs/getting-started/installation)**
-### Quick Example
+### Quick example
-Here's what a complete Laravel setup with NGINX + PHP 8.5 looks like:
+A Laravel app with NGINX and PHP 8.5, configured with environment variables:
```yml
services:
@@ -60,21 +61,14 @@ services:
ports:
- "80:8080"
environment:
- # Customize PHP with environment variables
- PHP_OPCACHE_ENABLE: "1"
PHP_MEMORY_LIMIT: "512M"
-
- # Laravel automations (migrations, storage link, etc.)
+ # Run migrations, storage:link, and optimize on start
AUTORUN_ENABLED: "true"
volumes:
- .:/var/www/html
```
-That's it. No complex configs. Just environment variables.
-
-**Ready to try it?** [Get started with our tutorial →](https://serversideup.net/open-source/docker-php/docs/getting-started/installation)
-
-## Available Image Variations
+## Image variations
Choose the variation that fits your needs. All images follow the pattern:
@@ -82,7 +76,7 @@ Choose the variation that fits your needs. All images follow the pattern:
serversideup/php:{{version}}-{{variation-name}}
```
-### Popular Variations
+### Popular variations
| Variation | Best For | Example |
|-----------|----------|---------|
@@ -92,12 +86,12 @@ serversideup/php:{{version}}-{{variation-name}}
| **fpm-nginx** | Stable and performant web server | `serversideup/php:8.5-fpm-nginx` |
| **frankenphp** | Modern, high-performance apps with worker mode | `serversideup/php:8.5-frankenphp` |
-### Supported PHP Versions & Platforms
+### Supported PHP versions and platforms
> [!NOTE]
> All images are available on [**Docker Hub**](https://hub.docker.com/r/serversideup/php/) and [**GitHub Packages**](https://github.com/serversideup/docker-php/pkgs/container/php).
-We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images. PHP 8.1 is end of life but still built while its base operating systems receive security updates. See our [security policy](SECURITY.md#component-lifecycle-references) for the rule.
+We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images. PHP 8.1 is end of life but still built while its base operating systems receive security updates. See our [security policy](SECURITY.md#component-lifecycle-references) for the rule. PHP 8.6 release candidates are available for testing as `8.6-rc` tags, such as `serversideup/php:8.6-rc-fpm-nginx`. Every rebuild uses the newest release candidate on php.net.
[Learn More About Choosing an Image →](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image)
@@ -109,9 +103,9 @@ We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base imag
| fpm-apache | **Debian Based** [](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-apache&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-apache&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-apache&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-apache&page=1&ordering=-name) |
| fpm-nginx | **Debian Based** [](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-nginx&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-nginx&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-nginx&page=1&ordering=-name) **Alpine Based** [](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-nginx-alpine&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-nginx-alpine&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-nginx-alpine&page=1&ordering=-name) |
| frankenphp | **Debian Based** [](https://hub.docker.com/r/serversideup/php/tags?name=8.5-frankenphp&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.4-frankenphp&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.3-frankenphp&page=1&ordering=-name) **Alpine Based** [](https://hub.docker.com/r/serversideup/php/tags?name=8.5-frankenphp-alpine&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.4-frankenphp-alpine&page=1&ordering=-name) [](https://hub.docker.com/r/serversideup/php/tags?name=8.3-frankenphp-alpine&page=1&ordering=-name) |
-| unit (deprecated) | ⚠️ NGINX is no longer maintaining NGINX Unit. We stopped providing updates for this image. [Learn more →](https://serversideup.net/open-source/docker-php/docs/image-variations/unit) |
+| unit (removed) | ⚠️ NGINX no longer maintains NGINX Unit, so this variation is no longer built. [Learn more →](https://serversideup.net/open-source/docker-php/docs/image-variations/unit) |
-## Professional Support
+## Professional support
Need help integrating Docker with your PHP application?
- **[Managed Hosting](https://serversideup.net/hire-us/)** - CI/CD design, managed hosting, guaranteed uptime
@@ -120,13 +114,13 @@ Need help integrating Docker with your PHP application?
## Resources
- **[Website](https://serversideup.net/open-source/docker-php/)** overview of the product.
-- **[Docs](https://serversideup.net/open-source/docker-php/docs)** for a deep-dive on how to use the product.
+- **[Docs](https://serversideup.net/open-source/docker-php/docs)** for setup guides and the full reference.
- **[Discord](https://serversideup.net/discord)** for friendly support from the community and the team.
- **[GitHub](https://github.com/serversideup/docker-php)** for source code, bug reports, and project management.
- **[Get Professional Help](https://serversideup.net/professional-support)** - Get video + screen-sharing help directly from the core contributors.
## Contributing
-As an open-source project, we strive for transparency and collaboration in our development process. We greatly appreciate any contributions members of our community can provide. Whether you're fixing bugs, proposing features, improving documentation, or spreading awareness - your involvement strengthens the project. Please review our [contribution guidelines](https://serversideup.net/open-source/docker-php/docs/getting-started/contributing) and [code of conduct](./.github/code_of_conduct.md) to understand how we work together respectfully.
+Bug fixes, features, and docs improvements are all welcome. Read the [contribution guide](https://serversideup.net/open-source/docker-php/docs/getting-started/contributing) and [code of conduct](./.github/code_of_conduct.md) before opening a pull request.
- **Bug Report**: If you're experiencing an issue while using these images, please [create an issue](https://github.com/serversideup/docker-php/issues/new/choose).
- **Feature Request**: Make this project better by [submitting a feature request](https://github.com/serversideup/docker-php/discussions/66).
diff --git a/SECURITY.md b/SECURITY.md
index 429f3e4b9..21a748668 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -19,7 +19,7 @@ Vulnerabilities in what *we* author and ship:
- Entrypoint scripts, healthchecks, helper scripts, and S6 service definitions in `src/`
- Default configs we ship for PHP-FPM, NGINX, Apache, and FrankenPHP
- Insecure defaults: ports, file permissions, the `www-data` user model, SSL defaults
-- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing)
+- Build pipeline integrity (GitHub Actions workflows, tag assembly, image publishing, image signing)
- Documentation that materially misleads users about a security-relevant setting
## What is out of scope — report to the upstream project
@@ -34,7 +34,13 @@ Vulnerabilities in software we package but do not maintain belong with the upstr
| Composer | [composer/composer on GitHub](https://github.com/composer/composer/security) |
| Debian / Alpine base packages | The respective distribution security teams |
-If an upstream CVE is already publicly disclosed, you don't need to file with us — we pick up upstream patches on our **weekly rebuilds (Tuesday 08:00 UTC)** for floating tags. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide#how-our-releases-work) for the full cadence and which tags receive rebuilds.
+If an upstream CVE is already publicly disclosed, you don't need to file with us. Floating tags pick up upstream fixes on their own: a new PHP patch publishes within about an hour of php.net listing it, and operating system packages update on the **weekly rebuild (Tuesday 08:00 UTC)**. See [How our releases work](https://serversideup.net/open-source/docker-php/docs/upgrading/upgrade-guide#when-images-are-rebuilt) for the full cadence and which tags receive rebuilds.
+
+## Verifying images and public scan results
+
+Images we publish are signed with cosign and carry an SBOM and build provenance. Images published before signing was added have provenance only. [Verifying images](https://serversideup.net/open-source/docker-php/docs/security/verifying-images) shows how to check them.
+
+Each production build ends with a vulnerability scan of every published image. The results are in the run summary of each [production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml), and [Vulnerability scanning](https://serversideup.net/open-source/docker-php/docs/security/vulnerability-scanning) explains how to read them. Scanner findings are already disclosed upstream CVEs. Anything that is not yet public, including a problem in what we author, still goes through [private reporting](#reporting-a-vulnerability).
## Component lifecycle references
@@ -51,10 +57,10 @@ Our images bundle third-party software, each with its own support window. Before
We publish images for end-of-life PHP versions so legacy applications have a path into containers, but only while two things remain true:
-1. The official `php` image for that version still exists on Docker Hub, so we have a base to build from.
+1. The official docker-library/php Dockerfile for that version exists at some commit, so we can still compile it the same way the official images did.
2. The operating system release underneath still receives security updates from its distribution. Debian LTS counts. Once a release passes its end of life, a rebuild cannot deliver any patches, and Debian removes the packages from its mirrors soon after, so the build fails anyway.
-When either condition stops holding, we stop rebuilding that image and its existing tags freeze at their last successful build. We do not pin archived or snapshot package repositories to keep an EOL base building: that produces a fresh tag with the same unpatched packages, which is worse than an honest frozen tag. Use an EOL base as a stepping stone, not a destination. See [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#operating-systems) for the trade-off.
+When either condition stops holding, we stop rebuilding that image and its existing tags freeze at their last successful build. We do not pin archived or snapshot package repositories to keep an EOL base building: that produces a fresh tag with the same unpatched packages, which is worse than an honest frozen tag. Use an EOL base as a stepping stone, not a destination. See [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#pick-debian-or-alpine) for the trade-off.
## EOL versions and the legacy-modernization path
@@ -67,12 +73,12 @@ The following images are no longer built. Their existing tags remain pullable on
| Anything on Debian Bullseye | 2026-09-03 | Debian 11 LTS ended on 2026-08-31. The following week Debian removed the Bullseye packages from `deb.debian.org` and `security.debian.org`, so `apt-get install` fails inside the build. |
| Anything on Alpine 3.16 | 2026-09-03 | Alpine 3.16 reached end of life on 2024-05-23 and has received no security updates since. It was only ever used by PHP 7.4 and 8.0. |
-PHP 8.1 is also end of life, but it is still built. Its official base images exist, and Debian Bookworm, Debian Trixie, and Alpine 3.22 still receive security updates, so a weekly rebuild delivers real operating system patches. It stops when those bases do.
+PHP 8.1 is end of life and still built. We compile it from the last docker-library/php commit that included it. Debian Bookworm, Debian Trixie, and Alpine 3.22 still receive security updates, so a weekly rebuild delivers real operating system patches. It stops when those bases do.
-If you are running one of the frozen images, treat it as a migration deadline rather than a stable base. Move to a supported PHP version and base from [Choosing an image](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image). The [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide) covers moving between our releases. If you cannot move yet and need to install packages on a frozen Bullseye tag, the [major version migration guide](https://serversideup.net/open-source/docker-php/docs/guide/major-version-migrations#installing-packages-on-a-frozen-bullseye-image) shows how to do it in your own Dockerfile.
+If you are running one of the frozen images, treat it as a migration deadline rather than a stable base. Move to a supported PHP version and base from [Choosing an image](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image). The [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/upgrading/upgrade-guide) covers moving between our releases. If you cannot move yet and need to install packages on a frozen Bullseye tag, the [upgrade guide](https://serversideup.net/open-source/docker-php/docs/upgrading/upgrade-guide#installing-packages-on-a-frozen-bullseye-image) shows how to do it in your own Dockerfile.
## How updates flow
-For the full release model, how floating vs. version-pinned tags behave, and how to apply your own patches to a pinned image, see the [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide).
+For the full release model, how floating vs. version-pinned tags behave, and how to apply your own patches to a pinned image, see the [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/upgrading/upgrade-guide).
-For the EOL trade-off when picking an operating system base, see [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#operating-systems).
+For the EOL trade-off when picking an operating system base, see [Choosing an image — Operating Systems](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image#pick-debian-or-alpine).
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index cf969c43c..75f004b58 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -1,187 +1,63 @@
-# AI Agent Guidelines for Documentation
+# Documentation guidelines
-You are a highly skilled technical writer and PHP system administrator. You are an expert at breaking down complex technical concepts into easy to understand language. You also carry a significant experience in structuring open source documentation to make it easy for others to understand, modify, and contribute to the project.
+The docs and marketing site for `serversideup/php`. It is a static Nuxt 4 site built with Nuxt Content and Nuxt UI. Pages are Markdown in `content/docs/`; the home page is `content/index.md`.
-## Project Context
-
-This is the documentation site for **serversideup/php** - production-ready Docker images optimized for PHP applications (especially Laravel). Looking at the project root, you'll see the following directories:
-```
-docs/ # Documentation and Marketing site
-scripts/ # Scripts used for image building and deployment
-src/ # Source code for the PHP Docker images
-```
-### Key Technologies in the docs/ directory
-- **Nuxt 4** - Vue-based static site generator
-- **Nuxt Content** - File-based CMS for markdown documentation
-- **Nuxt UI** - Component library for the Nuxt 4 application
-- **TailwindCSS** - Utility-first CSS framework
-
-## Documentation Structure
-
-The documentation site (located in the docs/ directory) follows this organization:
-
-```
-app/ # Nuxt app configuration
-content/ # Markdown documentation
-public/ # Static assets
-server/ # Server-side routes for the Nuxt application
-```
-
-Important note: This application is 100% static and does not require a database or server-side rendering. It is a simple Nuxt 4 application that uses the Nuxt Content module to build the documentation site. The site is then deployed to a static hosting provider like CloudFlare Pages.
-
-
-## Writing Guidelines
-
-### 1. **Tone and Voice**
-- Use clear, conversational language that's professional but approachable
-- Write for developers of varying skill levels - beginners to advanced
-- Avoid jargon when possible; when technical terms are necessary, explain them
-- Use active voice and second person ("you" instead of "one" or "the user")
-- Be friendly and approachable, but not too casual.
-
-### 2. **Content Structure**
-- Start with the "why" before the "how"
-- Use clear, descriptive headings that follow a logical hierarchy
-- Include practical examples that users can copy and run
-- Add callouts (notes, warnings, tips) for important information
-- Break up long sections with subheadings, lists, and code blocks
-
-### 3. **Code Examples**
-- Always test code examples to ensure they work
-- Include comments in complex examples
-- Show realistic, production-ready examples when possible
-- Specify language syntax highlighting in code blocks
-- For Docker examples, use the actual image tags available in the project
-
-### 4. **Markdown Conventions**
-- Use ATX-style headers (# ## ###) not underline style
-- Use fenced code blocks with language identifiers
-- Use relative links for internal documentation
-- Use absolute URLs for external resources
-- Include alt text for all images
-
-### 5. **Docker-Specific Guidelines**
-When documenting Docker concepts:
-- Show both Docker CLI and Docker Compose examples
-- Explain what environment variables do and their default values
-- Include health check examples
-- Demonstrate volume mounts with real use cases
-- Always specify image tags (never use `:latest`)
-
-### 6. **Laravel-Specific Guidelines**
-When documenting Laravel features:
-- Reference official Laravel documentation when appropriate
-- Show examples using Laravel conventions (Artisan, config, .env)
-- Explain automations that the images provide for Laravel
-- Document queue, schedule, and Horizon workers properly
-
-## Content Review Checklist
-
-Before considering documentation complete, verify:
-
-- [ ] All code examples are tested and working
-- [ ] External links are valid and not broken
-- [ ] Spelling and grammar are correct
-- [ ] Headings follow logical hierarchy (H1 → H2 → H3)
-- [ ] Code blocks have appropriate syntax highlighting
-- [ ] Complex concepts include examples or diagrams
-- [ ] Callouts (notes/warnings) are used appropriately
-- [ ] Cross-references to other docs use relative links
-- [ ] Docker image versions match what's actually available
-- [ ] Content is accurate to the current version
-
-## Common Patterns
-
-### Callout Boxes
-Use Markdown callouts for important information:
-```markdown
-::note
-Here's some additional information.
-::
-
-::tip
-Here's a helpful suggestion.
-::
-
-::warning
-Be careful with this action as it might have unexpected results.
-::
-
-::caution
-This action cannot be undone.
-::
-```
-
-### Code Blocks
-Use code blocks to display multi-line code snippets with syntax highlighting. Code blocks are essential for presenting code examples clearly. When writing a code-block, you can specify a filename that will be displayed on top of the code block. An icon will be automatically displayed based on the extension or the name. Filenames help users understand the code's location and purpose within a project. To highlight lines of code, add {} around the line numbers you want to highlight. Line highlighting is useful for focusing users on important parts of code examples.
-
-```markdown
-Here's how to configure PHP-FPM with custom settings:
-
-\`\`\`ts [nuxt.config.ts]{4-5}
-export default defineAppConfig({
- ui: {
- icons: {
- copy: 'i-lucide-copy',
- copyCheck: 'i-lucide-copy-check'
- }
- }
-})
-\`\`\`
+```bash
+yarn install
+yarn dev # http://localhost:3000
+yarn generate # static build; fails on dead internal links
```
-## File Naming Conventions
-
-- Use numbered prefixes for ordered content: `1.index.md`, `2.installation.md`
-- Use kebab-case for file names: `these-images-vs-others.md`
-- Keep file names concise but descriptive
-- Match file names to the primary H1 heading (URL-friendly version)
+## Facts come from the code
-## When to Ask Questions
+Every option name, default, path, and port must match `src/` and `scripts/conf/php-versions-base-config.yml`. Check the source before writing, never memory or another docs page.
-Don't guess or assume when:
-- Technical accuracy is in question (Docker config, PHP settings, etc.)
-- Breaking changes affect existing documentation
-- New features need to be documented but requirements are unclear
-- Examples might not work across different OS or environments
+Each fact has one home. Other pages link to it instead of repeating it:
-## Helpful Resources
+| Fact | Page |
+|---|---|
+| Environment variables, defaults, and which variations use them | `content/data/env-vars.yml`, rendered on `9.reference/1.environment-variable-specification.md`. `scripts/check-env-var-docs.sh` fails CI when it drifts from `src/`. |
+| Helper commands, their usage, and which variations ship them | `content/data/commands.yml`, rendered on `9.reference/2.command-reference.md`. `scripts/check-command-docs.sh` fails CI when it drifts from `src/`. |
+| Variations, PHP versions, operating systems, and tags | `1.getting-started/5.choosing-an-image.md` |
+| Floating and pinned tags, release model | `6.upgrading/1.upgrade-guide.md` |
+| SSL modes and certificates | `4.deployment-and-production/2.configuring-ssl.md` |
+| The web server health check and `HEALTHCHECK_PATH` | `4.deployment-and-production/3.health-checks.md` |
+| Laravel worker health check scripts | `3.framework-guides/1.laravel/3.health-checks.md` |
+| Worker stop signals and the `AUTORUN_ENABLED` warning | `3.framework-guides/1.laravel/2.workers.md` |
+| Entrypoint script order | `5.customizing-the-image/3.adding-your-own-start-up-scripts.md` |
+| OPcache and PHP-FPM tuning | `4.deployment-and-production/5.production-performance-tuning.md` |
+| How images are built and published | `8.security/1.how-images-are-built.md` |
-- [Official Nuxt Content Documentation](https://content.nuxt.com/)
-- [Nuxt UI Docs Template](https://docs-template.nuxt.dev/)
-- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
-- [Laravel Documentation](https://laravel.com/docs)
-- [serversideup/php GitHub Repository](https://github.com/serversideup/docker-php)
-- [Write the Docs Style Guide](https://www.writethedocs.org/guide/writing/style-guides/)
+A page may repeat a variable's default in a table that has `Variable` and `Default` columns. Start the cell with `` `value` ``, `` `""` `` for empty, or `Unset`. A short note may follow, such as `` `ondemand`, or `dynamic` in `fpm` ``. `scripts/check-env-var-docs.sh` checks those tables against `env-vars.yml`. It doesn't check defaults mentioned in prose, so mention one only when the sentence depends on it. Upgrade pages are the exception: they say what a default used to be and what it changed to.
-## Component Usage
+## Writing style
-This Nuxt docs site has custom Vue components. Familiarize yourself with:
-- `` - Project logo
-- `` - Status badges
-- `` - Video embeds
-- `` - Navigation
-- `` - Template selection
+- Lead with the point. The first sentence says what the page is for or what the reader gets. One or two sentences of why, then the how.
+- One job per page. If the title needs an "and", it is two pages.
+- Second person, active voice, imperative steps. Paragraphs of one to four sentences.
+- Headings in sentence case that say something: "Custom NGINX rules go in `server-opts.d`", not "NGINX Configuration".
+- Tables for options and comparisons, numbered lists or `::steps` for ordered steps. Emoji only where they help a table scan (✅ ❌).
+- Docs, not marketing. No unverified numbers, no sales copy, no "seamlessly", "powerful", "simply", "just works".
+- Some pages persuade as well as inform, because they decide whether someone switches: `content/index.md`, every page in `1.getting-started/` except Installation, Container basics, and Contributing, plus Laravel Automations, Laravel Health checks, and How images are built. Write them like friendly marketing copy: the reader's problem first, headings that make a point, and one fact per sentence. Every claim still needs a source in the code or from the maintainers.
+- US English. Few em dashes.
-Check `app/components/` directory for available components before creating new ones.
+## Examples
-## Testing Changes Locally
-
-To test documentation changes:
-```bash
-cd docs/
-yarn install
-yarn dev
-```
+- Use Compose by default. Add a `docker run` tab with `::code-group` only when it is shorter or the page is about the CLI.
+- Use `8.5` tags (`serversideup/php:8.5-fpm-nginx`). Never `latest` or an old patch tag.
+- Containers listen on `8080` and `8443`; `fpm` listens on `9000`. Map host ports explicitly (`80:8080`).
+- Every code block has a language and, where it is a file, a filename: ` ```yaml [compose.yml] `.
+- An example that can't be tested by reading the source gets run against a built image (`scripts/dev.sh`).
-Browse to http://localhost:3000 to preview changes.
+## Components
-## Remember
+- Callouts: `::note`, `::tip`, `::warning` (will break), `::caution` (data loss or security). At most one or two per section.
+- `::lead-p` for a page's opening paragraph, `::steps{level="4"}`, `::code-group`, `::code-tree{defaultValue="compose.yml"}`.
+- Other components live in `app/components/`; check there before inventing markup.
-- **Users first**: Always consider what the reader needs to accomplish
-- **Clarity over cleverness**: Simple, clear language beats fancy technical writing
-- **Examples matter**: Show, don't just tell
-- **Accuracy is critical**: Wrong documentation is worse than no documentation
-- **Open source mindset**: Make it easy for others to contribute and improve
+## Files and links
-Your goal is to help users succeed with these Docker images quickly and confidently.
\ No newline at end of file
+- A number prefix sets the order and is dropped from the URL: `1.getting-started/3.installation.md` is `/docs/getting-started/installation`.
+- Every page has `title` and a one-sentence `description` in frontmatter.
+- Link to other pages by URL path without prefixes. Full URLs for external links.
+- When a page moves or is removed, add its old path to `routeRules` in `nuxt.config.ts` and to `nitro.prerender.routes` so the redirect is generated.
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
deleted file mode 120000
index 47dc3e3d8..000000000
--- a/docs/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-AGENTS.md
\ No newline at end of file
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
new file mode 100644
index 000000000..43c994c2d
--- /dev/null
+++ b/docs/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts
index 4646d4997..414f63ed1 100644
--- a/docs/app/app.config.ts
+++ b/docs/app/app.config.ts
@@ -166,6 +166,12 @@ export default defineAppConfig({
to: 'https://github.com/serversideup/docker-php',
target: '_blank'
},
+ {
+ icon: 'i-lucide-scroll-text',
+ label: 'Release Notes',
+ to: 'https://github.com/serversideup/docker-php/releases',
+ target: '_blank'
+ },
{
icon: 'i-lucide-bell-ring',
label: 'Subscribe',
diff --git a/docs/app/assets/css/main.css b/docs/app/assets/css/main.css
index eb53465ec..b4386804a 100644
--- a/docs/app/assets/css/main.css
+++ b/docs/app/assets/css/main.css
@@ -32,19 +32,3 @@
:root {
--ui-container: var(--container-8xl);
}
-
-ul li a:after {
- margin-left: 6px;
-}
-
-ul ul ul {
- border: none !important;
-}
-
-/* ul ul ul li a:after {
- margin-left: -13px;
-}
-
-ul li ul li{
- padding-inline-start: 0;
-} */
\ No newline at end of file
diff --git a/docs/app/components/InlineMarkdown.vue b/docs/app/components/InlineMarkdown.vue
new file mode 100644
index 000000000..3c7ba06cc
--- /dev/null
+++ b/docs/app/components/InlineMarkdown.vue
@@ -0,0 +1,44 @@
+
+
+
+
+ {{ part.text }}
+
+ {{ part.text }}
+
+
+ {{ part.text }}
+
+
+
diff --git a/docs/app/components/LeadP.vue b/docs/app/components/LeadP.vue
index 286453e18..d07d6474b 100644
--- a/docs/app/components/LeadP.vue
+++ b/docs/app/components/LeadP.vue
@@ -1,7 +1,7 @@
-
+
+ Only takes effect when {{ variable.requires }}=true.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/app/components/content/YoutubeVideo.vue b/docs/app/components/content/YoutubeVideo.vue
new file mode 100644
index 000000000..7619e7c29
--- /dev/null
+++ b/docs/app/components/content/YoutubeVideo.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/docs/app/composables/useReferenceList.ts b/docs/app/composables/useReferenceList.ts
new file mode 100644
index 000000000..60eca4b48
--- /dev/null
+++ b/docs/app/composables/useReferenceList.ts
@@ -0,0 +1,86 @@
+import type { Ref } from 'vue'
+
+// Shared behavior for the expandable reference lists (environment variables, commands):
+// one open row mirrored in the URL hash, arrow-key movement between rows, copy buttons
+// that work on plain-HTTP previews, and scrolling that clears the sticky header and toolbar.
+export function useReferenceList(options: {
+ names: Ref
+ list: Ref
+ toolbar: Ref
+ searchInput: Ref<{ focus: () => void } | null>
+}) {
+ const open = ref('')
+ const stickyTop = ref(0)
+ const toolbarHeight = ref(0)
+ const rowOffset = computed(() => stickyTop.value + toolbarHeight.value + 8)
+
+ function anchor(name: string) {
+ return name.toLowerCase().replaceAll('_', '-')
+ }
+
+ function toggle(name: string) {
+ open.value = open.value === name ? '' : name
+ history.replaceState(history.state, '', open.value ? `#${anchor(open.value)}` : `${location.pathname}${location.search}`)
+ }
+
+ function rows() {
+ return [...(options.list.value?.querySelectorAll('[data-row]') ?? [])]
+ }
+
+ function focusRow(index: number) {
+ rows()[index]?.focus()
+ }
+
+ function moveFocus(event: KeyboardEvent, step: number) {
+ const all = rows()
+ const index = all.indexOf(event.currentTarget as HTMLButtonElement) + step
+ if (index < 0) return options.searchInput.value?.focus()
+ all[index]?.focus()
+ }
+
+ // The Clipboard API needs HTTPS, so fall back to execCommand on plain HTTP previews.
+ const { copy } = useClipboard({ legacy: true })
+ const copiedKey = ref('')
+
+ async function copyText(key: string, text: string) {
+ await copy(text)
+ copiedKey.value = key
+ setTimeout(() => {
+ if (copiedKey.value === key) copiedKey.value = ''
+ }, 2000)
+ }
+
+ function linkTo(name: string) {
+ return `${location.origin}${location.pathname}#${anchor(name)}`
+ }
+
+ async function openFromHash() {
+ const target = options.names.value.find(name => `#${anchor(name)}` === location.hash)
+ if (!target) return
+ open.value = target
+ await nextTick()
+ document.getElementById(anchor(target))?.scrollIntoView({ block: 'start' })
+ }
+
+ defineShortcuts({
+ '/': () => options.searchInput.value?.focus()
+ })
+
+ onMounted(async () => {
+ // The site header is sticky and its height changes with the banner, so measure it.
+ const header = document.querySelector('div.sticky.top-0')
+ const measure = () => {
+ stickyTop.value = header?.offsetHeight ?? 0
+ const element = options.toolbar.value
+ toolbarHeight.value = element && getComputedStyle(element).position === 'sticky' ? element.offsetHeight : 0
+ }
+ measure()
+ if (header) useResizeObserver(header, measure)
+ useResizeObserver(options.toolbar, measure)
+
+ await openFromHash()
+ useEventListener(window, 'hashchange', openFromHash)
+ })
+
+ return { open, stickyTop, rowOffset, anchor, toggle, focusRow, moveFocus, copiedKey, copyText, linkTo }
+}
diff --git a/docs/app/utils/reference-links.ts b/docs/app/utils/reference-links.ts
new file mode 100644
index 000000000..3225f4b50
--- /dev/null
+++ b/docs/app/utils/reference-links.ts
@@ -0,0 +1,20 @@
+const SOURCES: Record = {
+ 'www.php.net': 'PHP manual',
+ 'nginx.org': 'NGINX docs',
+ 'httpd.apache.org': 'Apache docs',
+ 'caddyserver.com': 'Caddy docs',
+ 'frankenphp.dev': 'FrankenPHP docs',
+ 'letsencrypt.org': 'Let\'s Encrypt',
+ 'mercure.rocks': 'Mercure docs',
+ 'github.com': 'GitHub'
+}
+
+export function isExternal(url: string) {
+ return /^https?:\/\//.test(url)
+}
+
+export function referenceSource(url: string) {
+ if (!isExternal(url)) return 'Guide'
+ const host = new URL(url).hostname
+ return SOURCES[host] ?? host
+}
diff --git a/docs/content.config.ts b/docs/content.config.ts
index e81cfdf98..82a9eb823 100644
--- a/docs/content.config.ts
+++ b/docs/content.config.ts
@@ -10,7 +10,7 @@ export default defineContentConfig({
type: 'page',
source: {
include: '**',
- exclude: ['index.md']
+ exclude: ['index.md', 'data/**']
},
schema: z.object({
redirect: z.string().optional(),
@@ -21,6 +21,52 @@ export default defineContentConfig({
target: z.string().optional()
})).optional()
})
+ }),
+ envVars: defineCollection({
+ type: 'data',
+ source: 'data/env-vars.yml',
+ schema: z.object({
+ variables: z.array(z.object({
+ name: z.string(),
+ default: z.string().nullable(),
+ variation_defaults: z.record(z.string(), z.string()).optional(),
+ variations: z.array(z.string()).optional(),
+ requires: z.string().optional(),
+ values: z.array(z.string()).optional(),
+ description: z.string(),
+ reference: z.object({
+ label: z.string(),
+ url: z.string()
+ }).optional()
+ }))
+ })
+ }),
+ commands: defineCollection({
+ type: 'data',
+ source: 'data/commands.yml',
+ schema: z.object({
+ commands: z.array(z.object({
+ name: z.string(),
+ summary: z.string(),
+ variations: z.array(z.string()).optional(),
+ root: z.boolean().optional(),
+ description: z.string().optional(),
+ usage: z.string().optional(),
+ options: z.array(z.object({
+ name: z.string(),
+ description: z.string()
+ })).optional(),
+ example: z.object({
+ language: z.string(),
+ filename: z.string(),
+ code: z.string()
+ }).optional(),
+ reference: z.object({
+ label: z.string(),
+ url: z.string()
+ }).optional()
+ }))
+ })
})
}
})
diff --git a/docs/content/data/commands.yml b/docs/content/data/commands.yml
new file mode 100644
index 000000000..acd96f475
--- /dev/null
+++ b/docs/content/data/commands.yml
@@ -0,0 +1,270 @@
+# Every helper command the images add to /usr/local/bin. The reference page at
+# /docs/reference/command-reference renders this file, and
+# scripts/check-command-docs.sh fails CI when it drifts from src/.
+#
+# Each command:
+# name The command
+# summary One line on what it does
+# variations Variations that ship it. Leave it out when every variation does
+# root true when it needs `USER root` in a Dockerfile
+# description More detail. Markdown: inline code and links only
+# usage The command's signature
+# options Flags and arguments, each with a name and description
+# example A working example: language, filename, and code
+# reference Optional link to further reading
+commands:
+ - name: artisan
+ summary: Run `php artisan` from any directory.
+ description: Runs `php $APP_BASE_DIR/artisan` with your arguments, so you don't need to be in the app directory. It exits with an error if there's no `artisan` file in `APP_BASE_DIR`.
+ usage: artisan [arguments]
+ example:
+ language: bash
+ filename: Terminal
+ code: docker compose exec php artisan migrate:status
+ - name: docker-php-serversideup-dep-install-alpine
+ summary: Install OS packages on Alpine only.
+ root: true
+ description: Installs packages with `apk` on Alpine and does nothing on Debian, so one Dockerfile builds on both. Pair it with `docker-php-serversideup-dep-install-debian` for the Debian package names. Pass packages as separate arguments, a comma-separated list, or across lines. It cleans up the package cache when it finishes.
+ usage: docker-php-serversideup-dep-install-alpine [package ...]
+ example:
+ language: dockerfile
+ filename: Dockerfile
+ code: |-
+ FROM serversideup/php:8.5-fpm-nginx
+
+ USER root
+ RUN docker-php-serversideup-dep-install-debian "default-mysql-client, git" \
+ && docker-php-serversideup-dep-install-alpine "mysql-client, git"
+ USER www-data
+ - name: docker-php-serversideup-dep-install-debian
+ summary: Install OS packages on Debian only.
+ root: true
+ description: Installs packages with `apt-get` on Debian and does nothing on Alpine, so one Dockerfile builds on both. Pair it with `docker-php-serversideup-dep-install-alpine` for the Alpine package names. Pass packages as separate arguments, a comma-separated list, or across lines. It cleans up the package cache when it finishes.
+ usage: docker-php-serversideup-dep-install-debian [package ...]
+ example:
+ language: dockerfile
+ filename: Dockerfile
+ code: |-
+ FROM serversideup/php:8.5-fpm-nginx
+
+ USER root
+ RUN docker-php-serversideup-dep-install-debian "default-mysql-client, git" \
+ && docker-php-serversideup-dep-install-alpine "mysql-client, git"
+ USER www-data
+ - name: docker-php-serversideup-download
+ summary: Download a file, retrying failed requests.
+ description: Downloads a file during a build and retries failed requests up to five times. Without an output file, it writes to stdout, so you can pipe it into another command.
+ usage: docker-php-serversideup-download [output-file]
+ options:
+ - name:
+ description: The file to download.
+ - name: "[output-file]"
+ description: Where to save it. Leave it out to write to stdout.
+ example:
+ language: bash
+ filename: Terminal
+ code: |-
+ docker-php-serversideup-download https://example.com/tool.tar.gz /tmp/tool.tar.gz
+ docker-php-serversideup-download https://example.com/key.asc | gpg --dearmor > /usr/share/keyrings/example.gpg
+ - name: docker-php-serversideup-entrypoint
+ summary: The image's entrypoint, which runs the start-up scripts.
+ description: The `ENTRYPOINT` of every variation. It sources the scripts in `/etc/entrypoint.d` in order, then runs the container's command. If you set your own `ENTRYPOINT`, none of the image's start-up scripts run.
+ reference:
+ label: Adding your own start-up scripts
+ url: /docs/customizing-the-image/adding-your-own-start-up-scripts
+ - name: docker-php-serversideup-install-php-ext-installer
+ summary: Install a specific version of `install-php-extensions`.
+ root: true
+ description: Downloads `install-php-extensions` to `/usr/local/bin`. Pass a version to pin it; without one, it installs the latest release.
+ usage: docker-php-serversideup-install-php-ext-installer [version]
+ example:
+ language: bash
+ filename: Terminal
+ code: docker-php-serversideup-install-php-ext-installer 2.12.6
+ - name: docker-php-serversideup-install-php-extensions
+ summary: Install PHP extensions, optionally from another source.
+ root: true
+ description: Installs extensions with `install-php-extensions`. The optional second argument swaps an extension's source, such as a branch or tag. Our image builds set the `PHPREDIS_VERSION` and `PHPREDIS_LIBLZF_REF` build args to build `redis` from GitHub; your Dockerfile doesn't, so `redis` installs from PECL unless you pass an override.
+ usage: docker-php-serversideup-install-php-extensions "" [" ..."]
+ options:
+ - name: ""
+ description: Space-separated extensions to install.
+ - name: ""
+ description: Space-separated pairs that build an extension from another source, such as `redis=phpredis/phpredis@develop`.
+ example:
+ language: bash
+ filename: Terminal
+ code: docker-php-serversideup-install-php-extensions "opcache redis zip" "redis=phpredis/phpredis@develop"
+ - name: docker-php-serversideup-s6-init
+ summary: Run the start-up scripts as S6 Overlay services.
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ root: true
+ description: Converts every script in `/etc/entrypoint.d` into an S6 Overlay oneshot service that runs in the same order the entrypoint would use. PHP-FPM and the web server start after the last one. Use it when your own S6 service has to wait for a start-up script. It's safe to run again in an image built on one that already ran it.
+ usage: docker-php-serversideup-s6-init
+ example:
+ language: dockerfile
+ filename: Dockerfile
+ code: |-
+ FROM serversideup/php:8.5-fpm-nginx
+
+ USER root
+ COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
+ RUN docker-php-serversideup-s6-init
+ USER www-data
+ reference:
+ label: Using S6 Overlay
+ url: /docs/customizing-the-image/using-s6-overlay
+ - name: docker-php-serversideup-set-file-permissions
+ summary: Give a UID and GID ownership of the files the image needs.
+ root: true
+ description: Changes the owner of the directories the variation needs, such as `/var/www`, `/composer`, and the web server's config and log directories. It detects the variation for you.
+ usage: docker-php-serversideup-set-file-permissions --owner USER:GROUP [--service SERVICE] [--dir PATH]...
+ options:
+ - name: --owner
+ description: Required. The user and group, as names or IDs.
+ - name: --service
+ description: Overrides detection with `cli`, `fpm`, `fpm-nginx`, `fpm-apache`, or `frankenphp`.
+ - name: --dir
+ description: Adds a directory. Repeat it for more.
+ example:
+ language: bash
+ filename: Terminal
+ code: docker-php-serversideup-set-file-permissions --owner 1000:1000 --dir /var/www/storage
+ reference:
+ label: Understanding file permissions
+ url: /docs/customizing-the-image/understanding-file-permissions
+ - name: docker-php-serversideup-set-id
+ summary: Change the UID and GID of `www-data` or another user.
+ root: true
+ description: Changes the UID and GID of a user. If another user or group already has the ID, it moves that one to `99` first. Run `docker-php-serversideup-set-file-permissions` with the same IDs afterward, so the user still owns its files.
+ usage: docker-php-serversideup-set-id :
+ example:
+ language: bash
+ filename: Terminal
+ code: docker-php-serversideup-set-id www-data 1000:1000
+ reference:
+ label: Understanding file permissions
+ url: /docs/customizing-the-image/understanding-file-permissions
+ - name: healthcheck-horizon
+ summary: Check that Laravel Horizon is running.
+ description: Runs `php artisan horizon:status`. It exits with `1` when no Horizon process is running or Horizon is paused, and `0` otherwise.
+ usage: healthcheck-horizon
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "healthcheck-horizon"]
+ start_period: 10s
+ reference:
+ label: Horizon
+ url: /docs/framework-guides/laravel/horizon
+ - name: healthcheck-nightwatch
+ summary: Check the Laravel Nightwatch agent.
+ description: Runs `php artisan nightwatch:status` from `APP_BASE_DIR` and returns its exit code. It boots your application on every check.
+ usage: healthcheck-nightwatch
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "healthcheck-nightwatch"]
+ start_period: 10s
+ reference:
+ label: Nightwatch
+ url: /docs/framework-guides/laravel/nightwatch
+ - name: healthcheck-octane
+ summary: Check that Laravel Octane is running.
+ description: Runs `php artisan octane:status` from `APP_BASE_DIR` and returns its exit code, which fails when the Octane server isn't running.
+ usage: healthcheck-octane
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "healthcheck-octane"]
+ start_period: 10s
+ reference:
+ label: Laravel Octane
+ url: /docs/framework-guides/laravel/octane
+ - name: healthcheck-queue
+ summary: Check that a `queue:work` process is running.
+ description: Looks for a process named `queue:work` with `pgrep`. It proves the worker is running, not that it's processing jobs. It fails if you run `queue:listen` instead.
+ usage: healthcheck-queue
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "healthcheck-queue"]
+ start_period: 10s
+ reference:
+ label: Queue
+ url: /docs/framework-guides/laravel/queue
+ - name: healthcheck-reverb
+ summary: Check that a `reverb:start` process is running.
+ description: Looks for a process named `reverb:start` with `pgrep`. It proves the server is running, not that it accepts connections.
+ usage: healthcheck-reverb
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "healthcheck-reverb"]
+ start_period: 10s
+ reference:
+ label: Reverb
+ url: /docs/framework-guides/laravel/reverb
+ - name: healthcheck-schedule
+ summary: Check that a `schedule:work` process is running.
+ description: Looks for a process named `schedule:work` with `pgrep`. It proves the scheduler is running, not that tasks succeed.
+ usage: healthcheck-schedule
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "healthcheck-schedule"]
+ start_period: 10s
+ reference:
+ label: Task scheduler
+ url: /docs/framework-guides/laravel/task-scheduler
+ - name: install-php-extensions
+ summary: Install PHP extensions and the libraries they need.
+ root: true
+ description: From docker-php-extension-installer. It installs PHP extensions and the system libraries they need in one command. `docker-php-ext-install` and `pecl` from the official image also work.
+ usage: install-php-extensions [extension ...]
+ example:
+ language: dockerfile
+ filename: Dockerfile
+ code: |-
+ FROM serversideup/php:8.5-fpm-nginx
+
+ USER root
+ RUN install-php-extensions intl bcmath
+ USER www-data
+ reference:
+ label: docker-php-extension-installer
+ url: https://github.com/mlocati/docker-php-extension-installer
+ - name: php-fpm-healthcheck
+ summary: Check PHP-FPM through its ping and status pages.
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: The `fpm` variation's health check, from php-fpm-healthcheck. It exits with `1` when PHP-FPM doesn't answer, or when a threshold you pass is crossed.
+ usage: php-fpm-healthcheck [--=]
+ options:
+ - name: --listen-queue
+ description: Fail when more requests than this are waiting for a worker.
+ example:
+ language: yaml
+ filename: compose.yml
+ code: |-
+ healthcheck:
+ test: ["CMD", "php-fpm-healthcheck", "--listen-queue=10"]
+ reference:
+ label: php-fpm-healthcheck
+ url: https://github.com/renatomefi/php-fpm-healthcheck
diff --git a/docs/content/data/env-vars.yml b/docs/content/data/env-vars.yml
new file mode 100644
index 000000000..e2b707800
--- /dev/null
+++ b/docs/content/data/env-vars.yml
@@ -0,0 +1,969 @@
+# Every environment variable the images read. The reference page at
+# /docs/reference/environment-variable-specification renders this file, and
+# scripts/check-env-var-docs.sh fails CI when it drifts from src/.
+#
+# Each variable:
+# name The variable
+# default The default value. "" means empty; null means unset
+# variation_defaults Defaults that differ for specific variations
+# variations Variations that read it. Leave it out when every variation does
+# requires Another variable that must be set for this one to matter
+# values The accepted values, when the variable only takes a fixed set
+# description What it does, in plain words. Markdown: inline code and links only
+# reference Optional link to the official docs for the underlying setting
+variables:
+ - name: APACHE_DOCUMENT_ROOT
+ default: "/var/www/html/public"
+ variations:
+ - fpm-apache
+ description: Directory Apache serves files from. Set `/var/www/html` for apps without a `public/` folder.
+ reference:
+ label: DocumentRoot
+ url: https://httpd.apache.org/docs/2.4/mod/core.html#documentroot
+ - name: APACHE_HTTPS_PORT
+ default: "8443"
+ variations:
+ - fpm-apache
+ description: Port Apache listens on for HTTPS inside the container.
+ - name: APACHE_HTTP_PORT
+ default: "8080"
+ variations:
+ - fpm-apache
+ description: Port Apache listens on for HTTP inside the container.
+ - name: APACHE_MAX_CONNECTIONS_PER_CHILD
+ default: "0"
+ variations:
+ - fpm-apache
+ description: Connections an Apache process handles before it restarts. `0` never restarts.
+ reference:
+ label: MaxConnectionsPerChild
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#maxconnectionsperchild
+ - name: APACHE_MAX_REQUEST_WORKERS
+ default: "150"
+ variations:
+ - fpm-apache
+ description: Most requests Apache serves at once.
+ reference:
+ label: MaxRequestWorkers
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#maxrequestworkers
+ - name: APACHE_MAX_SPARE_THREADS
+ default: "75"
+ variations:
+ - fpm-apache
+ description: Most idle threads Apache keeps.
+ reference:
+ label: MaxSpareThreads
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#maxsparethreads
+ - name: APACHE_MIN_SPARE_THREADS
+ default: "10"
+ variations:
+ - fpm-apache
+ description: Fewest idle threads Apache keeps.
+ reference:
+ label: MinSpareThreads
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#minsparethreads
+ - name: APACHE_RUN_GROUP
+ default: "www-data"
+ variations:
+ - fpm-apache
+ description: The group Apache runs as when started as root. Same build-time limit as `APACHE_RUN_USER`.
+ - name: APACHE_RUN_USER
+ default: "www-data"
+ variations:
+ - fpm-apache
+ description: The user Apache runs as when started as root. Written into `/etc/apache2/envvars` at build time, so changing it at runtime has no effect.
+ - name: APACHE_START_SERVERS
+ default: "2"
+ variations:
+ - fpm-apache
+ description: Apache processes started at launch.
+ reference:
+ label: StartServers
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#startservers
+ - name: APACHE_THREADS_PER_CHILD
+ default: "25"
+ variations:
+ - fpm-apache
+ description: Threads each Apache process runs.
+ reference:
+ label: ThreadsPerChild
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#threadsperchild
+ - name: APACHE_THREAD_LIMIT
+ default: "64"
+ variations:
+ - fpm-apache
+ description: Upper limit for `APACHE_THREADS_PER_CHILD`.
+ reference:
+ label: ThreadLimit
+ url: https://httpd.apache.org/docs/2.4/mod/mpm_common.html#threadlimit
+ - name: APP_BASE_DIR
+ default: "/var/www/html"
+ description: Where your app lives in the container. If you change it, also change `NGINX_WEBROOT`, `APACHE_DOCUMENT_ROOT`, or `CADDY_SERVER_ROOT`.
+ - name: AUTORUN_DEBUG
+ default: "false"
+ requires: AUTORUN_ENABLED
+ description: Print each automation step and the `AUTORUN_*` values it read, plus the database errors while it waits for the database. `LOG_OUTPUT_LEVEL=debug` prints the steps but not the database errors.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_ENABLED
+ default: "false"
+ description: Run the Laravel automations (storage link, migrations, and caches) each time the container starts. They run whatever the container's command is, so turn them on for one service only, not your [workers](/docs/framework-guides/laravel/workers). `DISABLE_DEFAULT_CONFIG=true` also turns them off.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_CONFIG_CACHE
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan config:cache` so Laravel loads its config from one cached file. Once config is cached, Laravel stops reading `.env`, so `env()` outside your config files only sees the container's environment variables.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_EVENT_CACHE
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan event:cache` so Laravel reads its event listeners from a cache instead of scanning your `Listeners` directory on every request.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan migrate` at start-up, after clearing the config cache and waiting for the database. `AUTORUN_LARAVEL_MIGRATION_MODE` picks the command, and the variables below add flags.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION_DATABASE
+ default: ""
+ requires: AUTORUN_ENABLED
+ description: Connections to migrate, comma-separated, such as `mysql,pgsql`. Empty uses the default connection.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION_FORCE
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Add `--force` so migrations run without the confirmation prompt Laravel shows when `APP_ENV=production`. A container can't answer it, so with `false` in production Laravel cancels the migration and the container stops.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION_ISOLATION
+ default: "false"
+ requires: AUTORUN_ENABLED
+ description: Add `--isolated` so only one container migrates at a time. Needs a default cache store that supports [atomic locks](https://laravel.com/docs/migrations#running-migrations), shared by every host that runs migrations, and `default` mode; any other mode stops the container. Laravel older than 9.38.0 migrates without it, and the first deploy skips it until the migrations table exists.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION_MODE
+ default: "default"
+ requires: AUTORUN_ENABLED
+ description: 'Pick the migrate command: `default` (`migrate`), `fresh` (`migrate:fresh`), or `refresh` (`migrate:refresh`). `fresh` and `refresh` drop every table, so use them only in development.'
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ values:
+ - "default"
+ - "fresh"
+ - "refresh"
+ - name: AUTORUN_LARAVEL_MIGRATION_SEED
+ default: "false"
+ requires: AUTORUN_ENABLED
+ description: Add `--seed` to run your database seeders after migrating. They run on every start, so make them safe to repeat.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION_SKIP_DB_CHECK
+ default: "false"
+ requires: AUTORUN_ENABLED
+ description: Migrate right away instead of first waiting for the database to accept connections.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_MIGRATION_TIMEOUT
+ default: "30"
+ requires: AUTORUN_ENABLED
+ description: Seconds to wait for the database to accept connections before migrating, trying once a second. A host that never answers holds each try for the driver's connect timeout, so the wait can run much longer. If the database isn't ready in time, the container stops.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_OPTIMIZE
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan optimize`, which caches config, events, routes, and views in one command on Laravel 11 and newer. Turn one cache off with its variable below and the image leaves it out with `--except` (Laravel 11.38.0+) or runs the other `*:cache` commands one by one. With `false`, only the cache variables set to `true` run.
+ reference:
+ label: Optimization
+ url: https://laravel.com/docs/deployment#optimization
+ - name: AUTORUN_LARAVEL_ROUTE_CACHE
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan route:cache` so Laravel registers your routes from one cached file instead of loading every route file on each request.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND
+ default: "false"
+ requires: AUTORUN_ENABLED
+ description: Skip without failing when there's no Laravel in `APP_BASE_DIR`, such as before the first `composer install`. Laravel counts as found when `artisan` and `vendor/` both exist.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_STORAGE_LINK
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan storage:link` so files in `storage/app/public` are served from `public/storage`. Skipped when `public/storage` already exists.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: AUTORUN_LARAVEL_VIEW_CACHE
+ default: "true"
+ requires: AUTORUN_ENABLED
+ description: Run `php artisan view:cache` to compile every Blade template at start-up, so no visitor waits for a page to compile.
+ reference:
+ label: Laravel automations
+ url: /docs/framework-guides/laravel/automations
+ - name: CADDY_ACME_PROFILE
+ default: "off"
+ variations:
+ - frankenphp
+ description: Let's Encrypt certificate profile. `shortlived` issues certificates that last about six days and renew every few days; it's also needed for IP-address certificates. Any profile other than `off` drops Caddy's ZeroSSL fallback, so the container needs reliable outbound access to Let's Encrypt.
+ reference:
+ label: Let's Encrypt profiles
+ url: https://letsencrypt.org/docs/profiles/
+ values:
+ - "off"
+ - "shortlived"
+ - "tlsserver"
+ - "classic"
+ - name: CADDY_ADMIN
+ default: "off"
+ variations:
+ - frankenphp
+ description: Caddy's admin API, which can reconfigure the server. Ignored under [Laravel Octane](/docs/framework-guides/laravel/octane#octane-options), which turns it on for itself on `localhost`.
+ reference:
+ label: admin
+ url: https://caddyserver.com/docs/caddyfile/options#admin
+ - name: CADDY_AUTO_HTTPS
+ default: "off"
+ variations:
+ - frankenphp
+ description: Whether Caddy gets certificates and redirects HTTP to HTTPS on its own.
+ reference:
+ label: auto_https
+ url: https://caddyserver.com/docs/caddyfile/options#auto-https
+ values:
+ - "on"
+ - "off"
+ - "disable_redirects"
+ - "disable_certs"
+ - "ignore_loaded_certs"
+ - name: CADDY_GLOBAL_OPTIONS
+ default: ""
+ variations:
+ - frankenphp
+ description: Extra Caddy global options. Ignored under [Laravel Octane](/docs/framework-guides/laravel/octane#octane-options), which overwrites it; use a file in `/etc/frankenphp/caddyfile-global.d/` instead.
+ reference:
+ label: Global options
+ url: https://caddyserver.com/docs/caddyfile/options
+ - name: CADDY_HTTPS_PORT
+ default: "8443"
+ variations:
+ - frankenphp
+ description: Port Caddy listens on for HTTPS inside the container.
+ - name: CADDY_HTTPS_SERVER_ADDRESS
+ default: "https://"
+ variations:
+ - frankenphp
+ description: Site address Caddy answers on for HTTPS. Set your domain here, such as `https://example.com`.
+ reference:
+ label: Addresses
+ url: https://caddyserver.com/docs/caddyfile/concepts#addresses
+ - name: CADDY_HTTP_PORT
+ default: "8080"
+ variations:
+ - frankenphp
+ description: Port Caddy listens on for HTTP inside the container.
+ - name: CADDY_HTTP_SERVER_ADDRESS
+ default: "http://"
+ variations:
+ - frankenphp
+ description: Site address Caddy answers on for HTTP. The default `http://` answers any hostname.
+ reference:
+ label: Addresses
+ url: https://caddyserver.com/docs/caddyfile/concepts#addresses
+ - name: CADDY_LOG_FORMAT
+ default: "auto"
+ variations:
+ - frankenphp
+ description: Format of Caddy's logs. `auto` writes JSON in a container and readable lines in a terminal. Every format redacts the `authorization` query parameter. Leave `auto` under [Laravel Octane](/docs/framework-guides/laravel/octane#logging), which only parses JSON.
+ reference:
+ label: log format
+ url: https://caddyserver.com/docs/caddyfile/directives/log#format
+ values:
+ - "auto"
+ - "console"
+ - "json"
+ - name: CADDY_LOG_OUTPUT
+ default: "stderr"
+ variations:
+ - frankenphp
+ description: Where Caddy writes its access and runtime logs. Leave `stderr` under [Laravel Octane](/docs/framework-guides/laravel/octane#logging), which only relays `stderr`.
+ - name: CADDY_PHP_SERVER_OPTIONS
+ default: ""
+ variations:
+ - frankenphp
+ description: Extra directives inside the `php_server` block.
+ reference:
+ label: php_server
+ url: https://frankenphp.dev/docs/config/#caddyfile-config
+ - name: CADDY_SERVER_ADMIN_HOST
+ default: "localhost"
+ variations:
+ - frankenphp
+ description: Set by Laravel Octane, so you don't set it. Host of the Caddy admin API that `octane:status`, `octane:reload`, and `octane:stop` call.
+ - name: CADDY_SERVER_ADMIN_PORT
+ default: "2019"
+ variations:
+ - frankenphp
+ description: 'Set by Laravel Octane, so you don''t set it. Port of the Caddy admin API, from Octane''s `--admin-port` or derived from `--port`: `--port=8080` gives `2099`.'
+ - name: CADDY_SERVER_EXTRA_DIRECTIVES
+ default: ""
+ variations:
+ - frankenphp
+ description: Extra Caddyfile directives for your site. Laravel Octane overwrites it, so prefer a file in `/etc/frankenphp/caddyfile-server.d/`. See [adding your own Caddyfile rules](/docs/image-variations/frankenphp#adding-your-own-caddyfile-rules).
+ - name: CADDY_SERVER_LOG_LEVEL
+ default: null
+ variations:
+ - frankenphp
+ description: Caddy's log level. When unset, it follows `LOG_OUTPUT_LEVEL`. Under [Laravel Octane](/docs/framework-guides/laravel/octane#logging), use `octane:start --log-level` instead.
+ values:
+ - "DEBUG"
+ - "INFO"
+ - "WARN"
+ - "ERROR"
+ - name: CADDY_SERVER_ROOT
+ default: "/var/www/html/public"
+ variations:
+ - frankenphp
+ description: Directory Caddy serves files from. Set `/var/www/html` for apps without a `public/` folder.
+ reference:
+ label: root
+ url: https://caddyserver.com/docs/caddyfile/directives/root
+ - name: CADDY_SERVER_WATCH_DIRECTIVES
+ default: ""
+ variations:
+ - frankenphp
+ description: Set by Laravel Octane, so you don't set it. Holds the `watch` directives from `--watch` and the `watch` paths in `config/octane.php`.
+ - name: CADDY_SERVER_WORKER_DIRECTIVE
+ default: ""
+ variations:
+ - frankenphp
+ description: Set by Laravel Octane, so you don't set it. Holds the worker settings from `--workers` for the `worker` block that loads `frankenphp-worker.php`.
+ - name: COMPOSER_ALLOW_SUPERUSER
+ default: "1"
+ description: Hide Composer's warning about running as root.
+ - name: COMPOSER_HOME
+ default: "/composer"
+ description: Composer's global directory, shared by every project. It holds global config, `auth.json`, and globally required packages.
+ - name: COMPOSER_MAX_PARALLEL_HTTP
+ default: "24"
+ description: Parallel Composer downloads, `1` to `50`. Composer's own default is `12`, and raising it further rarely helps. Lower it if your proxy struggles.
+ - name: DISABLE_DEFAULT_CONFIG
+ default: "false"
+ description: '`true` skips the image''s start-up scripts and automations, so you configure everything yourself. That includes the web server configuration, so `fpm-nginx` and `fpm-apache` need your own. Your own scripts in `/etc/entrypoint.d/` still run.'
+ - name: FRANKENPHP_CONFIG
+ default: ""
+ variations:
+ - frankenphp
+ description: Directives inside the global `frankenphp` block, such as `num_threads`.
+ reference:
+ label: frankenphp
+ url: https://frankenphp.dev/docs/config/#caddyfile-config
+ - name: HEALTHCHECK_PATH
+ default: "/healthcheck"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ - frankenphp
+ description: Path the container health check requests, left out of access logs. The image answers `/healthcheck` itself; set an app route such as `/up` to check your app instead.
+ reference:
+ label: Health checks
+ url: /docs/deployment-and-production/health-checks
+ - name: HEALTHCHECK_SSL_CERTIFICATE_FILE
+ default: "/etc/ssl/healthcheck/localhost.crt"
+ variations:
+ - frankenphp
+ description: Self-signed certificate for the health check on `localhost` when `SSL_MODE=full`. The image generates it at start-up whenever `SSL_MODE` isn't `off`.
+ - name: HEALTHCHECK_SSL_PRIVATE_KEY_FILE
+ default: "/etc/ssl/healthcheck/localhost.key"
+ variations:
+ - frankenphp
+ description: Private key for the health check certificate.
+ - name: LARAVEL_OCTANE
+ default: "0"
+ variations:
+ - frankenphp
+ description: Set by Laravel Octane, so you don't set it. Octane passes `1`, which runs `frankenphp-worker.php` as a worker and turns on the admin API. If you set it yourself, only `1` and `0` work; anything else, such as `true` or an empty string, stops FrankenPHP with a "File to import not found" error. See [Laravel Octane](/docs/framework-guides/laravel/octane).
+ - name: LOG_OUTPUT_LEVEL
+ default: "warn"
+ variation_defaults:
+ frankenphp: "info"
+ description: How much PHP, PHP-FPM, and the web server log. `debug` also shows every PHP error and traces the entrypoint. FrankenPHP defaults to `info` because Caddy logs requests at that level.
+ values:
+ - "debug"
+ - "info"
+ - "notice"
+ - "warn"
+ - "error"
+ - "crit"
+ - "alert"
+ - "emerg"
+ - name: MERCURE_ENABLED
+ default: "false"
+ variations:
+ - frankenphp
+ values:
+ - "true"
+ - "false"
+ description: Run the [Mercure hub](/docs/image-variations/frankenphp#mercure) at `/.well-known/mercure`. Needs both JWT keys.
+ reference:
+ label: Mercure configuration
+ url: https://mercure.rocks/docs/deployment/configuration
+ - name: MERCURE_EXTRA_DIRECTIVES
+ default: ""
+ variations:
+ - frankenphp
+ description: Extra hub directives, one per line, such as `anonymous` or `cors_origins`.
+ reference:
+ label: Mercure directives
+ url: https://mercure.rocks/docs/deployment/configuration
+ - name: MERCURE_PUBLISHER_JWT_ALG
+ default: "HS256"
+ variations:
+ - frankenphp
+ description: Algorithm for publisher tokens. A PEM key needs an asymmetric one, such as `RS256`.
+ - name: MERCURE_PUBLISHER_JWT_KEY
+ default: null
+ variations:
+ - frankenphp
+ description: Shared secret or PEM public key that verifies publisher tokens.
+ - name: MERCURE_SUBSCRIBER_JWT_ALG
+ default: "HS256"
+ variations:
+ - frankenphp
+ description: Algorithm for subscriber tokens.
+ - name: MERCURE_SUBSCRIBER_JWT_KEY
+ default: null
+ variations:
+ - frankenphp
+ description: Shared secret or PEM public key that verifies subscriber tokens.
+ - name: MERCURE_TRUSTED_ISSUERS
+ default: "https://localhost"
+ variations:
+ - frankenphp
+ description: The `iss` claim tokens must carry, usually your app's URL. Tokens are verified only with the keys of the issuer they claim.
+ - name: NGINX_ACCESS_LOG
+ default: "/dev/stdout"
+ variations:
+ - fpm-nginx
+ description: Where NGINX writes access logs. The default sends them to the container log.
+ reference:
+ label: access_log
+ url: https://nginx.org/en/docs/http/ngx_http_log_module.html#access_log
+ - name: NGINX_CLIENT_MAX_BODY_SIZE
+ default: "100M"
+ variations:
+ - fpm-nginx
+ description: Largest request body NGINX accepts. Keep it in line with `PHP_POST_MAX_SIZE`.
+ reference:
+ label: client_max_body_size
+ url: https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size
+ - name: NGINX_ERROR_LOG
+ default: "/dev/stderr"
+ variations:
+ - fpm-nginx
+ description: Where NGINX writes errors. The default sends them to the container log.
+ reference:
+ label: error_log
+ url: https://nginx.org/en/docs/ngx_core_module.html#error_log
+ - name: NGINX_FASTCGI_BUFFERS
+ default: "8 8k"
+ variations:
+ - fpm-nginx
+ description: Number and size of the buffers NGINX uses to read a response from PHP-FPM. Raise it if large responses get written to disk.
+ reference:
+ label: fastcgi_buffers
+ url: https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_buffers
+ - name: NGINX_FASTCGI_BUFFER_SIZE
+ default: "8k"
+ variations:
+ - fpm-nginx
+ description: Buffer for the first part of a PHP-FPM response, which holds the headers. Raise it if large headers cause `502` errors.
+ reference:
+ label: fastcgi_buffer_size
+ url: https://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_buffer_size
+ - name: NGINX_HTTPS_PORT
+ default: "8443"
+ variations:
+ - fpm-nginx
+ description: Port NGINX listens on for HTTPS inside the container.
+ - name: NGINX_HTTP_PORT
+ default: "8080"
+ variations:
+ - fpm-nginx
+ description: Port NGINX listens on for HTTP inside the container.
+ - name: NGINX_LISTEN_IP_PROTOCOL
+ default: "all"
+ variations:
+ - fpm-nginx
+ description: Which IP versions NGINX listens on. Use `ipv4` on hosts without IPv6.
+ reference:
+ label: listen
+ url: https://nginx.org/en/docs/http/ngx_http_core_module.html#listen
+ values:
+ - "all"
+ - "ipv4"
+ - "ipv6"
+ - name: NGINX_SERVER_TOKENS
+ default: "off"
+ variations:
+ - fpm-nginx
+ description: Show the NGINX version in the `Server` header and on error pages.
+ reference:
+ label: server_tokens
+ url: https://nginx.org/en/docs/http/ngx_http_core_module.html#server_tokens
+ - name: NGINX_WEBROOT
+ default: "/var/www/html/public"
+ variations:
+ - fpm-nginx
+ description: Directory NGINX serves files from. Set `/var/www/html` for apps without a `public/` folder.
+ reference:
+ label: root
+ url: https://nginx.org/en/docs/http/ngx_http_core_module.html#root
+ - name: PHP_DATE_TIMEZONE
+ default: "UTC"
+ description: Default timezone for PHP date and time functions.
+ reference:
+ label: date.timezone
+ url: https://www.php.net/manual/en/datetime.configuration.php#ini.date.timezone
+ - name: PHP_DISABLE_FUNCTIONS
+ default: ""
+ description: PHP functions to turn off, comma-separated, such as `exec,shell_exec`. Empty by default because Laravel, Composer, and Symfony Process need `proc_open`.
+ reference:
+ label: disable_functions
+ url: https://www.php.net/manual/en/ini.core.php#ini.disable-functions
+ - name: PHP_DISPLAY_ERRORS
+ default: "Off"
+ description: Show PHP errors in the response. Keep it `Off` in production; errors still go to the log.
+ reference:
+ label: display_errors
+ url: https://www.php.net/manual/en/errorfunc.configuration.php#ini.display-errors
+ - name: PHP_DISPLAY_STARTUP_ERRORS
+ default: "Off"
+ description: Show errors that happen while PHP starts, before your code runs.
+ reference:
+ label: display_startup_errors
+ url: https://www.php.net/manual/en/errorfunc.configuration.php#ini.display-startup-errors
+ - name: PHP_ERROR_LOG
+ default: "/dev/stderr"
+ description: Where PHP writes errors. The default sends them to the container log.
+ reference:
+ label: error_log
+ url: https://www.php.net/manual/en/errorfunc.configuration.php#ini.error-log
+ - name: PHP_ERROR_REPORTING
+ default: "22527"
+ description: 'Which errors PHP reports, as a number. `22527` is `E_ALL & ~E_DEPRECATED & ~E_STRICT`: everything except deprecation and strict notices. [This calculator](https://maximivanov.github.io/php-error-reporting-calculator/) works out other values.'
+ reference:
+ label: error_reporting
+ url: https://www.php.net/manual/en/errorfunc.configuration.php#ini.error-reporting
+ - name: PHP_FILE_UPLOADS
+ default: "On"
+ description: Allow file uploads in HTTP requests.
+ reference:
+ label: file_uploads
+ url: https://www.php.net/manual/en/ini.core.php#ini.file-uploads
+ - name: PHP_FPM_CHILD_PROCESS_GROUP
+ default: "www-data"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Group PHP-FPM workers run as. Only used when the container runs as root.
+ reference:
+ label: group
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_CHILD_PROCESS_USER
+ default: "www-data"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: User PHP-FPM workers run as. Only used when the container runs as root.
+ reference:
+ label: user
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_PM_CONTROL
+ default: "dynamic"
+ variation_defaults:
+ fpm-nginx: "ondemand"
+ fpm-apache: "ondemand"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: How PHP-FPM manages workers. `static` keeps a fixed number running, `dynamic` keeps a few spare, and `ondemand` starts them as requests arrive.
+ reference:
+ label: pm
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ values:
+ - "static"
+ - "dynamic"
+ - "ondemand"
+ - name: PHP_FPM_PM_MAX_CHILDREN
+ default: "20"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Most PHP-FPM workers at once, which caps how many requests run in parallel. With `static`, it's the exact number of workers. See [PHP-FPM workers](/docs/deployment-and-production/production-performance-tuning#php-fpm-workers).
+ reference:
+ label: pm.max_children
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_PM_MAX_REQUESTS
+ default: "0"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Requests a worker handles before it restarts, which limits memory leaks. `0` never restarts.
+ reference:
+ label: pm.max_requests
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_PM_MAX_SPARE_SERVERS
+ default: "3"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Most idle workers to keep. Only used with `dynamic`.
+ reference:
+ label: pm.max_spare_servers
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_PM_MIN_SPARE_SERVERS
+ default: "1"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Fewest idle workers to keep. Only used with `dynamic`.
+ reference:
+ label: pm.min_spare_servers
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_PM_START_SERVERS
+ default: "2"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Workers started with PHP-FPM. Only used with `dynamic`.
+ reference:
+ label: pm.start_servers
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_PM_STATUS_PATH
+ default: "/status"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: URL path for the PHP-FPM status page. The `fpm` variation's health check reads `/status`, so changing it there fails the health check.
+ reference:
+ label: pm.status_path
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_FPM_POOL_NAME
+ default: "www"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: Name of the PHP-FPM pool, shown in logs and on the status page.
+ - name: PHP_FPM_PROCESS_CONTROL_TIMEOUT
+ default: "10s"
+ variations:
+ - fpm
+ - fpm-nginx
+ - fpm-apache
+ description: How long workers get to react to a stop or reload signal from PHP-FPM before they are killed.
+ reference:
+ label: process_control_timeout
+ url: https://www.php.net/manual/en/install.fpm.configuration.php
+ - name: PHP_HTML_ERRORS
+ default: "On"
+ description: Format displayed errors as HTML. Only affects errors shown on screen; PHP always turns it off for the CLI.
+ reference:
+ label: html_errors
+ url: https://www.php.net/manual/en/errorfunc.configuration.php#ini.html-errors
+ - name: PHP_MAX_EXECUTION_TIME
+ default: "99"
+ description: Seconds a script may run before PHP stops it. On Linux, time spent waiting on the database, network, or other system calls doesn't count.
+ reference:
+ label: max_execution_time
+ url: https://www.php.net/manual/en/info.configuration.php#ini.max-execution-time
+ - name: PHP_MAX_FILE_UPLOADS
+ default: "20"
+ description: Most files one request can upload.
+ reference:
+ label: max_file_uploads
+ url: https://www.php.net/manual/en/ini.core.php#ini.max-file-uploads
+ - name: PHP_MAX_INPUT_TIME
+ default: "-1"
+ description: Seconds PHP may spend reading request input, such as POST data and uploads. `-1` uses `PHP_MAX_EXECUTION_TIME` and `0` removes the limit. The CLI always uses `-1`.
+ reference:
+ label: max_input_time
+ url: https://www.php.net/manual/en/info.configuration.php#ini.max-input-time
+ - name: PHP_MAX_INPUT_VARS
+ default: "1000"
+ description: Most input variables PHP accepts per request, counted separately for query strings, POST data, and cookies. Raise it for very large forms.
+ reference:
+ label: max_input_vars
+ url: https://www.php.net/manual/en/info.configuration.php#ini.max-input-vars
+ - name: PHP_MEMORY_LIMIT
+ default: "256M"
+ description: Most memory one script may use, such as `512M`. `-1` removes the limit.
+ reference:
+ label: memory_limit
+ url: https://www.php.net/manual/en/ini.core.php#ini.memory-limit
+ - name: PHP_OPCACHE_ENABLE
+ default: "0"
+ description: Keep compiled PHP in memory so requests skip parsing and compiling. Set `1` in production. See [production performance tuning](/docs/deployment-and-production/production-performance-tuning#php-opcache).
+ reference:
+ label: opcache.enable
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.enable
+ - name: PHP_OPCACHE_ENABLE_CLI
+ default: "1"
+ description: Use OPcache for `php` commands too. Only matters when OPcache is on. `0` keeps it to the web server, so a preload script doesn't run on every `php` command.
+ reference:
+ label: opcache.enable_cli
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.enable-cli
+ - name: PHP_OPCACHE_ENABLE_FILE_OVERRIDE
+ default: "0"
+ description: Answer `file_exists()`, `is_file()`, and `is_readable()` from the cache for files that are already cached. With `PHP_OPCACHE_VALIDATE_TIMESTAMPS` off, a file deleted after it was cached still looks present.
+ reference:
+ label: opcache.enable_file_override
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.enable-file-override
+ - name: PHP_OPCACHE_FORCE_RESTART_TIMEOUT
+ default: "180"
+ description: Seconds to wait for a pending cache reset before OPcache kills the processes still holding the old cache.
+ reference:
+ label: opcache.force_restart_timeout
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.force-restart-timeout
+ - name: PHP_OPCACHE_INTERNED_STRINGS_BUFFER
+ default: "32"
+ description: Memory, in MB, for storing repeated strings once. It comes out of `PHP_OPCACHE_MEMORY_CONSUMPTION`.
+ reference:
+ label: opcache.interned_strings_buffer
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.interned-strings-buffer
+ - name: PHP_OPCACHE_JIT
+ default: "off"
+ description: PHP's just-in-time compiler. To use it, set `tracing` and a non-zero `PHP_OPCACHE_JIT_BUFFER_SIZE`.
+ reference:
+ label: opcache.jit
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.jit
+ - name: PHP_OPCACHE_JIT_BUFFER_SIZE
+ default: "0"
+ description: Memory for JIT-compiled code, such as `64M`. `0` leaves the JIT off. Added on top of `PHP_OPCACHE_MEMORY_CONSUMPTION`.
+ reference:
+ label: opcache.jit_buffer_size
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.jit-buffer-size
+ - name: PHP_OPCACHE_MAX_ACCELERATED_FILES
+ default: "32531"
+ description: Most scripts OPcache can hold. PHP rounds up to a prime, and `32531` is one.
+ reference:
+ label: opcache.max_accelerated_files
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.max-accelerated-files
+ - name: PHP_OPCACHE_MEMORY_CONSUMPTION
+ default: "256"
+ description: Shared memory for the cache, in MB. It fills only as files are cached.
+ reference:
+ label: opcache.memory_consumption
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.memory-consumption
+ - name: PHP_OPCACHE_PRELOAD
+ default: ""
+ description: Path to a script that loads classes into memory when PHP starts. Empty turns preloading off. It also runs on every `php` command unless `PHP_OPCACHE_ENABLE_CLI=0`. See [preloading](/docs/deployment-and-production/production-performance-tuning#preloading).
+ reference:
+ label: opcache.preload
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.preload
+ - name: PHP_OPCACHE_PRELOAD_USER
+ default: ""
+ description: User the preload script runs as. Required to preload when the container runs as root.
+ reference:
+ label: opcache.preload_user
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.preload-user
+ - name: PHP_OPCACHE_REVALIDATE_FREQ
+ default: "2"
+ description: Seconds between checks for changed files. Only used when `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`.
+ reference:
+ label: opcache.revalidate_freq
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.revalidate-freq
+ - name: PHP_OPCACHE_SAVE_COMMENTS
+ default: "1"
+ description: Keep doc comments in the cache. `0` breaks code that reads PHPDoc annotations, such as Doctrine and PHPUnit.
+ reference:
+ label: opcache.save_comments
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.save-comments
+ - name: PHP_OPCACHE_VALIDATE_TIMESTAMPS
+ default: "0"
+ description: Check cached files for changes. With `0`, code changes need a container restart. Set `1` if you mount your code and keep OPcache on.
+ reference:
+ label: opcache.validate_timestamps
+ url: https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.validate-timestamps
+ - name: PHP_OPEN_BASEDIR
+ default: ""
+ description: Limit the files PHP can open to these directories, separated by `:`. It's a safety net, not a security boundary. Setting it turns off the realpath cache.
+ reference:
+ label: open_basedir
+ url: https://www.php.net/manual/en/ini.core.php#ini.open-basedir
+ - name: PHP_POST_MAX_SIZE
+ default: "100M"
+ description: Largest request body PHP accepts. Keep it at least as large as `PHP_UPLOAD_MAX_FILE_SIZE`.
+ reference:
+ label: post_max_size
+ url: https://www.php.net/manual/en/ini.core.php#ini.post-max-size
+ - name: PHP_REALPATH_CACHE_SIZE
+ default: "4096K"
+ description: Memory PHP uses to remember resolved file paths, which saves file system lookups on every request. Raise it for apps with a large `vendor/` directory. See [realpath cache](/docs/deployment-and-production/production-performance-tuning#php-realpath-cache).
+ reference:
+ label: realpath_cache_size
+ url: https://www.php.net/manual/en/ini.core.php#ini.realpath-cache-size
+ - name: PHP_REALPATH_CACHE_TTL
+ default: "600"
+ description: Seconds PHP keeps a resolved file path in the cache. `600` follows [Symfony's recommendation](https://symfony.com/doc/current/performance.html#configure-the-php-realpath-cache).
+ reference:
+ label: realpath_cache_ttl
+ url: https://www.php.net/manual/en/ini.core.php#ini.realpath-cache-ttl
+ - name: PHP_SESSION_COOKIE_HTTPONLY
+ default: "On"
+ description: Hide the session cookie from JavaScript. Applies to native PHP sessions only; Laravel sets its own cookie flags.
+ reference:
+ label: session.cookie_httponly
+ url: https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-httponly
+ - name: PHP_SESSION_COOKIE_SECURE
+ default: "false"
+ description: Send the session cookie over HTTPS only. Off by default so local development over HTTP works; set `true` when you serve over HTTPS. Applies to native PHP sessions only; Laravel sets its own cookie flags.
+ reference:
+ label: session.cookie_secure
+ url: https://www.php.net/manual/en/session.configuration.php#ini.session.cookie-secure
+ - name: PHP_SESSION_UPLOAD_PROGRESS_ENABLED
+ default: "0"
+ description: Track upload progress in the session. Off because of [PHP's October 23, 2026 advisory](https://www.php.net/archive/2026.php#2026-10-23-1). Only set `1` if your app reads upload progress and never changes `session.serialize_handler` at runtime.
+ reference:
+ label: session.upload_progress.enabled
+ url: https://www.php.net/manual/en/session.configuration.php#ini.session.upload-progress.enabled
+ - name: PHP_UPLOAD_MAX_FILE_SIZE
+ default: "100M"
+ description: Largest single file PHP accepts in an upload.
+ reference:
+ label: upload_max_filesize
+ url: https://www.php.net/manual/en/ini.core.php#ini.upload-max-filesize
+ - name: PHP_ZEND_DETECT_UNICODE
+ default: ""
+ description: Check scripts for a byte order mark to detect their encoding. Only used when `PHP_ZEND_MULTIBYTE` is on.
+ reference:
+ label: zend.detect_unicode
+ url: https://www.php.net/manual/en/ini.core.php#ini.zend.detect-unicode
+ - name: PHP_ZEND_MULTIBYTE
+ default: "Off"
+ description: Parse scripts written in multibyte encodings such as Shift JIS. Most apps leave it off.
+ reference:
+ label: zend.multibyte
+ url: https://www.php.net/manual/en/ini.core.php#ini.zend.multibyte
+ - name: S6_BEHAVIOUR_IF_STAGE2_FAILS
+ default: "2"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ description: Stop the container if a start-up service fails.
+ reference:
+ label: s6-overlay
+ url: https://github.com/just-containers/s6-overlay#customizing-s6-overlay-behaviour
+ - name: S6_CMD_WAIT_FOR_SERVICES_MAXTIME
+ default: "0"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ description: Milliseconds to wait for services to start before giving up. `0` waits forever.
+ reference:
+ label: s6-overlay
+ url: https://github.com/just-containers/s6-overlay#customizing-s6-overlay-behaviour
+ - name: S6_KEEP_ENV
+ default: "1"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ description: Pass the container's environment to every service.
+ reference:
+ label: s6-overlay
+ url: https://github.com/just-containers/s6-overlay#customizing-s6-overlay-behaviour
+ - name: S6_VERBOSITY
+ default: "1"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ description: How much S6 Overlay logs, from `0` to `5`. `1` prints warnings and errors. Raise it first when a service won't start.
+ reference:
+ label: s6-overlay
+ url: https://github.com/just-containers/s6-overlay#customizing-s6-overlay-behaviour
+ - name: SHOW_WELCOME_MESSAGE
+ default: "true"
+ description: Print the container information banner at start-up.
+ - name: SSL_CERTIFICATE_FILE
+ default: "/etc/ssl/private/self-signed-web.crt"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ - frankenphp
+ description: Path to the TLS certificate. When `SSL_MODE` isn't `off` and neither this file nor the private key exists, the image generates a self-signed pair; if only one exists, the container stops. FrankenPHP ignores it when `CADDY_AUTO_HTTPS=on`.
+ - name: SSL_MODE
+ default: "off"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ - frankenphp
+ description: Whether the web server serves HTTP, HTTPS, or both. `mixed` serves both, and `full` redirects HTTP to HTTPS. With native PHP sessions over HTTPS, also set `PHP_SESSION_COOKIE_SECURE=true`.
+ reference:
+ label: Configuring SSL
+ url: /docs/deployment-and-production/configuring-ssl
+ values:
+ - "off"
+ - "mixed"
+ - "full"
+ - name: SSL_PRIVATE_KEY_FILE
+ default: "/etc/ssl/private/self-signed-web.key"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ - frankenphp
+ description: Path to the TLS private key.
+ - name: TRUSTED_PROXY
+ default: "cloudflare"
+ variations:
+ - fpm-nginx
+ - fpm-apache
+ - frankenphp
+ description: Which proxies may pass the visitor's real IP address to your app.
+ reference:
+ label: Configuring trusted proxies
+ url: /docs/deployment-and-production/configuring-trusted-proxies
+ values:
+ - "cloudflare"
+ - "sucuri"
+ - "local"
+ - "off"
+ - name: XDG_CONFIG_HOME
+ default: "/config"
+ variations:
+ - frankenphp
+ description: Where Caddy saves its autosaved config.
+ - name: XDG_DATA_HOME
+ default: "/data"
+ variations:
+ - frankenphp
+ description: Where Caddy stores certificates. Mount a volume here if Caddy issues certificates, so they survive restarts.
diff --git a/docs/content/docs/1.getting-started/1.index.md b/docs/content/docs/1.getting-started/1.index.md
index 8cfd53719..38c60ac83 100644
--- a/docs/content/docs/1.getting-started/1.index.md
+++ b/docs/content/docs/1.getting-started/1.index.md
@@ -1,6 +1,6 @@
---
title: Introduction
-description: 'Production-ready PHP Docker images built on official PHP. Optimized for security, performance, and developer experience.'
+description: 'Production-ready PHP Docker images for Laravel, WordPress, and any PHP app. Built from official PHP and configured with environment variables.'
---
::hero-video
@@ -9,129 +9,55 @@ src: https://docker-php-public-assets.serversideup.net/docker-demo.mp4
---
::
-## What's "serversideup/php"
-
:badges
-**serversideup/php** takes the official PHP Docker images and adds everything you need for production: better security, performance optimizations, and a developer experience that just works.
-
-## These images are very different from other PHP Docker Images
-::u-page-grid
- :::u-page-card
- ---
- icon: i-features-rocket-square
- orientation: vertical
- ---
- #title
- Production Ready
-
- #description
- Built to be performant and secure to exist on the wild web.
- :::
-
- :::u-page-card
- ---
- icon: i-features-heart-square
- orientation: vertical
- ---
- #title
- Native Health Checks
-
- #description
- Be 100% confident your application is actually running.
- :::
-
- :::u-page-card
- ---
- icon: i-features-lightning-square
- orientation: vertical
- ---
- #title
- High Performance
-
- #description
- Get the easiest experience for fine tuning performance.
- :::
-
- :::u-page-card
- ---
- icon: i-features-stars-square
- orientation: vertical
- ---
- #title
- Customizable & Flexible
-
- #description
- Environment variables make customizations a breeze.
- :::
-
- :::u-page-card
- ---
- icon: i-features-cloudflare-square
- orientation: vertical
- ---
- #title
- Native CloudFlare Support
-
- #description
- Get real IP addresses from visitors from trusted proxies.
- :::
-
- :::u-page-card
- ---
- icon: i-features-php-square
- orientation: vertical
- ---
- #title
- Based on official PHP
-
- #description
- Upgrade from the official PHP docker images with confidence.
- :::
-
- :::u-page-card
- ---
- icon: i-features-frankenphp-square
- orientation: vertical
- ---
- #title
- FrankenPHP
-
- #description
- Ditch FPM for a modern way of running PHP. Designed for containers from the ground up.
- :::
-
- :::u-page-card
- ---
- icon: i-features-logging-square
- orientation: vertical
- ---
- #title
- Unified Logging
-
- #description
- All logs are directed to STDOUT & STDERR for centralized output.
- :::
-
- :::u-page-card
- ---
- icon: i-features-heartbeat-square
- orientation: vertical
- ---
- #title
- FPM + S6 Overlay
-
- #description
- Our FPM-Apache & FPM-NGINX images use this intelligent init system.
- :::
-::
+## Run PHP in production without building the image yourself
+
+A PHP container usually starts with the official image and an afternoon of setup. You add Composer and a few extensions. You wire up a web server, write a health check, and switch to a non-root user.
+
+`serversideup/php` ships with that work done. It's built from the same source as the [official PHP images](https://hub.docker.com/_/php){target="_blank"}. Your app runs the same way. You change settings with environment variables instead of editing config files.
+
+The images are free and open source, maintained by Jay Rogers and Dan Pastori of [Server Side Up](https://serversideup.net){target="_blank"}.
+
+## The setup you'd otherwise do by hand
+
+### Unprivileged from the start.
+Every image runs as `www-data`. Web servers listen on ports `8080` and `8443`.
+
+### Settings live in your compose file.
+Set `PHP_MEMORY_LIMIT: "512M"` or `PHP_OPCACHE_ENABLE: "1"` as an environment variable. There's no `php.ini` to mount.
+
+### The usual tools are already installed.
+Composer, `install-php-extensions`, and common extensions like `redis`, `pdo_pgsql`, and `zip` come in every image.
+
+### Health checks and logs work anywhere.
+Every image except `cli` has a built-in health check. All logs go to `stdout` and `stderr`.
+
+### Laravel prepares itself on startup.
+Set `AUTORUN_ENABLED: "true"`. The container runs `storage:link`, your migrations, and `optimize` before it starts serving.
+
+## Five ways to run PHP
+
+The tag names the variation, as in `serversideup/php:8.5-fpm-nginx`.
+
+| Variation | What runs in the container |
+|---|---|
+| [`cli`](/docs/image-variations/cli) | PHP on the command line, for scripts, Composer, and CI. |
+| [`fpm`](/docs/image-variations/fpm) | PHP-FPM on port `9000`, behind a web server you run separately. |
+| [`fpm-nginx`](/docs/image-variations/fpm-nginx) | PHP-FPM with NGINX, supervised by S6 Overlay. |
+| [`fpm-apache`](/docs/image-variations/fpm-apache) | PHP-FPM with Apache, supervised by S6 Overlay. Debian only. |
+| [`frankenphp`](/docs/image-variations/frankenphp) | FrankenPHP, a PHP application server built on Caddy. PHP 8.3 and newer. |
+
+Each one is on [Docker Hub](https://hub.docker.com/r/serversideup/php){target="_blank"} and [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php){target="_blank"}, for `amd64` and `arm64`. Every variation runs on Debian, and all but `fpm-apache` also run on Alpine. [Choosing an image](/docs/getting-started/choosing-an-image) lists every PHP version and operating system.
-
+## Get running in three steps
-:u-button{to="/docs/getting-started/these-images-vs-others" label="See all advantages" aria-label="Read more about the advantages" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+1. Add `serversideup/php:8.5-fpm-nginx` to your `compose.yml`.
+2. Set the PHP settings you need as environment variables.
+3. Run `docker compose up`.
-## Ready to get started?
+The installation guide walks through each step with a working example.
-Ship PHP applications faster with a production-ready setup that includes everything you need out of the box.
+:u-button{to="/docs/getting-started/installation" label="Start the installation guide" aria-label="Start the installation guide" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-:u-button{to="/docs/getting-started/installation" label="Installation" aria-label="Installation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
\ No newline at end of file
+New to containers? Read [Container basics](/docs/getting-started/container-basics) first. Coming from the official images? Follow [Migrating from official PHP images](/docs/getting-started/migrating-from-official-php-images).
diff --git a/docs/content/docs/1.getting-started/2.container-basics.md b/docs/content/docs/1.getting-started/2.container-basics.md
index 747f57b1a..67a8a187c 100644
--- a/docs/content/docs/1.getting-started/2.container-basics.md
+++ b/docs/content/docs/1.getting-started/2.container-basics.md
@@ -1,206 +1,95 @@
---
-title: Container Basics
-description: 'Learn the basics of containers with PHP and how to use it to deploy your PHP applications.'
+title: Container basics
+description: 'The container terms and Docker Compose commands you need to run a PHP application with serversideup/php.'
---
-## Why even care about containers?
-
-If you're new to Docker or containers, you might be wondering why you should containerize your application in the first place. The short answer: **containers let you run your application anywhere** — from your laptop to any cloud provider — with zero changes.
-
-Key benefits of containerization:
-- **Consistency** - Your app runs the same on Mac, Windows, Linux, and production
-- **Confidence** - Infrastructure as code means easier testing and rollbacks
-- **Freedom** - No vendor lock-in; migrate hosts with minimal effort
-- **Simplicity** - Scaling from 1 to 100 containers is straightforward
-
-There are some important terms to understand when working with containers:
-
-| Term | Definition |
-|------|------------|
-| Container | A running instance of an image. |
-| Image | A template for a container to start with (ie. `serversideup/php:8.5-frankenphp`). |
-| Tag | A specific version of an image (ie. `8.5-frankenphp`). |
-| Registry | A repository of images. This is where users can pull images from to start a container. This can be places like [Docker Hub](https://hub.docker.com/r/serversideup/php) or [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php). |
-| Volume | A directory on your host machine that is mounted into a container. This allows you to share files between your host machine and the container. |
-| Port | Ports are virtual numbers organizing network data traffic, directing it to the correct application on a device. If you want to expose traffic to work with `http://localhost`, you would map port `80` on your host machine to port `8080` on the container. |
-| Environment Variable | A variable that is set in the container's environment. This allows you to configure the container's behavior. |
-
-## What are containers?
-Containers are isolated environments that can run on any host. They are a great way to package your application and all of its dependencies into a single unit that can be easily deployed to any environment.
-
-
-
-### Why containerize at all?
-Going through the efforts of containerizing your application gives you one extremely powerful capability:
-
-> 👉 **You can run your application anywhere.**
-
-Once you're at this level, you unlock a ton of new possibilities:
-
-- **Your application runs the same across Mac, Windows, and Linux** - no more "it works on my machine". Run 100% of your application on any operating system. All they need is Docker installed.
-- **Ship with more confidence** - all infrastructure configurations are *centrally managed* in Git, so you can change, test and rollback with ease
-- **No more vendor lock-in** - if a host raises their prices on you, you can migrate with very little effort
-- **Scaling is a breeze** - once you get your application in one container, it's so easy to scale up to any number of containers
-- **Improved security** - containerized apps are more secure than traditional apps because they are isolated from the host
-- **Better uptime** - if something fails during deployment, you can roll back your application to a previous version
-
-The crazier part is you don't need a Platform as an expensive Service (PaaS) to get all these benefits. Everything can be done with 100% free and open source tools.
-
-## Docker vs. Containers
-When you're first learning about containers, you'll likely hear the terms "Docker" and "containers" used interchangeably. While they are related, they are not the same thing. [Docker](https://www.docker.com/) is an open source utility for building, shipping, and running applications in containers. It's one of the most popular tools for containerization.
+::lead-p
+A container packages your PHP application with the exact PHP version, extensions, and web server it needs, so it runs the same on your laptop, in CI, and in production. This page covers the terms and commands you need before the [installation guide](/docs/getting-started/installation).
+::
-Although you may see us reference things as "Docker containers", we're actually referring to the broader concept of containers because containers follow a standard known as the [OCI (Open Container Initiative)](https://opencontainers.org/). This means Docker containers can run on any container orchestrator that supports the OCI standard (like Kubernetes, Docker Swarm, Docker Compose, HashiCorp Nomad, etc.).
+## Why run PHP in containers
-So although we're going to show you best practices with Docker, this means you'll maintain your freedom and flexibility to choose how you want to run your containers.
+- **Same environment everywhere.** Mac, Windows, Linux, and your servers all run the same image.
+- **Configuration lives in Git.** Your `compose.yml` and `Dockerfile` describe the whole stack. You review and roll back changes like code.
+- **No host lock-in.** Any host that runs containers can run your app.
+- **Scaling and rollbacks.** To scale, start more containers from the image. To roll back, start the previous image.
-## How containers work
-If you followed our [installation guide](/docs/getting-started/installation), you've already run containers and seen them in action. Let's break down what actually happened when you ran `docker compose up`.
+Docker and these images are free and open source. You don't need a paid platform.
-### Images vs Containers
-Think of an **image** as a blueprint and a **container** as the actual running instance of that blueprint. When you specified `image: serversideup/php:8.5-fpm-nginx` in your `compose.yml`, you told Docker to:
+## Terms you'll see
-1. Download the `serversideup/php:8.5-fpm-nginx` image (the blueprint)
-2. Create a container from that image (the running instance)
-3. Start the container with your specified configuration
+| Term | Meaning |
+|---|---|
+| Image | A read-only template for a container, such as `serversideup/php:8.5-fpm-nginx`. |
+| Container | A running instance of an image. Many containers can run from one image, each with its own processes and files. |
+| Tag | The part after the `:` that picks a version of an image. Our tags name the PHP version and variation: `8.5-fpm-nginx`. |
+| Registry | Where images are stored and pulled from. We publish to [Docker Hub](https://hub.docker.com/r/serversideup/php){target="_blank"} and [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php){target="_blank"}. |
+| Volume | A directory from your machine, or one Docker manages, mounted into a container. Files in it outlive the container. |
+| Port mapping | Forwards a port on your machine to a port in the container, such as `80:8080`. |
+| Environment variable | A setting passed to the container at startup. Our images read them to configure PHP and the web server. |
-You can create multiple containers from the same image — just like you can build multiple houses from the same blueprint. Each container runs independently with its own isolated filesystem and processes.
+
-### Understanding image layers
-Container images are built in layers, like a stack of pancakes. Each layer adds something new: the operating system, PHP, web servers, and configurations. When you pull an image, Docker downloads only the layers you don't already have, making updates incredibly efficient.
+### Docker is one tool for running containers
-This is why switching from PHP 8.3 to 8.4 in the installation guide was so fast — most of the layers were already on your machine, and Docker only downloaded the differences.
+[Docker](https://www.docker.com/){target="_blank"} builds and runs containers. The images follow the [Open Container Initiative (OCI)](https://opencontainers.org/){target="_blank"} standard, so the same image runs on Kubernetes, Docker Swarm, HashiCorp Nomad, and other OCI platforms. These docs use Docker Compose.
-::tip
-Image tags like `8.3-fpm-nginx` and `8.4-frankenphp` aren't just version numbers — they describe the entire stack that's included in that image. The tag tells you the PHP version and which variation (web server stack) you're getting.
-::
+### Images are built in layers
-## Key concepts
+An image is a stack of layers: the operating system, PHP, the web server, and our configuration. Your own `Dockerfile` adds layers on top, such as your code. When you pull an image, Docker only downloads the layers you don't already have.
-### Service names
-A **service** in Docker Compose is a named container definition. Think of it as a label for a specific part of your application — like your web server, database, or cache. Each service runs in its own container and can be managed independently.
+## How a `compose.yml` uses these terms
-In your `compose.yml`, when you define a service called `php`, you're telling Docker Compose "this is my PHP application server." You can name it anything you want — we use `php` to keep it simple, but `web`, `app`, or `backend` would work just as well.
+This is the file from the installation guide:
-```yml [compose.yml] {2}
+```yaml [compose.yml]
services:
php:
image: serversideup/php:8.5-fpm-nginx
ports:
- 80:8080
+ volumes:
+ - ./:/var/www/html
+ environment:
+ PHP_MEMORY_LIMIT: "512M"
```
-Whenever you run commands, you'll need to reference the service name you gave it.
-
-```bash [Terminal]
-docker compose run php php -v
-```
-
-If you named your service `app`, you would run the following command:
-
-```bash [Terminal]
-docker compose run app php -v
-```
-
-### Volumes: Sharing files with containers
-When you added this to your `compose.yml`:
-
-```yml
-volumes:
- - ./:/var/www/html
-```
-
-You created a **bind mount** that connects your local project directory to the container's `/var/www/html` directory. This means:
+| Line | What it does | In this file |
+|---|---|---|
+| `php` | Names the service. You pick the name, and commands use it. | `docker compose exec php php -v` runs `php -v` in this service. |
+| `image` | The image the container runs. | PHP 8.5 with NGINX. See [Choosing an image](/docs/getting-started/choosing-an-image). |
+| `ports` | Forwards a port on your machine to the container, as `host:container`. | `http://localhost` (port `80`) reaches NGINX on `8080`. |
+| `volumes` | Mounts a folder from your machine into the container. | Your project appears at `/var/www/html`, and edits show up on the next request without a rebuild. |
+| `environment` | Sets environment variables, which our images turn into PHP and web server settings at startup. | `PHP_MEMORY_LIMIT: "512M"` sets `memory_limit`. |
-- Changes you make on your computer are instantly visible inside the container
-- The container serves your actual project files, not a copy
-- When the container stops, your files remain on your computer
+Our images run as an unprivileged user, so web servers listen on `8080` and `8443` inside the container. If another program already uses port `80` on your machine, Docker fails with "port is already allocated". Use another host port, such as `8000:8080`, and visit `http://localhost:8000`.
-This is perfect for development because you can edit files with your favorite editor and see changes immediately without rebuilding the container.
+The [environment variable specification](/docs/reference/environment-variable-specification) lists every setting you can change this way.
::note
-In production, you'll typically build your application files directly into the image instead of using volumes. We cover this in our [packaging guide](/docs/deployment-and-production/packaging-your-app-for-deployment).
+In production, copy your code into the image instead of mounting it. See [Packaging your app for deployment](/docs/deployment-and-production/packaging-your-app-for-deployment).
::
-### Ports: Accessing your container
-The `ports` configuration maps ports between your computer and the container:
+## Common Docker Compose commands
-```yml
-ports:
- - 80:8080
-```
+Run these from the directory that holds `compose.yml`.
-This means "take port 8080 inside the container and make it available on port 80 on my computer." When you visited `http://localhost` in your browser, you were actually connecting to port 80 on your computer, which Docker forwarded to port 8080 inside the container where NGINX or FrankenPHP was listening.
+| Command | What it does |
+|---|---|
+| `docker compose up` | Creates and starts the containers, with logs in your terminal. Press :kbd{value="ctrl"} + :kbd{value="C"} to stop them. |
+| `docker compose up -d` | Same, in the background. |
+| `docker compose ps` | Lists the project's containers and their health. |
+| `docker compose logs -f` | Follows the logs. Press :kbd{value="ctrl"} + :kbd{value="C"} to stop following. |
+| `docker compose exec php php -v` | Runs a command in the running `php` container. |
+| `docker compose stop` | Stops the containers without removing them. `docker compose start` resumes them. |
+| `docker compose down` | Stops and removes the containers and the project's network. Images and named volumes stay. |
+| `docker compose up --build` | Rebuilds images from your `Dockerfile` before starting. |
+| `docker compose down --rmi all` | Also removes the images, so the next `up` pulls them again. |
::warning
-Only one service can use a port at a time. If you get a "port already in use" error, another service on your computer is already using that port. Try using a different port like `8000:8080`.
-::
-
-### Environment variables: Configuring your container
-Environment variables let you configure your container without modifying the image. When you set:
-
-```yml
-environment:
- PHP_UPLOAD_MAX_FILE_SIZE: "500M"
- PHP_OPCACHE_ENABLE: "1"
-```
-
-You're telling our PHP images to adjust PHP's configuration. Our images read these variables at startup and automatically configure PHP accordingly. This means the same image can be configured differently for development, staging, and production — just by changing environment variables.
-
-::tip{to="/docs/reference/environment-variable-specification"}
-Check out our [Environment Variable Specification](/docs/reference/environment-variable-specification) to see all available configuration options.
-::
-
-## The container lifecycle
-Understanding the lifecycle helps you work more effectively with containers:
-
-1. **Create & Start** - `docker compose up` creates and starts containers
-2. **Running** - Your application is active and serving requests
-3. **Stop** - `docker compose down` stops containers (but keeps configurations)
-4. **Remove** - Containers are deleted (but images remain for faster restarts)
-
-Important to know:
-- Stopping a container doesn't always delete it
-- Restarting is fast because the image is already downloaded
-- Any data stored inside the container (not in volumes) is lost when the container is removed
-- Your images remain on your machine until you explicitly remove them
-
-## Common Docker commands
-Here are the essential commands you'll use regularly:
-
-```bash [Terminal]
-# Start containers (creates if they don't exist)
-docker compose up
-
-# Start in background (detached mode)
-docker compose up -d
-
-# Stop and remove containers
-docker compose down
-
-# View running containers
-docker compose ps
-
-# View container logs
-docker compose logs
-
-# Follow logs in real-time
-docker compose logs -f
-
-# Execute a command in a running container
-docker compose exec php php -v
-
-# Rebuild containers after image changes
-docker compose up --build
-
-# Remove images to force fresh download
-docker compose down --rmi all
-```
-
-::tip
-The `-f` flag in `docker compose logs -f` means "follow" — it keeps showing new log entries as they happen. Press :kbd{value="ctrl"} + :kbd{value="C"} to stop following.
+Anything a container writes outside a volume is deleted when the container is removed, including by `docker compose down`. Keep uploads, databases, and other data you need in a volume.
::
-## What's next?
-Now that you understand the fundamentals of containers, let's create your first containerized PHP project.
+## Next step
-:u-button{to="/docs/getting-started/installation" label="Create your first Docker PHP project" aria-label="Create your first Docker PHP project" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+:u-button{to="/docs/getting-started/installation" label="Run your first PHP container" aria-label="Run your first PHP container" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
diff --git a/docs/content/docs/1.getting-started/3.installation.md b/docs/content/docs/1.getting-started/3.installation.md
index e1680e47d..3cbb9de7e 100644
--- a/docs/content/docs/1.getting-started/3.installation.md
+++ b/docs/content/docs/1.getting-started/3.installation.md
@@ -1,170 +1,152 @@
---
head.title: 'Installation - Docker PHP - Server Side Up'
-description: 'Learn how to get started with serversideup/php Docker Images.'
+description: 'Run your first PHP app with serversideup/php and Docker Compose, then change its PHP settings with environment variables.'
layout: docs
title: Installation
---
::lead-p
-`serversideup/php` is compatible with any container orchestrator that supports Docker images (Kubernetes, Docker Swarm, Docker Compose, HashiCorp Nomad, etc.). All images are hosted on [DockerHub](https://hub.docker.com/r/serversideup/php) and [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php) for free. Containers default to running Debian, but Alpine images are also available.
+Run a PHP 8.5 app with NGINX in a container, then change its PHP settings without touching a config file. It takes a few minutes and needs only Docker.
::
-## Quick Start
::tip{to="/docs/getting-started/container-basics"}
-New to containers? Check out our [Container Basics guide](/docs/getting-started/container-basics) for a beginner-friendly introduction to Docker and containerization.
+New to containers? Read Container basics first for the terms used here.
::
-In order to run containers, we need a container engine installed such as Docker. You can follow [Docker's installation guide](https://docs.docker.com/get-started/get-docker/) to get started. Confirm Docker is working by running these commands in your terminal:
+## Install Docker
+
+Install Docker by following [Docker's installation guide](https://docs.docker.com/get-started/get-docker/){target="_blank"}, then confirm Docker and Docker Compose both print a version:
+
```bash [Terminal]
-# Check Docker version
docker --version
-
-# Check Docker Compose version
docker compose version
```
-If you see version numbers after running the commands, you're ready to go!
+## Create a sample project
-### Create a new sample project
-Let's create a new sample project to test our Docker setup. Open up your terminal and run the following commands:
+Create a project directory with a `public` folder and move into it:
```bash [Terminal]
-# Create a project directory with a public directory
mkdir -p my-php-project/public
-
-# Change directory to our project
cd my-php-project
```
-Once we're in our project directory, we will want to create two files:
+Create these two files. `public/index.php` is the page NGINX serves, because the web root in our NGINX image is `/var/www/html/public`. `compose.yml` tells Docker which image to run and how.
-1. `public/index.php` - The file that will be served when someone visits our site
-1. `compose.yml` - The place where we will put our configuration files
+::code-tree{defaultValue="compose.yml"}
-The content of these files should look like:
-
-::code-tree{defaultValue="public/index.php"}
-
-```yml [compose.yml]
+```yaml [compose.yml]
services:
php:
- # Choose our PHP version and variation
- image: serversideup/php:8.3-fpm-nginx
- # Expose localhost:80 to NGINX's port 8080
+ image: serversideup/php:8.5-fpm-nginx
+ # Your machine's port 80 goes to NGINX on port 8080
ports:
- 80:8080
- # Mount current directory to /var/www/html
+ # Mount this project at /var/www/html
volumes:
- ./:/var/www/html
- # PHP settings that we can override
environment:
PHP_UPLOAD_MAX_FILE_SIZE: "250M"
- PHP_OPCACHE_ENABLE: "0"
```
```php [public/index.php]
```
::
-Save these files and ensure they are in the exact structure above.
+## Start the container
-### Bringing up your PHP app
-From your project root directory, run the following command to start your PHP app:
+Before you start, check that:
-::warning
-Make sure you are running the following commands from your **project root directory** (`my-php-project`). If you do not have `compose.yml` in the same directory as you run this command, the command will not work. Also, make sure you don't have any other containers or services that are currently running on port 80. If you do, you will need to stop them before running the following command.
-::
+- [ ] Your terminal is in `my-php-project`, next to `compose.yml`.
+- [ ] Nothing else on your machine is using port `80`. If something is, change the mapping to `8000:8080` and use `http://localhost:8000` below.
::steps{level="4"}
-#### Start the PHP container
+#### Start the container
+
```bash [Terminal]
docker compose up
```
-You'll see the logs appear in your terminal. **Keep your terminal open** as we'll need it to control the container.
-
-
-
-#### Viewing your PHP app
-To view your PHP app, open your browser and navigate to `http://localhost`. You should see the PHP info page showing PHP `8.3` with the `fpm-nginx` variation:
-
+Docker pulls the image and starts the container. Keep this terminal open to see the logs.
-You can see the PHP version is in the upper left corner of the page. But what's also cool is you can see the settings we configured in the `compose.yml` file are being applied.
+
-Look for these values in the PHP info page:
+#### Open the app
-- `upload_max_filesize` - This should show `250M`
-- `opcache.enable` - This should show `Off`
+Go to `http://localhost`. The PHP info page shows:
-::
+- **PHP Version** `8.5.x` at the top
+- **Server API** `FPM/FastCGI`
+- **upload_max_filesize** `250M`, the value you set in `compose.yml`
+- **opcache.enable** `Off`, the image default
-### Making changes
-::caution
-To have our changes take effect, we must restart the containers.
::
-Let's make some upgrades to our PHP app:
-
-1. Let's upgrade to PHP 8.4
-1. Let's use FrankenPHP instead of FPM-NGINX
-2. Turn on OPCache
-3. Increase the upload limit to 500M
+## Change PHP settings
-To do this, we need to need to:
+Our images read environment variables when the container starts. A settings change needs a restart, not a rebuild. Raise the upload limit:
::steps{level="4"}
#### Stop the container
-**Press :kbd{value="ctrl"} + :kbd{value="C"} on the original terminal window** or you can run the following command *in a new terminal window from your project root directory*:
+
+Press :kbd{value="ctrl"} + :kbd{value="C"} in the terminal running the container. From another terminal, you can run this in the project directory instead:
+
```bash [Terminal]
docker compose down
```
-#### Making changes to your PHP app
-Make the updates below to your `compose.yml` file:
+#### Update `compose.yml`
-```yml [compose.yml]{3-4,10-13}
+```yaml [compose.yml]{9-11}
services:
php:
- # Change image to 8.4 with FrankenPHP
- image: serversideup/php:8.4-frankenphp
+ image: serversideup/php:8.5-fpm-nginx
ports:
- 80:8080
volumes:
- ./:/var/www/html
environment:
- # Increase the upload limit to 500M
PHP_UPLOAD_MAX_FILE_SIZE: "500M"
- # Turn OPCache on
- PHP_OPCACHE_ENABLE: "1"
+ PHP_POST_MAX_SIZE: "500M"
+ NGINX_CLIENT_MAX_BODY_SIZE: "500M"
```
-#### Bring the container up again
+An upload has to fit three limits: PHP's file size limit, PHP's request size limit, and NGINX's request size limit. All three default to `100M`.
+
+#### Start it again
+
```bash [Terminal]
docker compose up
```
-You'll see the logs appear in your terminal. **Keep your terminal open** as we'll need it to control the container.
-#### Refresh your browser
+#### Refresh the page
-Check `http://localhost` again and you should see the changes we made:
+`http://localhost` now shows `upload_max_filesize` and `post_max_size` as `500M`.
-
+::
-Holy smokes! We've upgraded to PHP 8.4 and you're using FrankenPHP! You can also find the changes to the PHP settings have been applied.
+## Change the PHP version and web server
-
+The PHP version and web server are part of the image tag, so switching either one is a one-line change. Stop the container and change the `image` line to FrankenPHP on PHP 8.4:
-::
+```yaml [compose.yml]{3}
+services:
+ php:
+ image: serversideup/php:8.4-frankenphp
+```
+
+Run `docker compose up` again. The page now shows **PHP Version** `8.4.x` and **Server API** `FrankenPHP`, and your upload settings still apply.
+
+
-## You've got this 💪
-You've successfully created your first PHP app with Docker. Better yet, you've seen the power of serversideup/php where it's easy to change your PHP version and variation by changing a single line in your configuration file.
+Change it back to `8.5-fpm-nginx` before you continue, or keep whichever fits your app.
-If you're curious what the whole process looks like from Development to Production, check out out guide below.
+## Next steps
-:u-button{to="/docs/deployment-and-production/development-to-production" label="Learn how to deploy your PHP app from Development to Production" aria-label="Learn how to deploy your PHP app from Development to Production" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
\ No newline at end of file
+- Pick the right variation, PHP version, and OS in [Choosing an image](/docs/getting-started/choosing-an-image).
+- See every setting you can change in the [environment variable specification](/docs/reference/environment-variable-specification).
+- Build your code into an image for production with [Packaging your app for deployment](/docs/deployment-and-production/packaging-your-app-for-deployment).
diff --git a/docs/content/docs/1.getting-started/4.these-images-vs-others.md b/docs/content/docs/1.getting-started/4.these-images-vs-others.md
index c309271b4..b20889efe 100644
--- a/docs/content/docs/1.getting-started/4.these-images-vs-others.md
+++ b/docs/content/docs/1.getting-started/4.these-images-vs-others.md
@@ -1,325 +1,56 @@
---
-title: These Images vs Others
-description: 'Discover why thousands of developers trust serversideup/php for their production PHP applications.'
+title: These images vs others
+description: 'What serversideup/php adds on top of the official PHP and FrankenPHP Docker images.'
---
-## The Problem with Traditional PHP Deployment
-
-If you've ever deployed a PHP application to production, you've probably experienced one (or all) of these frustrations:
-
-- **"It works on my machine"** - Your local environment doesn't match production, leading to mysterious bugs that only appear after deployment
-- **Configuration Hell** - Spending hours tweaking `php.ini`, `www.conf`, and web server configs across multiple servers
-- **Security Vulnerabilities** - Running as root, outdated extensions, or misconfigured permissions exposing your application to attacks
-- **Manual Server Management** - SSH-ing into servers to update PHP, install extensions, or troubleshoot issues
-- **Inconsistent Environments** - Each server is slightly different, making debugging and scaling a nightmare
-- **Framework-Specific Tweaks** - Researching and applying dozens of optimizations for Laravel or WordPress performance
-
-You're not alone. These are the exact problems that led us to create `serversideup/php`.
-
-## How serversideup/php Solves These Problems
-
-`serversideup/php` is built on the official PHP images but adds everything needed for real-world production use:
-
-- ✅ **Works Identically Everywhere** - Same container runs on your laptop, CI/CD, and production
-- ✅ **Zero Configuration Required** - Production-ready defaults with simple environment variable customization
-- ✅ **Secure by Default** - Runs as unprivileged user, hardened for the open internet
-- ✅ **Batteries Included** - Composer, common extensions, and helpful utilities pre-installed
-- ✅ **Framework Optimized** - Pre-configured for Laravel and WordPress best practices
-- ✅ **Modern Architecture** - FrankenPHP, S6 Overlay, native health checks, and multi-process support
-
-::note
-**Trusted by the Community** - Over 1 million Docker image pulls and actively used by Laravel and PHP developers worldwide.
-::
-
-## Feature Comparison
-
-See how `serversideup/php` stacks up against other PHP deployment options:
-
-| Feature | **Traditional Server** | **Official PHP Images** | **serversideup/php** |
-|---------|------------------------|-------------------------|----------------------|
-| Consistent environments | ❌ | ✅ | ✅ |
-| Easy to scale | ❌ | ✅ | ✅ |
-| Base OS Options | Manual Setup | Debian, Alpine | Debian, Alpine |
-| PHP Version Management | Manual Updates | Easy Upgrades | Easy Upgrades |
-| Multi-arch support | ❌ | ✅ | ✅ |
-| Production-ready defaults | ⚠️ Manual | ❌ | ✅ |
-| Runs as non-root user | ⚠️ Manual | ❌ | ✅ |
-| Variable-first configuration | ❌ | ❌ | ✅ |
-| Includes `composer` | ⚠️ Manual | ❌ | ✅ |
-| Includes `install-php-extensions` | ❌ | ❌ | ✅ |
-| Built-in security hardening | ⚠️ Manual | ❌ | ✅ |
-| Laravel & WordPress optimizations | ⚠️ Manual | ❌ | ✅ |
-| NGINX + FPM variation | ⚠️ Manual | ❌ | ✅ |
-| FrankenPHP support | ❌ | ❌ | ✅ |
-| Native health checks | ⚠️ Manual | ❌ | ✅ |
-| S6 Overlay init system | ❌ | ❌ | ✅ |
-| Published Registries | N/A | DockerHub | [DockerHub](https://hub.docker.com/r/serversideup/php) + [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php) |
-
-## Key Advantages Explained
-
-### Security First: Unprivileged by Default
-
-::caution
-Running containers as `root` in production is a critical security vulnerability. If your application is compromised, an attacker gains root access to your container which could lead to a full system compromise.
-::
-
-Our images run as the `www-data` user by default, following the principle of least privilege. This means:
-
-- **Limited Blast Radius** - If your application is compromised, don't have root privileges
-- **Kubernetes Compatible** - Many Kubernetes clusters require non-root containers by policy
-- **Production Best Practice** - Aligns with NIST and CIS security benchmarks
-
-We also include additional security hardening:
-- Disabled dangerous PHP functions by default (but you control them)
-- Proper file permissions out of the box
-- Customizable trusted proxy configuration (Cloudflare, Sucuri, local, or off) for accurate IP logging
-- Regular security updates from official PHP base images
-
-### Performance Optimized
-
-Every image ships tuned defaults for PHP, OPcache, and PHP-FPM:
-
-**OPcache Configuration**
-- Pre-configured for optimal memory usage and caching strategy
-- One variable, `PHP_OPCACHE_ENABLE`, turns OPcache on with tuned defaults
-- Smart defaults that work for most applications
-
-**Process Management**
-- PHP-FPM tuned for typical low resource usage for Laravel/WordPress workloads
-- S6 Overlay for intelligent process supervision
-- Graceful shutdown handling for zero-downtime deployments
-
-**Modern Options**
-- FrankenPHP support for incredible performance gains (2-3x faster than FPM in many claims made by developers)
-- HTTP/2 and HTTP/3 ready configurations
-
-::tip
-Need to customize performance settings? Just set an environment variable like `PHP_OPCACHE_ENABLE=1` or `PHP_MEMORY_LIMIT=512M`. No config files needed.
-::
-
-### Developer Experience: Variable-First Configuration
-
-Stop editing config files. Stop rebuilding images for simple changes. Just set environment variables:
-
-```yml [compose.yml] {5-9,11-12}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- environment:
- # Change any PHP setting with environment variables
- PHP_MEMORY_LIMIT: "512M"
- PHP_UPLOAD_MAX_FILE_SIZE: "100M"
- PHP_MAX_EXECUTION_TIME: "180"
- PHP_OPCACHE_ENABLE: "1"
-
- # Run Migrations, Storage Link, Caching, and more
- AUTORUN_ENABLED: "true"
-```
-
-No Dockerfile modifications. No config file mounting. No image rebuilds. Just simple environment variables with production-ready defaults.
-
-:u-button{to="/docs/reference/environment-variable-specification" label="View all environment variables" aria-label="View all environment variables" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### Production-Ready Out of the Box
-
-Our images are built for the wild internet:
-
-**Security Hardening**
-- Unprivileged user execution
-- Disabled dangerous PHP functions (you control which ones)
-- Proper file permissions and ownership
-- Regular security updates
-
-**Monitoring & Observability**
-- Built-in health check endpoints
-- All logs to STDOUT/STDERR for centralized logging
-- Compatible with Prometheus, DataDog, and other monitoring tools
-
-**Deployment Features**
-- Zero-downtime deployment support
-- Graceful shutdown handling
-- Queue worker and scheduler support for Laravel
-- Automatic migration running on container start (optional)
-
-**Infrastructure as Code**
-- Version your entire PHP stack in a `compose.yml` file
-- Reproduce every environment with 100% consistency
-- Easy rollbacks to previous versions
-
-### Framework Optimized for Laravel & WordPress
-
-We've done the hard work of optimizing for PHP's most popular frameworks:
-
-**Laravel Automations**
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- environment:
- # Run migrations, storage link, caching, and more
- AUTORUN_ENABLED: "true"
-```
-
-:u-button{to="/docs/framework-guides/laravel/automations" label="Learn More About Laravel Automations" aria-label="Learn More About Laravel Automations" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-**WordPress Optimizations**
-- Pre-installed PHP extensions WordPress needs
-- Optimized PHP settings for WordPress performance
-- Support for popular WordPress hosting patterns
-
-**All Frameworks Welcome**
-
-While we optimize for Laravel and WordPress, our images work great with:
-- Symfony
-- CodeIgniter
-- Drupal
-- Joomla
-- Custom PHP applications
-
-### Batteries Included
-
-Unlike the official PHP images, we include tools you'll actually use:
-
-**Composer**
-- Pre-installed and ready to use
-- No need to install it in every Dockerfile
-- Supports Composer v2 for lightning-fast installs
-
-**install-php-extensions**
-- The popular [mlocati/docker-php-extension-installer](https://github.com/mlocati/docker-php-extension-installer) included
-- Install any PHP extension with a single command
-- Handles all dependencies automatically
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-cli
-
-# Switch to root to install extensions
-USER root
-
-# Install any PHP extension easily
-RUN install-php-extensions bcmath imagick mongodb
-
-# Switch back to unprivileged user
-USER www-data
-```
-
-**Modern Init System**
-- S6 Overlay for our FPM-Apache and FPM-NGINX variations
-- Proper process supervision in containers
-- Better than Supervisor for containerized workloads
-- Graceful handling of signals for zero-downtime deployments
-
-**Multiple Variations**
-Choose the right tool for your use case:
-- [`cli`](/docs/image-variations/cli) - Command-line scripts, Composer, CI/CD
-- [`fpm`](/docs/image-variations/fpm) - Just PHP-FPM (bring your own web server)
-- [`fpm-nginx`](/docs/image-variations/fpm-nginx) - PHP-FPM + NGINX (most popular)
-- [`fpm-apache`](/docs/image-variations/fpm-apache) - PHP-FPM + Apache
-- [`frankenphp`](/docs/image-variations/frankenphp) - Modern, incredibly fast (2-3x FPM performance)
-
-## Real-World Impact
-
-Don't just take our word for it. Here's what developers are experiencing:
-
-::note
-**By the Numbers**
-- **1,000,000+** Docker image pulls
-- **2,000+** GitHub stars
-- **Active Community** with regular updates and contributions
-- **Production-Proven** across startups to enterprise applications
+::lead-p
+The official PHP images are a clean starting point. Before one can serve a web app in production, you add a non-root user, a web server, Composer, and the extensions your app needs. `serversideup/php` starts from the same source and ships with those parts in place.
::
-**Time Savings**
-- **Minutes vs Hours** - Go from zero to production-ready PHP in minutes, not hours of server configuration
-- **Consistent Deployments** - Eliminate "works on my machine" debugging sessions
-- **Pre-configured** - Stop researching optimal PHP settings for Laravel
+## Same PHP, production defaults
-**Better Security**
-- **Hardened by Default** - Security best practices built-in, not bolted on
-- **Regular Updates** - Based on official PHP images with security patches
-- **Audit Trail** - Infrastructure as code means every change is tracked
+We compile PHP from the same [docker-library/php](https://github.com/docker-library/php){target="_blank"} Dockerfiles as the official images. Your code runs the same way. The defaults are what change.
-**Happier Developers**
-- **Simple Configuration** - Environment variables instead of config file archaeology
-- **Modern Tools** - FrankenPHP, native health checks, and container-native features
-- **Community Support** - Active community and comprehensive documentation
+| | Official `php` | `serversideup/php` |
+|---|---|---|
+| Default user | `root` | `www-data` (UID 33 on Debian, 82 on Alpine) |
+| Web server ports | `80` | `8080` and `8443` (`fpm`: `9000`) |
+| PHP settings from environment variables | ❌ | ✅ |
+| Composer and `install-php-extensions` | ❌ | ✅ |
+| `pcntl`, `pdo_mysql`, `pdo_pgsql`, `redis`, `zip` | ❌ | ✅ |
+| NGINX + PHP-FPM image | ❌ | ✅ |
+| Apache image | `mod_php` | PHP-FPM |
+| FrankenPHP image | ❌ | ✅ |
+| Built-in health check | ❌ | ✅ (all but `cli`) |
+| Real client IPs behind Cloudflare | ❌ | ✅ |
+| Laravel automations at startup | ❌ | ✅ (opt-in) |
+| Debian, Alpine, `amd64`, `arm64` | ✅ | ✅ |
+| Registries | Docker Hub | Docker Hub, GitHub Packages |
-## Making the Switch
+## New PHP patches without waiting for a base image
-### From Traditional Servers (LAMP/LEMP)
+We build PHP from php.net ourselves instead of waiting for a new base image on Docker Hub. We check php.net every hour, and a new patch release starts a build right away. The images publish once that build passes its tests.
-If you're currently managing PHP on traditional servers, the switch to containers might seem daunting, but it's easier than you think:
+The floating tags, such as `8.5-fpm-nginx`, also rebuild each Tuesday to pick up operating system security fixes. [How images are built](/docs/security/how-images-are-built) has the details.
-**Benefits You'll Gain**
-- Identical environments from development to production
-- Scale horizontally by adding more containers
-- Roll back bad deployments in seconds
-- Version control your entire infrastructure
+## Images you can verify
-See our quick start guide to run your first PHP container.
-:u-button{to="/docs/getting-started/installation" label="Quick Start Guide" aria-label="Quick Start Guide" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Every image is signed and carries an SBOM and build provenance. Check them with `cosign` before you deploy. [Verifying images](/docs/security/verifying-images) shows the commands.
-### From Official PHP Images
+## Requests for `.env` get a 403
-Already using Docker with official PHP images? Switching is trivially easy.
+NGINX, Apache, and Caddy refuse requests for dotfiles such as `.env` and for files ending in `.log`, `.sql`, or `.bak`. Your app's responses get `X-Frame-Options`, `X-Content-Type-Options`, and `Referrer-Policy` headers.
-:u-button{to="/docs/guide/migrating-from-official-php-images" label="Official PHP Migration Guide" aria-label="Official PHP Migration Guide" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+## FrankenPHP with the same defaults
-### From Other Docker Images
+Our `frankenphp` variation builds FrankenPHP on our PHP base. It shares every default above. It runs as `www-data` on `8080` and `8443` and serves `/var/www/html/public`.
-Switching from Bitnami, custom images, or other PHP Docker images is straightforward:
+Caddy's automatic HTTPS and admin API start off (`CADDY_AUTO_HTTPS=off`, `CADDY_ADMIN=off`). That fits a container behind a reverse proxy. Turn HTTPS on when you need it; the [FrankenPHP variation](/docs/image-variations/frankenphp) page explains how.
-**Key Differences to Note**
-- We run as `www-data` (UID 33) by default, not root
-- Configuration via environment variables, not config file mounts
-- Web root is `/var/www/html` by default
-- All variations expose port `8080` (unprivileged port)
+## Switch an existing project
-**Migration Strategy**
-1. Review our [environment variable specification](/docs/reference/environment-variable-specification)
-2. Map your current config to environment variables
-3. Test in development first
-4. Switch image tag in production
+Moving from the official image takes a few changes: the port, the document root, and how you install extensions. The migration guide lists each one.
-::warning
-Always test in a development or staging environment first to ensure your application works correctly with the new image.
-::
-
-## Ready to Get Started?
-
-You're just minutes away from a better PHP deployment experience.
-
-::steps
-
-### Choose Your Path
-
-**Quick Start (New Projects)**
-Follow our installation guide to create your first PHP app with Docker in under 5 minutes.
-
-:u-button{to="/docs/getting-started/installation" label="Quick Start Guide" aria-label="Quick Start Guide" size="md" color="primary" variant="solid" trailing-icon="i-lucide-rocket" class="font-bold text-white bg-blue-600 hover:bg-blue-700"}
-
-**Migration Guide (Existing Apps)**
-Already have a PHP application? Learn how to containerize it with serversideup/php.
-
-:u-button{to="/docs/guide/migrating-from-official-php-images" label="Migration Guides" aria-label="Migration Guides" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+:u-button{to="/docs/getting-started/migrating-from-official-php-images" label="Follow the migration guide" aria-label="Follow the migration guide" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-### Choose an Image Variation
-
-Not sure which image variation is right for you? We have a guide for that.
-
-:u-button{to="/docs/getting-started/choosing-an-image" label="Choosing an Image" aria-label="Choosing an Image" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-::
-
-## Questions?
-
-We're here to help! Check out these resources:
-
-- 📚 [Full Documentation](/docs) - Comprehensive guides and references
-- 💬 [GitHub Discussions](https://github.com/serversideup/docker-php/discussions) - Ask questions and share experiences
-- 🐛 [GitHub Issues](https://github.com/serversideup/docker-php/issues) - Report bugs or request features
-- 🗣️ [Discord](https://serversideup.net/discord) - Join our community and get help from the team and other developers.
-
-::tip
-Join our community! Star us on [GitHub](https://github.com/serversideup/docker-php) and follow updates.
-::
+[Default configurations](/docs/getting-started/default-configurations) lists every package, extension, and startup script we add.
diff --git a/docs/content/docs/1.getting-started/5.choosing-an-image.md b/docs/content/docs/1.getting-started/5.choosing-an-image.md
index 49a64f156..a94d73e6b 100644
--- a/docs/content/docs/1.getting-started/5.choosing-an-image.md
+++ b/docs/content/docs/1.getting-started/5.choosing-an-image.md
@@ -1,164 +1,84 @@
---
-title: Choosing an Image
-description: 'Learn how to choose the right image for your use case.'
+title: Choosing an image
+description: 'Pick a serversideup/php variation, PHP version, and operating system, and read the tag that combines them.'
---
+
::lead-p
-Choosing an image might be a little intimidating because there are so many options, but this ultimately gives you a ton of flexibility. This guide will help simplify your decision making process on choosing the right image.
+Every tag answers three questions: which PHP, which web server, and which operating system. `serversideup/php:8.5-fpm-nginx` is PHP 8.5 with NGINX on Debian. Answer the three below, and you have your tag.
::
-## Registry: Where images are located
-Before diving into the different options available, let's understand where these images come from. The source code is [hosted on GitHub](https://github.com/serversideup/docker-php){target="_blank"} with an automated process that builds and uploads images to container registries:
-
-1. [Docker Hub](https://hub.docker.com/r/serversideup/php)
-2. [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php)
-
-To use these images, you need to specify a certain "tag" for the image you want. For example, the `8.4` version of the `fpm-nginx` variation is tagged as:
-
-```bash
-serversideup/php:8.4-fpm-nginx
-```
-
-This image gives you the latest stable patch version of PHP 8.4 running the `fpm-nginx` variation. Since no registry is specified, it defaults to Docker Hub.
-
-When selecting an image, you'll need to decide on:
-
-1. PHP version
-2. PHP variation
-3. Base operating system
-4. serversideup/php version (for production environments)
-
-## Our most popular images
-All images are intelligently tagged with the PHP version and variation, allowing you to easily select the right image for your use case.
+## Pick the variation that matches how you serve PHP
-If you don't specify a variation, it defaults to `cli` and the latest supported Debian release for that variation.
+| If you want | Use | Learn more |
+|---|---|---|
+| NGINX and PHP-FPM in one container. A common choice for Laravel. | `fpm-nginx` | [fpm-nginx](/docs/image-variations/fpm-nginx) |
+| One process serving PHP through Caddy, with worker mode, HTTP/2, and HTTP/3. | `frankenphp` | [frankenphp](/docs/image-variations/frankenphp) |
+| Apache with `.htaccess` support in front of PHP-FPM, such as for WordPress. | `fpm-apache` | [fpm-apache](/docs/image-variations/fpm-apache) |
+| PHP-FPM on port `9000`, behind a web server or load balancer you run yourself. | `fpm` | [fpm](/docs/image-variations/fpm) |
+| PHP without a server, for scripts, Composer, queue workers, and CI. | `cli` | [cli](/docs/image-variations/cli) |
-Our most popular tags include:
-| ⚙️ Variation | 🚀 Version |
-| ------------ | ---------- |
-| cli | **Debian Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-cli&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-cli&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-cli&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-cli&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-cli-alpine&page=1&ordering=-name) |
-| fpm | **Debian Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-alpine&page=1&ordering=-name) |
-| fpm-apache | **Debian Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-apache&page=1&ordering=-name) |
-| fpm-nginx | **Debian Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-nginx&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-nginx-alpine&page=1&ordering=-name) |
-| frankenphp | **Debian Based** [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-frankenphp&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-frankenphp&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-frankenphp&page=1&ordering=-name) **Alpine Based** ⚠️ Some users are experiencing [known performance issues](https://frankenphp.dev/docs/known-issues/){target="_blank"} with FrankenPHP on Alpine. Consider using the Debian version. [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.5-frankenphp-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.4-frankenphp-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.3-frankenphp-alpine&page=1&ordering=-name) |
-| unit (deprecated) | NGINX stopped supporting NGINX Unit it has been removed from our project. [Learn more →](/docs/image-variations/unit/) |
-
-## The full tag schema
-Our tagging process is quite flexible, and it can become quite complex, but don't worry -- we've spent a ton of time applying sensible defaults to give you the best experience.
-
-The full tag schema is as follows:
-```jinja
-{{registry-url}}/serversideup/php:{{release-prefix}}-{{php-version}}-{{variation-name}}-{{operating-system}}-v{{github-release-version}}
-```
-
-Here's what each part means:
-
-| Option | Default | Other Options |
-|--------|---------|---------------|
-| `{{registry-url}}` Which registry to pull images from. | `''` (Docker Hub) | `ghcr.io` (GitHub Packages) |
-| `{{release-prefix}}` The prefix of the release. | `''` (stable) | `beta` (beta releases) |
-| `{{php-version}}` The version of PHP to use. | Latest stable PHP minor version (ie. `8.5`) | `8.4` `8.3` `8.2` `8.1` (end of life, see below) (you can also specify the full version number, ie. `8.4.1`) |
-| `{{variation-name}}` The name of the variation to use. | `cli` | `fpm` `fpm-apache` `fpm-nginx` `frankenphp` `unit` (deprecated) |
-| `{{operating-system}}` The operating system to use. | `debian` | `alpine` `bookworm` `trixie` |
-| `{{github-release-version}}` The version of the GitHub release to use. | (latest stable release) | See our [GitHub Releases](https://github.com/serversideup/docker-php/releases){target="_blank"} for specific versions. |
-
-::warning{title="PHP 7.4 and 8.0 are no longer built"}
-These images are frozen at their last successful build. The tags stay pullable, but they receive no further security updates, for PHP or for the operating system underneath. Move to PHP 8.2 or newer. See [EOL versions and the legacy-modernization path](https://github.com/serversideup/docker-php/blob/main/SECURITY.md#eol-versions-and-the-legacy-modernization-path) for the full picture.
-
-PHP 8.1 is end of life but still built on `bookworm`, `trixie`, and `alpine3.22`, because those operating systems still receive security updates. PHP itself no longer gets fixes, so treat `8.1` as a stepping stone too.
-::
+A tag that names no variation gets `cli`. Every variation runs as `www-data` and takes its settings from environment variables.
-::note{title="Floating vs. version-pinned tags"}
-Including `{{github-release-version}}` (e.g. `8.4-fpm-nginx-v4.3.5`) creates a **version-pinned tag** that is written once and never updated. Omitting it (e.g. `8.4-fpm-nginx`) gives you a **floating tag** that we rebuild weekly with the latest security patches. The right choice depends on how you balance reproducibility against staying current — see [How our releases work](/docs/getting-started/upgrade-guide#how-our-releases-work) and [Choosing your update strategy](/docs/getting-started/upgrade-guide#choosing-your-update-strategy) in the upgrade guide.
+::note
+The `unit` variation was removed in v4.1.0 (November 21, 2025) after NGINX archived NGINX Unit. See [Unit](/docs/image-variations/unit) to move off it.
::
-## PHP version
-There are many factors to consider when choosing the right PHP version. Best practices include:
-
-1. Choose the latest PHP version (as your dependencies allow)
-2. Choose a PHP version that is receiving [active and security support by PHP](https://www.php.net/supported-versions.php){target="_blank"}
-
-If you need help understanding what's new in each PHP version, check out [the official PHP changelog](https://www.php.net/ChangeLog-8.php){target="_blank"}. This will give you a detailed overview of the changes and improvements in each version.
-
-## PHP variation
-Choose from several variations to suit your needs. Each variation is optimized for specific use cases.
-
-| Variation | Description |
-|-----------|-------------|
-| `cli` | Minimal image for running PHP from the command line only. No web server included. |
-| `fpm` | Runs PHP-FPM without a web server. Ideal for microservices architectures where you need a dedicated PHP backend. |
-| `fpm-apache` | Combines PHP-FPM with Apache as a reverse proxy. Ideal for running WordPress with Docker. |
-| `fpm-nginx` | Combines PHP-FPM with NGINX as a reverse proxy. This is the traditional setup widely adopted for modern PHP applications, including Laravel. |
-| `frankenphp` | Modern application server built on Caddy that runs everything in a single process. Ideal for Laravel/Symfony applications with worker mode support, automatic HTTPS, and HTTP/2 and HTTP/3 protocols. |
-
-### CLI
-The CLI variation is a minimal image designed for running PHP from the command line only. It does not include a web server. Use this variation when you need a disposable and repeatable method for easily running PHP scripts from your terminal.
-
-:u-button{to="/docs/image-variations/cli" label="Learn more about the CLI variation" aria-label="Learn more about the CLI variation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### FPM
-The FPM (Fast Process Manager) variation runs PHP-FPM without a web server. This variation is ideal for microservices architectures where you need a dedicated PHP backend.
+## Use the newest PHP your app supports
-Use this variation when you already have a separate proxy or load balancer handling static content and routing PHP requests to your FPM container. This is commonly used in larger-scale deployments.
+Pick the newest PHP version your dependencies support that php.net still [supports](https://www.php.net/supported-versions.php){target="_blank"}. The [PHP changelog](https://www.php.net/ChangeLog-8.php){target="_blank"} lists what changed in each release.
-:u-button{to="/docs/image-variations/fpm" label="Learn more about the FPM variation" aria-label="Learn more about the FPM variation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+New PHP patches don't wait on us. We check php.net every hour, and a new patch starts a build right away. The images publish once that build passes its tests. `8.6-rc` tracks the newest PHP 8.6 release candidate, such as `serversideup/php:8.6-rc-fpm-nginx`. Use it for testing, not production.
-### FPM-Apache
-The FPM-Apache variation combines PHP-FPM with Apache as a reverse proxy. Apache serves static content directly and forwards PHP requests to PHP-FPM for processing.
+PHP 8.1 is end of life upstream. We still build it for operating system fixes, but PHP itself gets none. PHP 7.4 and 8.0 are no longer built at all. Their tags still pull, frozen with no updates. See [EOL versions and the legacy-modernization path](https://github.com/serversideup/docker-php/blob/main/SECURITY.md#eol-versions-and-the-legacy-modernization-path).
-This is an excellent choice for WordPress sites and applications that specifically require Apache features or `.htaccess` support.
+## Pick Debian or Alpine
-:u-button{to="/docs/image-variations/fpm-apache" label="Learn more about the FPM-Apache variation" aria-label="Learn more about the FPM-Apache variation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Both get the same PHP, the same defaults, and the same release schedule. Choose the one that fits your app.
+| | Debian | Alpine |
+|---|---|---|
+| Download size of `8.5-fpm-nginx` |  |  |
+| C library | glibc | musl |
+| Variations | All five | All but `fpm-apache` |
+| Tags | `-debian` (Trixie), `-trixie`, `-bookworm` | `-alpine` (newest), `-alpine3.22`, `-alpine3.23`, `-alpine3.24` |
-### FPM-NGINX
-The FPM-NGINX variation combines PHP-FPM with NGINX as a reverse proxy. This is the traditional setup that has been widely adopted for modern PHP applications, including many Laravel deployments.
+**Alpine** images are much smaller. They pull faster, start new servers sooner, and take less disk on every host. Alpine also ships fewer packages, which leaves less for a vulnerability scanner to flag.
-NGINX serves static files efficiently and proxies PHP requests to PHP-FPM, providing excellent performance and flexibility.
+**Debian** uses glibc, which most prebuilt binaries and PHP extensions expect. Something that works on your laptop or in a typical CI runner is more likely to work unchanged. That's why a tag with no OS gets Debian Trixie.
-:u-button{to="/docs/image-variations/fpm-nginx" label="Learn more about the FPM-NGINX variation" aria-label="Learn more about the FPM-NGINX variation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Before you pick Alpine, check that every extension and package your app needs builds against musl. FrankenPHP has [known performance issues](https://frankenphp.dev/docs/known-issues/){target="_blank"} on Alpine upstream. `-alpine` gets the newest Alpine for that PHP version: Alpine 3.24, or Alpine 3.22 for PHP 8.1.
-### FrankenPHP
-The FrankenPHP variation is a modern application server built on top of the Caddy web server. It runs everything in a single process, eliminating the complexity of managing PHP-FPM and a separate web server.
+Whichever you choose, stay on a release that still gets security updates. When a release reaches end of life, we stop building it and its tags freeze, as Debian Bullseye and Alpine 3.16 did. Check the [Debian](https://endoflife.date/debian){target="_blank"} and [Alpine](https://endoflife.date/alpine-linux){target="_blank"} lifecycles.
-Use this variation for Laravel or Symfony applications that can benefit from worker mode, automatic HTTPS, and modern protocols like HTTP/2 and HTTP/3. This is the recommended variation for new projects seeking cutting-edge performance.
+## Every combination we build
-:u-button{to="/docs/image-variations/frankenphp" label="Learn more about the FrankenPHP variation" aria-label="Learn more about the FrankenPHP variation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Every image runs on `amd64` and `arm64`, from Docker Hub or GitHub Packages. This matrix shows which PHP versions each variation is built for on each operating system:
-### Unit (deprecated)
-::warning{to="https://github.com/nginx/unit?tab=readme-ov-file#nginx-unit" target="_blank"}
-In October 2025, NGINX stopped supporting NGINX Unit and archived the project. NGINX Unit has been removed from our project. [See the official announcement →](https://github.com/nginx/unit?tab=readme-ov-file#nginx-unit)
-::
-
-## Operating Systems
-Choosing an operating system comes down to a few preferences, but ultimately you need to make sure your dependencies are available for the operating system you choose.
-
-| Operating System | Description |
-|-----------|-------------|
-| `debian` (default) | Debian is a popular Linux distribution that is known for its stability and reliability. It is the default operating system for our images. |
-| `alpine` | Alpine is a lightweight Linux distribution that is known for its small size and low resource usage. |
-
-::warning{title="Choose an OS release that's still supported"}
-We rebuild an image only while the operating system release underneath still receives security updates from its distribution (Debian LTS counts). Once a release reaches end of life, a rebuild cannot deliver any patches, so we stop rebuilding that base and its tags freeze where they are. That is what happened to Debian Bullseye and Alpine 3.16.
+| Variation | `bookworm` | `trixie` | `alpine3.22` | `alpine3.23` | `alpine3.24` |
+|---|---|---|---|---|---|
+| `cli`, `fpm`, `fpm-nginx` | 8.1 to 8.6-rc | 8.1 to 8.6-rc | 8.1 | 8.2 to 8.6-rc | 8.2 to 8.6-rc |
+| `fpm-apache` | 8.1 to 8.6-rc | 8.1 to 8.6-rc | ❌ | ❌ | ❌ |
+| `frankenphp` | 8.3 to 8.6-rc | 8.3 to 8.6-rc | ❌ | 8.3 to 8.6-rc | 8.3 to 8.6-rc |
-Be aware that EOL bases also ship EOL versions of other software like NGINX and OpenSSL, and no amount of `apt upgrade` will get you newer ones, because the distribution itself has stopped releasing fixes.
+## Every tag reads the same way
-Before picking a base, confirm it's currently supported:
+```txt
+{{registry}}/serversideup/php:{{prefix}}-{{php-version}}-{{variation}}-{{os}}-{{release}}
+```
-- [Debian release lifecycle](https://endoflife.date/debian){target="_blank"}
-- [Alpine Linux release lifecycle](https://endoflife.date/alpine-linux){target="_blank"}
+Every part is optional except the repository.
-If you must start on an EOL base, treat it as a stepping stone. See [EOL versions and the legacy-modernization path](https://github.com/serversideup/docker-php/blob/main/SECURITY.md#eol-versions-and-the-legacy-modernization-path) for the recommended migration approach.
-::
+| Part | Default | Other values |
+|---|---|---|
+| `{{registry}}` | Docker Hub | `ghcr.io` for GitHub Packages |
+| `{{prefix}}` | none (stable) | `beta` |
+| `{{php-version}}` | newest stable minor | `8.4`, `8.1`, `8.6-rc`, a major such as `8`, or a patch such as `8.4.27` |
+| `{{variation}}` | `cli` | `fpm`, `fpm-nginx`, `fpm-apache`, `frankenphp` |
+| `{{os}}` | `debian` (Trixie) | `bookworm`, `trixie`, `alpine`, `alpine3.22`, `alpine3.23`, `alpine3.24` |
+| `{{release}}` | none (floating tag) | a [GitHub release](https://github.com/serversideup/docker-php/releases){target="_blank"} such as `v5.0.5` |
-### Specific versions
-::note
-Not all operating systems are available for all image variations and PHP versions. Double check [Docker Hub](https://hub.docker.com/r/serversideup/php/tags){target="_blank"} and [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php){target="_blank"} for the most accurate list of available tags.
-::
-We also support specific versions of operating systems. So this means that you can choose versions like:
+## Floating tags stay patched, pinned tags never change
-- `bookworm`
-- `trixie`
-- `alpine3.23`
-- `alpine3.24`
-- `alpine3.22` (PHP 8.1 only)
+A tag without a release, such as `8.5-fpm-nginx`, floats. We rebuild it with every PHP patch and each week with operating system security fixes.
+A tag with a release, such as `8.5-fpm-nginx-v5.0.5`, is pinned. It never changes. The [upgrade guide](/docs/upgrading/upgrade-guide) explains when to use each.
diff --git a/docs/content/docs/1.getting-started/6.default-configurations.md b/docs/content/docs/1.getting-started/6.default-configurations.md
index e9509f253..0c59e7d63 100644
--- a/docs/content/docs/1.getting-started/6.default-configurations.md
+++ b/docs/content/docs/1.getting-started/6.default-configurations.md
@@ -1,178 +1,129 @@
---
-head.title: 'Installation - Docker PHP - Server Side Up'
-description: 'Learn how to get started with serversideup/php Docker Images.'
+head.title: 'Default Configurations - Docker PHP - Server Side Up'
+description: 'The user, ports, PHP settings, extensions, packages, health checks, logging, and startup scripts every serversideup/php image ships with.'
layout: docs
+title: Default configurations
---
::lead-p
-`serversideup/php` is configured for real-world deployments right out of the box. This saves you many hours so you can launch faster than ever.
+The official PHP image gives you PHP. Everything a production container needs on top of that is yours to add: a safe user, the right extensions, health checks, and logs your platform can read. Our images ship with all of it set up. Here's every default, and how to change each one.
::
-## Production-ready and optimized for Laravel & WordPress
-All values are defaulted to improve security and performance. We also spent the time to carefully review official documentation and include packages that are required specifically for Laravel and WordPress.
+## Runs as `www-data`, not root
-## Unprivileged by Default
+Every image runs as `www-data`. If someone exploits your app, they get an unprivileged user inside the container instead of root. Many Kubernetes clusters also require non-root containers.
-All images default to running as the OS-native `www-data` user instead of `root`. This is a critical security practice that limits what an attacker can do if they compromise your container.
+On Linux, only root can listen on ports below 1024. Our web servers use higher ports:
-### Why this matters
-Running as a non-root user means:
+| Variation | Listens on |
+|---|---|
+| `cli` | nothing |
+| `fpm` | `9000` (FastCGI) |
+| `fpm-nginx`, `fpm-apache`, `frankenphp` | `8080` (HTTP) and `8443` (HTTPS) |
-- **Better security**: If someone exploits a vulnerability in your app, they can't gain root access to your container or host system
-- **Production-ready**: This follows Docker and Kubernetes security best practices
-- **Easier compliance**: Many security policies require non-root containers
+Map them to `80` and `443` on the host (`80:8080`) or put a reverse proxy in front. See [Configuring SSL](/docs/deployment-and-production/configuring-ssl).
-### What this means for you
-Running unprivileged has two practical implications you should know about:
+`www-data` keeps the operating system's own IDs: `33:33` on Debian and `82:82` on Alpine. Files you mount or copy in must be readable by that user. Switching between Debian and Alpine changes the owner you need. See [Understanding file permissions](/docs/customizing-the-image/understanding-file-permissions).
-#### Different ports for web servers
-On Linux systems, only root can bind to ports below 1024 (like 80 and 443). Since our containers don't run as root, they use higher port numbers by default:
+## Every setting is an environment variable
-| **Variation** | **Default Ports** |
-|---------------|-------------------|
-| cli | (none) |
-| fpm | 9000 |
-| fpm-nginx | HTTP: 8080, HTTPS: 8443 |
-| fpm-apache | HTTP: 8080, HTTPS: 8443 |
-| frankenphp | HTTP: 8080, HTTPS: 8443 |
+PHP, PHP-FPM, and web server settings come from environment variables. The image applies them each time the container starts. The defaults are production values.
-#### File permissions to be aware of
-The `www-data` user has different IDs depending on your base operating system:
-- **Debian**: UID/GID `33:33`
-- **Alpine**: UID/GID `82:82`
+There's no `php.ini` to mount and no config file to edit. Change a setting in your Compose file and restart the container. The [environment variable specification](/docs/reference/environment-variable-specification) lists every variable, its default, and which variations read it.
-::tip
-We kept these as the OS defaults to maintain compatibility with other tools and images. If you're mounting volumes from your host machine or switching between Debian and Alpine, you might need to adjust file permissions.
-::
-
-### Running on standard HTTP/HTTPS ports (80 and 443)
-Don't worry—you have two easy options to run your app on the standard web ports:
-
-#### Docker port mapping (recommended for development)
-Use Docker's built-in port mapping to route traffic from port 80/443 on your host to the container's higher ports:
-
-```bash [Terminal]
-docker run -p 80:8080 -p 443:8443 serversideup/php:8.5-fpm-nginx
-```
-
-This tells Docker: "Take traffic coming to port 80 on my machine and send it to port 8080 in the container."
-
-#### Reverse proxy (recommended for production)
-Use a reverse proxy like [Caddy](https://caddyserver.com/){target="_blank"}, [Traefik](https://traefik.io/){target="_blank"}, or [NGINX](https://nginx.org/){target="_blank"} to handle SSL certificates and route traffic to your containers. This is the preferred approach for production because it:
-- Automatically handles SSL certificate generation and renewal
-- Can route to multiple containers/services
-- Provides additional security features like rate limiting
-
-:u-button{to="/docs/deployment-and-production/configuring-ssl" label="Learn about SSL configuration" aria-label="Learn about SSL configuration" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Default Environment Variables
-Environment variables give you a ton of flexibility to customize your container without the complexity of mounting custom configuration files. By default, these images are set to production-ready values. You can read more about the available environment variables below.
+## Your own ini files still work
-:u-button{to="/docs/reference/environment-variable-specification" label="Learn more about environment variables" aria-label="Learn more about environment variables" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+PHP loads every `.ini` file in `/usr/local/etc/php/conf.d/` in alphabetical order. A later file overrides an earlier one. Our image adds two:
-## Default PHP INI Settings
+| File | Purpose |
+|---|---|
+| `serversideup-docker-php.ini` | Our defaults. Each setting reads an environment variable, such as `memory_limit = ${PHP_MEMORY_LIMIT}`. [View it on GitHub](https://github.com/serversideup/docker-php/blob/main/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini){target="_blank"}. |
+| `zzz-serversideup-docker-php-debug.ini` | Empty unless `LOG_OUTPUT_LEVEL=debug`, which writes `display_errors` and `error_reporting` overrides into it. |
-| **PHP INI file** | **Description** |
-|-----------------|-----------------|
-| **Image Default**
`/usr/local/etc/php/conf.d/serversideup-docker-php.ini` | This is our production-ready PHP ini file that accepts environment variables. You can [review it in greater detail on GitHub](https://github.com/serversideup/docker-php/blob/main/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini){target="_blank"}. |
-| **Adding your own PHP INI file**
`/usr/local/etc/php/conf.d/*.ini` | To set your own PHP settings, simply create a `.ini` file in the `/usr/local/etc/php/conf.d/` directory. It will be loaded automatically *after* our default ini file, so any settings you define will override the defaults. |
-
-:u-button{to="/docs/customizing-the-image/changing-common-php-settings" label="Learn more about changing common PHP settings" aria-label="Learn more about changing common PHP settings" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Default PHP Extensions
-The following extensions are installed by default:
-
-| **Extension** | **Description** | **Why we included it** |
-|---------------|-----------------|------------------------|
-| [opcache](https://www.php.net/manual/en/book.opcache.php) | The Zend OPcache provides faster PHP execution through opcode caching and optimization. | This is a must-have for PHP performance.
⚠️ OPcache is disabled by default so code edits show up right away. Set [`PHP_OPCACHE_ENABLE=1`](/docs/reference/environment-variable-specification) to turn it on with tuned defaults. See the [production performance tuning guide](/docs/guide/production-performance-tuning#php-opcache).|
-| [mysqli](https://www.php.net/manual/en/book.mysqli.php) | The "MySQL Improved" extension is an older extension for connecting to MySQL 4.1 and above. | **Enabled for fpm-apache only**. This is a legacy MySQL connector required for WordPress.|
-| [pcntl](https://www.php.net/manual/en/intro.pcntl.php) | Process Control support in PHP implements the Unix style of process creation, program execution, signal handling and process termination. | This is required for [Laravel queues and Laravel Horizon](https://laravel.com/docs/10.x/queues#timeout)|
-| [pdo_mysql](https://www.php.net/manual/en/ref.pdo-mysql.php) | The MySQL PDO extension allows you to connect to MySQL databases. | MySQL and MariaDB databases are very popular. |
-| [pdo_pgsql](https://www.php.net/manual/en/ref.pdo-pgsql.php) | The PostgreSQL PDO extension allows you to connect to PostgreSQL databases. | PostgreSQL databases are very popular. |
-| [redis](https://www.php.net/manual/en/book.redis.php) | The Redis extension allows you to connect to Redis databases. | Redis is very popular for caching and it's also required for [Laravel Horizon](https://laravel.com/docs/10.x/horizon). Our tests concluded adding this package only added 2MB to the image size. |
-| [zip](https://www.php.net/manual/en/book.zip.php) | The Zip extension allows you to create and extract zip files. | We included this for the popularity of apps working with ZIP files. This package is also required if you're working with attachments on [Laravel Dusk](https://laravel.com/docs/10.x/dusk#attaching-files). |
-
-The official PHP images are already providing the following extensions:
-- ctype
-- curl
-- dom
-- fileinfo
-- filter
-- hash
-- mbstring
-- openssl
-- pcre
-- session
-- tokenizer
-- xml
-
-If you need to install additional extensions, you can use the guide below to learn more.
+::warning
+A file that sorts before `serversideup-docker-php.ini`, such as `custom.ini` or `php.ini`, loads first, and our defaults override it. Name yours so it sorts after, such as `zzz-custom-php.ini`.
+::
-:u-button{to="/docs/customizing-the-image/installing-additional-php-extensions" label="Learn more about installing additional PHP extensions" aria-label="Learn more about installing additional PHP extensions" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+For most settings, an environment variable is simpler than an ini file. See [Changing php.ini settings](/docs/customizing-the-image/changing-common-php-settings).
-## Default Operating System Packages
-The following packages are installed by default:
+## The extensions Laravel and WordPress reach for
-| **Package** | **Description** | **Image variations** | **Why we included it** |
-|-------------|-----------------|----------------------|------------------------|
-| `libfcgi-bin` (Debian) `fcgi` (Alpine) | FastCGI is a protocol for interfacing interactive programs with a web server. | *-fpm *-fpm-nginx *-fpm-apache | This is required for the webserver to interface with PHP-FPM and the [`php-fpm-healthcheck`](https://github.com/renatomefi/php-fpm-healthcheck) project. |
-| `gettext-base` (Debian) `gettext` (Alpine) | GNU gettext is a framework for translating user interfaces. | *-fpm-nginx *-fpm-apache | This is required for the `envsubst` command. We use this command to process templates on container initialization. |
-| `libstdc++6` (Debian) `libstdc++` (Alpine) | The GNU Standard C++ Library is a C++ standard library. | *-frankenphp | This is [required for the watcher to run](https://github.com/php/frankenphp/blob/e917ab79742c9e4703023861fdc7a86cdb59da1e/Dockerfile#L135-L138) with FrankenPHP. |
-| `procps` | The procps package contains programs for monitoring your system and its processes. | * (only Debian images) | This is required for `pgrep` so we can use that for our native health checks. |
-| `shadow` | Shadow is required for the `usermod` command. | *-alpine | This is required to change the UID and GID of the `www-data` user in `docker-php-serversideup-set-id`. |
+We add these extensions to the ones the official PHP build includes:
-:u-button{to="/docs/getting-started/choosing-an-image#operating-system" label="Learn which operating systems are available" aria-label="Learn which operating systems are available" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+| Extension | Why it's included |
+|---|---|
+| [opcache](https://www.php.net/manual/en/book.opcache.php){target="_blank"} | Caches compiled PHP. It's off by default, so code edits show up right away. Set `PHP_OPCACHE_ENABLE=1` in production. See [Production performance tuning](/docs/deployment-and-production/production-performance-tuning). |
+| [pcntl](https://www.php.net/manual/en/book.pcntl.php){target="_blank"} | Signal handling for [Laravel queue timeouts](https://laravel.com/docs/queues#timeout){target="_blank"} and [Horizon](https://laravel.com/docs/horizon){target="_blank"}. |
+| [pdo_mysql](https://www.php.net/manual/en/ref.pdo-mysql.php){target="_blank"} | MySQL and MariaDB. |
+| [pdo_pgsql](https://www.php.net/manual/en/ref.pdo-pgsql.php){target="_blank"} | PostgreSQL. |
+| [redis](https://www.php.net/manual/en/book.redis.php){target="_blank"} | Redis for caching and queues. [Horizon](https://laravel.com/docs/horizon){target="_blank"} requires it. |
+| [zip](https://www.php.net/manual/en/book.zip.php){target="_blank"} | Reading and writing ZIP files, such as [Laravel Dusk attachments](https://laravel.com/docs/dusk#attaching-files){target="_blank"}. |
+| [mysqli](https://www.php.net/manual/en/book.mysqli.php){target="_blank"} | `fpm-apache` only. WordPress uses it. |
+The official build already includes `ctype`, `curl`, `dom`, `fileinfo`, `filter`, `mbstring`, `openssl`, `pdo_sqlite`, `session`, `sodium`, `tokenizer`, `xml`, and others. To list everything an image has, run:
-## Health Checks
-By default, all health checks for web servers (Apache, NGINX, FrankenPHP, etc.) are located at `/healthcheck`. You should receive an `OK` response if the container is healthy.
+```bash [Terminal]
+docker run --rm serversideup/php:8.5-cli php -m
+```
-For our `fpm` variation, we use the [`php-fpm-healthcheck`](https://github.com/renatomefi/php-fpm-healthcheck) script to verify the health of PHP-FPM. This script is located at `/usr/local/bin/php-fpm-healthcheck`.
+Need another one? `install-php-extensions intl` adds it. See [Installing additional PHP extensions](/docs/customizing-the-image/installing-additional-php-extensions).
-The `cli` variation does not have a health check because it doesn't really make sense to have one. Would love to discuss more if you feel different.
+## Composer and the tools you'd install anyway
-:u-button{to="/docs/guide/using-healthchecks-with-laravel" label="Learn more about health checks" aria-label="Learn more about health checks" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+[Composer](https://getcomposer.org/){target="_blank"} 2 and [`install-php-extensions`](https://github.com/mlocati/docker-php-extension-installer){target="_blank"} are in every image. We also add a few operating system packages, each for a reason:
+| Package | Variations | Used for |
+|---|---|---|
+| `libfcgi-bin` (Debian), `fcgi` (Alpine) | `fpm`, `fpm-nginx`, `fpm-apache` | The [`php-fpm-healthcheck`](https://github.com/renatomefi/php-fpm-healthcheck){target="_blank"} script, which talks FastCGI to PHP-FPM. |
+| `gettext-base` (Debian), `gettext` (Alpine) | `fpm-nginx` | `envsubst`, which fills environment variables into the NGINX config templates at startup. |
+| `nginx` (from nginx.org) | `fpm-nginx` | The web server. |
+| `apache2` | `fpm-apache` | The web server. |
+| `libstdc++6` (Debian), `libstdc++` (Alpine) | `frankenphp` | The file watcher FrankenPHP uses for `--watch`. |
+| `procps` | All Debian images | `pgrep`, which the Laravel health check scripts use. Alpine has it built in. |
+| `zip` | All Debian images | The `zip` command. |
+| `shadow` | All Alpine images | `usermod` and `groupmod`, which `docker-php-serversideup-set-id` uses to change the `www-data` IDs. |
-## Logging
-Every variation writes its logs to the container's standard output streams instead of files. This is the [twelve-factor app](https://12factor.net/logs){target="_blank"} approach: the process writes its event stream and the environment decides where it goes, so `docker logs`, Docker Compose, Kubernetes, and log collectors all pick it up without any extra setup.
+## Health checks are built in
-Which stream a log lands on depends on the web server, and in each case we follow the official Docker image for that server:
+Every image except `cli` ships a Docker `HEALTHCHECK` that works with no configuration. The web server variations request `/healthcheck` on their HTTP port. `fpm` asks PHP-FPM's status page.
-| Variation | Access logs | Error logs | Why |
-|-----------|-------------|------------|-----|
-| `fpm-nginx` | `stdout` | `stderr` | Matches the [official NGINX image](https://github.com/nginxinc/docker-nginx/blob/master/mainline/debian/Dockerfile){target="_blank"}, which links `access.log` to `/dev/stdout` and `error.log` to `/dev/stderr`. |
-| `fpm-apache` | `stdout` | `stderr` | Matches the [official httpd image](https://github.com/docker-library/httpd/blob/master/2.4/Dockerfile){target="_blank"}, which sends `CustomLog` to `stdout` and `ErrorLog` to `stderr`. |
-| `fpm` | Off | `stderr` | Matches the [official PHP image](https://github.com/docker-library/php/blob/master/8.4/trixie/fpm/Dockerfile){target="_blank"}. PHP-FPM closes `stdout` when it starts ([PHP bug #73886](https://bugs.php.net/bug.php?id=73886){target="_blank"}), so `stderr` is the only option. |
-| `frankenphp` | `stderr` | `stderr` | Caddy's [access log](https://caddyserver.com/docs/caddyfile/directives/log){target="_blank"} and its runtime log share the same [structured format](https://caddyserver.com/docs/logging){target="_blank"} with a level on every entry, and [`stderr` is Caddy's default output](https://caddyserver.com/docs/caddyfile/directives/log#output){target="_blank"} for both. The format is Caddy's default too: JSON unless `stderr` is a terminal. |
-| PHP errors (all variations) | | `stderr` | `PHP_ERROR_LOG` defaults to `/dev/stderr`. |
+Set `HEALTHCHECK_PATH: "/up"` to check that Laravel boots, not only the web server. [Health checks](/docs/deployment-and-production/health-checks) covers how each variation checks itself. [Laravel health checks](/docs/framework-guides/laravel/health-checks) covers `/up` and the scripts for queue, Horizon, and scheduler containers.
-NGINX and Apache keep two logs, so the Unix convention applies: the access log is the server's normal output and goes to `stdout`, while diagnostics go to `stderr`. Caddy has an access log too, but it is a named logger (`http.log.access`) that shares the same structured format and default output as the runtime log, and every entry carries its own level. Splitting the two by stream would not add anything, so we keep Caddy's default. That also keeps FrankenPHP compatible with tools that build on it, like Laravel Octane, which only reads `stderr`.
+## Logs go where your platform looks
-Most platforms treat the two streams the same. One exception is Google Kubernetes Engine, which [tags `stdout` as `INFO` and `stderr` as `ERROR`](https://docs.cloud.google.com/kubernetes-engine/docs/concepts/about-logs){target="_blank"} unless the entry is structured JSON with a severity. If your platform does something similar, FrankenPHP's JSON entries already carry a `level` your pipeline can map to a severity, as long as you have not pinned `CADDY_LOG_FORMAT` to `console`. For the other variations, move the stream with `NGINX_ACCESS_LOG`, `NGINX_ERROR_LOG`, or `PHP_ERROR_LOG`.
+Every variation writes logs to the container's `stdout` and `stderr`, never to files. `docker logs`, Kubernetes, and log collectors pick them up with no setup. Each web server follows its own official image:
-Use `LOG_OUTPUT_LEVEL` to control verbosity in every variation. It maps to the native log level of PHP, PHP-FPM, and the web server.
+| Variation | Access log | Error and runtime log | Default level |
+|---|---|---|---|
+| `fpm-nginx` | `stdout` | `stderr` | `warn` |
+| `fpm-apache` | `stdout` | `stderr` | `warn` |
+| `fpm` | off | `stderr` (PHP-FPM [closes `stdout`](https://bugs.php.net/bug.php?id=73886){target="_blank"}) | `warn` |
+| `frankenphp` | `stderr` | `stderr` | `info` |
+| `cli` | none | `stderr` | `warn` |
-:u-button{to="/docs/reference/environment-variable-specification" label="See the logging environment variables" aria-label="See the logging environment variables" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+PHP errors go to `stderr` in every variation (`PHP_ERROR_LOG=/dev/stderr`). `LOG_OUTPUT_LEVEL` sets the level for PHP, PHP-FPM, and the web server together.
+FrankenPHP follows Caddy and the official FrankenPHP image. Its access log and runtime log share one structured format, with a level on every entry. Both go to `stderr`. The format is JSON unless `stderr` is a terminal (`CADDY_LOG_FORMAT=auto`). Laravel Octane only reads `stderr`. If you use Octane, leave `CADDY_LOG_OUTPUT` at its default.
-## Default Entrypoint Scripts
-We created an entrypoint script that scans the `entrypoint.d` directory for other shell scripts to execute before the main container process starts. All scripts are executed in alphabetical order so you can have full control over what script execution order.
+::note
+Some platforms treat `stderr` as errors. Google Kubernetes Engine, for example, [tags `stderr` as `ERROR`](https://docs.cloud.google.com/kubernetes-engine/docs/concepts/about-logs){target="_blank"} unless the entry is JSON with a severity. FrankenPHP's JSON entries carry a `level` your pipeline can map. For the other variations, move a log with `NGINX_ACCESS_LOG`, `NGINX_ERROR_LOG`, or `PHP_ERROR_LOG`.
+::
-We also provide a few default scripts to help you get started.
+## Startup scripts prepare the container
-| **Script Name** | **Description** | **Image variations** |
-|------------|-----------------|----------------------|
-| `0-container-info.sh` | Shows basic execution information, such as Docker User, UID, GID, etc. | all |
-| `1-log-output-level.sh` | Sets PHP log output level to match `LOG_OUTPUT_LEVEL` | all |
-| `5-generate-ssl.sh` | Generates a self-signed SSL certificate and key for the container. | fpm-nginx, fpm-apache, frankenphp |
-| `5-fpm-pool-user.sh` | Sets the user and group of the PHP-FPM child processes to the values of `PHP_FPM_CHILD_PROCESS_USER` and `PHP_FPM_CHILD_PROCESS_GROUP` if the container is running as root. | fpm |
-| `10-init-webserver-config.sh` | Processes web server configuration templates, configures SSL (if enabled), and prepares web server for launch | *-nginx *-apache |
-| `50-laravel-automations.sh` | If `AUTORUN_ENABLED` is set to true, and a Laravel installation is detected, the following commands will automatically execute on container start: - `php artisan config:cache` - `php artisan route:cache` - `php artisan view:cache` - `php artisan event:cache` - `php artisan migrate --force` | all |
+Before the main process starts, the entrypoint runs every `.sh` file in `/etc/entrypoint.d/` in version-sort order (`sort -V`). That means `5-*` runs before `10-*`. Add your own scripts to the same folder. [Adding your own start-up scripts](/docs/customizing-the-image/adding-your-own-start-up-scripts) explains the order.
-If you want to add your own entrypoint scripts, use the guide below to learn more.
+| Script | What it does | Variations |
+|---|---|---|
+| `0-container-info.sh` | Prints the image and PHP versions, the user and its IDs, and key PHP settings. `SHOW_WELCOME_MESSAGE=false` hides it. | all |
+| `1-log-output-level.sh` | Applies `LOG_OUTPUT_LEVEL` to PHP and PHP-FPM. | all |
+| `5-fpm-pool-user.sh` | When the container runs as root, sets the PHP-FPM pool's user and group to `PHP_FPM_CHILD_PROCESS_USER` and `PHP_FPM_CHILD_PROCESS_GROUP`. | `fpm`, `fpm-nginx`, `fpm-apache` |
+| `5-generate-ssl.sh` | Generates a self-signed certificate when `SSL_MODE` is not `off` and none is provided. | `fpm-nginx`, `fpm-apache`, `frankenphp` |
+| `10-init-webserver-config.sh` | Builds the web server config from templates and enables the site for the current `SSL_MODE`. | `fpm-nginx`, `fpm-apache` |
+| `50-laravel-automations.sh` | When `AUTORUN_ENABLED=true`, runs `php artisan storage:link`, `php artisan migrate --force`, then `php artisan optimize`. See [Laravel automations](/docs/framework-guides/laravel/automations). | all |
-:u-button{to="/docs/customizing-the-image/adding-your-own-start-up-scripts" label="Learn more about adding your own start up scripts" aria-label="Learn more about adding your own start up scripts" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Set `DISABLE_DEFAULT_CONFIG=true` to skip all of these scripts. Your own scripts in `/etc/entrypoint.d/` still run.
-## Disabling Default Entrypoint Scripts
-We get it. Sometimes you just want our stuff to get out of the way. If you want full control to customize your image, all default entrypoint scripts can be disabled by setting `DISABLE_DEFAULT_CONFIG` to `true`.
+::warning
+With `DISABLE_DEFAULT_CONFIG=true`, `fpm-nginx` has no `nginx.conf` and `fpm-apache` has no site enabled, because `10-init-webserver-config.sh` builds both. Provide your own web server configuration before you set it.
+::
diff --git a/docs/content/docs/1.getting-started/7.migrating-from-official-php-images.md b/docs/content/docs/1.getting-started/7.migrating-from-official-php-images.md
new file mode 100644
index 000000000..a6ced42c5
--- /dev/null
+++ b/docs/content/docs/1.getting-started/7.migrating-from-official-php-images.md
@@ -0,0 +1,129 @@
+---
+head.title: 'Migrating from official PHP Docker images - Docker PHP - Server Side Up'
+description: 'Move a project from the official PHP images to serversideup/php in eight steps. Your app code stays the same.'
+layout: docs
+title: Migrating from official PHP images
+---
+
+::lead-p
+You already have a Dockerfile that works. It installs extensions, copies in Composer, and tweaks `php.ini`. Most of those lines go away when you switch. We compile PHP the same way the official images do, and your app code doesn't change.
+::
+
+## Most of your Dockerfile goes away
+
+Here's a typical Laravel image built on the official Apache image:
+
+```dockerfile [Dockerfile (before)]
+FROM php:8.5-apache
+
+RUN apt-get update && apt-get install -y libzip-dev \
+ && docker-php-ext-install pcntl pdo_mysql zip \
+ && pecl install redis && docker-php-ext-enable redis \
+ && a2enmod rewrite
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+COPY php.ini /usr/local/etc/php/conf.d/app.ini
+COPY . /var/www/html
+```
+
+Here's the same app on ours:
+
+```dockerfile [Dockerfile (after)]
+FROM serversideup/php:8.5-fpm-apache
+
+COPY --chown=www-data:www-data . /var/www/html
+```
+
+`pcntl`, `pdo_mysql`, `redis`, and `zip` are already installed. So are Composer and Apache's `rewrite` module. The `php.ini` settings move to environment variables in your Compose file. The container runs as `www-data` instead of `root`.
+
+## Switch in eight steps
+
+Work through these in development first.
+
+::steps{level="3"}
+### Change the image
+
+| Official image | Replace with |
+|---|---|
+| `php:8.5-cli` | `serversideup/php:8.5-cli` |
+| `php:8.5-fpm` | `serversideup/php:8.5-fpm` |
+| `php:8.5-apache` | `serversideup/php:8.5-fpm-apache` |
+| `php:8.5-fpm` plus an NGINX container | `serversideup/php:8.5-fpm-nginx` |
+
+Not sure which fits? [Choosing an image](/docs/getting-started/choosing-an-image) compares all five.
+
+### Map the new ports
+
+Web server variations listen on `8080` and `8443` instead of `80` and `443`. `fpm` still uses `9000`.
+
+```yaml [compose.yml]{3,5-6}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-apache
+ ports:
+ - 80:8080
+ - 443:8443
+```
+
+Update anything else that connects to the container directly, such as a load balancer's health check.
+
+### Serve from `public/`, or change the web root
+
+`fpm-nginx`, `fpm-apache`, and `frankenphp` serve `/var/www/html/public`, not `/var/www/html`. Laravel and Symfony already use `public/`.
+
+For an app that serves from the project root, such as WordPress, set `NGINX_WEBROOT`, `APACHE_DOCUMENT_ROOT`, or `CADDY_SERVER_ROOT` to `/var/www/html`.
+
+### Give `www-data` your files
+
+Containers run as `www-data`, not `root`. The user ID is `33` on Debian and `82` on Alpine. That user must own anything your app writes to, such as Laravel's `storage/`:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+COPY --chown=www-data:www-data . /var/www/html
+```
+
+For mounted volumes, see [Understanding file permissions](/docs/customizing-the-image/understanding-file-permissions).
+
+### Install extras as `root`
+
+Package and extension installs need root. Switch to `root` for those steps, then back to `www-data`:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+USER root
+RUN install-php-extensions intl bcmath
+USER www-data
+```
+
+`install-php-extensions` installs each extension's system libraries for you. `docker-php-ext-install` still works too. See [Installing additional PHP extensions](/docs/customizing-the-image/installing-additional-php-extensions).
+
+### Swap `php.ini` for environment variables
+
+Most settings you'd copy in a `php.ini` have an environment variable, such as `PHP_MEMORY_LIMIT`. Set them in your Compose file and drop the file.
+
+If you keep an ini file, name it to load after ours, such as `zzz-custom-php.ini`. OPcache is off by default. Set `PHP_OPCACHE_ENABLE=1` in production. See [Changing php.ini settings](/docs/customizing-the-image/changing-common-php-settings).
+
+### Move `php_value` out of `.htaccess` (Apache only)
+
+The official Apache image runs PHP as `mod_php`. Our `fpm-apache` runs PHP-FPM. Apache returns a 500 error for `php_value` and `php_flag` lines in `.htaccess`. Move those settings to environment variables or an ini file.
+
+`fpm-apache` also blocks `xmlrpc.php`. WordPress features that use it, such as Jetpack and the mobile app, stop working. See [Using WordPress with Docker](/docs/framework-guides/wordpress/using-wordpress-with-docker).
+
+### Keep our entrypoint and default command
+
+Remove `ENTRYPOINT` and `CMD` lines you carried over, such as `CMD ["apache2-foreground"]`. Our entrypoint runs the [start-up scripts](/docs/customizing-the-image/adding-your-own-start-up-scripts), then the default command. On `fpm-nginx` and `fpm-apache` that's `/init`, which starts the web server and PHP-FPM together. A different command skips the web server.
+
+Our PHP-FPM pool replaces the stock `www.conf` and `zz-docker.conf`. Set pool options with `PHP_FPM_*` variables instead of copying those files in.
+::
+
+## Test it, then ship it
+
+Run your app and its tests against the new image in development or staging. Watch the container logs for permission errors.
+
+Once it's running, two features are worth turning on:
+
+- [Laravel automations](/docs/framework-guides/laravel/automations) run your migrations and caching at startup.
+- [`HEALTHCHECK_PATH: "/up"`](/docs/framework-guides/laravel/health-checks) makes Docker check that Laravel boots.
+
+[Default configurations](/docs/getting-started/default-configurations) lists everything else the image does for you.
diff --git a/docs/content/docs/1.getting-started/7.upgrade-guide.md b/docs/content/docs/1.getting-started/7.upgrade-guide.md
deleted file mode 100644
index 7172b4537..000000000
--- a/docs/content/docs/1.getting-started/7.upgrade-guide.md
+++ /dev/null
@@ -1,130 +0,0 @@
----
-head.title: 'Upgrade Guide - Docker PHP - Server Side Up'
-description: 'Learn how the serversideup/php Docker images are built and how to upgrade.'
-layout: docs
-title: Upgrade Guide
----
-
-::lead-p
-This guide explains how `serversideup/php` releases work, how to choose an update strategy that fits your environment, and how to apply your own security patches when you need them. Use it to decide whether to ride our floating tags for automatic weekly updates, or pin to a version-pinned release and take responsibility for your own patching — both are valid choices, and the right one depends on how you balance reproducibility against staying current. For crossing a major version boundary (V3 → V4, V2 → V3), see the [Major Version Migrations](/docs/guide/major-version-migrations) guide.
-::
-
-## Subscribe to repository updates
-Regardless if you are choosing to use floating tags or pinned releases, it is highly advised to subscribe to our releases. You can do this through the "Watch" button on our [GitHub](https://github.com/serversideup/docker-php).
-
-{.max-w-md.w-full.h-auto.mx-auto}
-
-:u-button{to="https://github.com/serversideup/docker-php" target="_blank" label="Subscribe to our Repo" aria-label="Subscribe to our Repo" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## How our releases work
-
-All source code merges into the `main` branch. A pre-release is created for beta testing. After internal and community testing, we [publish a release on GitHub](https://github.com/serversideup/docker-php/releases) with a detailed changelog. Publishing a release triggers a production build that pushes a new set of tags to Docker Hub and GitHub Packages.
-
-Production images are also rebuilt **automatically every Tuesday at 08:00 UTC** so floating tags pick up the latest upstream PHP and operating system security patches even when we haven't cut a release that week.
-
-### Floating tags vs. version-pinned tags
-
-Every production build pushes two kinds of tags for each image in our matrix:
-
-::note{title="Floating tags get updates automatically"}
-Tags like `8.4-fpm-nginx`, `8.4-fpm-nginx-bookworm`, and `latest` are **overwritten on every release and every weekly rebuild**. If you use a floating tag, you receive the latest security updates simply by running `docker pull`.
-::
-
-::caution{title="Version-pinned tags are immutable"}
-Tags that include a release suffix like `8.4-fpm-nginx-v4.3.5` are written **once** and never rebuilt. They stay exactly as they were the day they were published. If you pin to a version-pinned tag, you will **not** receive any future security updates from us — you are responsible for applying your own patches in your downstream image.
-::
-
-## Choosing your update strategy
-
-There are two valid ways to consume our images, and the right choice depends on how you balance reproducibility against staying current on security patches.
-
-| Strategy | Example tag | Update behavior | What you are responsible for |
-|----------|-------------|-----------------|------------------------------|
-| Floating tag *(recommended for simple projects)* | `serversideup/php:8.4-fpm-nginx` | Auto-rebuilt weekly and on every release. `docker pull` brings in the latest patch. | Pulling regularly and testing before promoting to production. |
-| Version-pinned release *(recommended for high-volume production environments)* | `serversideup/php:8.4-fpm-nginx-v4.3.5` | Never rebuilt. Stays exactly as published. | All security updates — OS packages, Composer, and any other software inside the image. |
-
-For simple deployments, we recommend pinning to a **minor PHP version on a floating tag** like `8.4-fpm-nginx`. This keeps your PHP version stable for compatibility while still receiving weekly security updates from our rebuilds.
-
-Choose a **version-pinned release** when you need fully reproducible builds — for example, for supply-chain compliance or air-gapped environments — and have a process in place to apply your own updates. See [Applying your own security updates](#applying-your-own-security-updates) for how to do that.
-
-## Choosing an image version
-
-If you do not select a specific patch version, you will receive automatic PHP patch updates through our floating-tag rebuilds. For example:
-
-- Major version (`8`) gives you the latest 8.x release.
-- Minor version (`8.4`) gives you the latest 8.4.x release.
-- Patch version (`8.4.1`) stays at exactly 8.4.1 — but on a floating tag, you still receive OS package updates on the weekly rebuild.
-- Version-pinned (`8.4.1-fpm-nginx-v4.3.5`) is fully frozen.
-
-If you use `latest`, you will always get the latest stable version of the CLI variation of PHP.
-
-:u-button{to="/docs/getting-started/choosing-an-image" label="Learn more about choosing an image" aria-label="Learn more about choosing an image" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Applying your own security updates
-
-There are two situations where you may need to apply updates yourself rather than waiting for us to publish a new image:
-
-1. **You're pinned to a version-pinned tag** (e.g. `-v4.3.5`) for reproducibility, and a CVE has been disclosed since that release was cut.
-2. **You're on a floating tag**, but a critical patch has landed upstream and you need it before our next weekly rebuild.
-
-In both cases, you apply updates by extending our image in your own Dockerfile.
-
-::warning
-Any update you apply carries a risk of breaking the application inside the container. Always test before promoting changes to production.
-::
-
-### Update operating system packages
-
-Our images run as the unprivileged `www-data` user by default, so you need to switch to `root` before running package managers, then switch back.
-
-For Debian-based images:
-
-```dockerfile [Dockerfile]
-# 8.4.1 is the PHP version, and 4.3.5 is our GitHub release version
-FROM serversideup/php:8.4.1-fpm-nginx-v4.3.5
-
-USER root
-RUN apt-get update \
- && apt-get upgrade -y \
- && apt-get clean \
- && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
-USER www-data
-```
-
-For Alpine-based images:
-
-```dockerfile [Dockerfile]
-# 8.4.1 is the PHP version, and 4.3.5 is our GitHub release version
-FROM serversideup/php:8.4.1-fpm-nginx-alpine-v4.3.5
-
-USER root
-RUN apk update \
- && apk upgrade \
- && rm -rf /var/cache/apk/*
-USER www-data
-```
-
-### Update Composer
-
-Composer is installed in our images by copying the binary from the [official `composer` Docker image](https://hub.docker.com/_/composer). You can use the same pattern in your own Dockerfile to pull in a newer Composer release without waiting for us to publish a new image:
-
-```dockerfile [Dockerfile]
-# 8.4.1 is the PHP version, and 4.3.5 is our GitHub release version
-FROM serversideup/php:8.4.1-cli-v4.3.5
-
-# Pin Composer to a specific patch for reproducibility,
-# or use composer:2 for the latest 2.x release.
-COPY --from=composer:2.9.8 /usr/bin/composer /usr/bin/composer
-```
-
-This works for any of our variations (`cli`, `fpm`, `fpm-apache`, `fpm-nginx`, `frankenphp`). It's the same mechanism we use inside our images, so you're not fighting the base image — you're just upgrading the same file we put there.
-
-::warning{title="NGINX, Apache, and other distro-provided software"}
-Web servers like NGINX and Apache are installed from your image's operating system package repositories. Running `apt-get upgrade` or `apk upgrade` only gets you the version your distribution currently ships. If you need a meaningfully newer NGINX — for example, to pick up a CVE fix that's only in a later distribution release — switch the base OS of our image (e.g. from `bookworm` to `trixie`) or upgrade to a newer `serversideup/php` release. Avoid swapping in upstream `nginx.org` packages, as that can break our integration with PHP-FPM and S6 Overlay.
-::
-
-## Migrating between major versions
-
-Crossing a major version boundary — like V3 → V4 or V2 → V3 — is a separate concern from day-to-day upgrades. We track breaking changes, new features, and step-by-step checklists for each major release in a dedicated guide.
-
-:u-button{to="/docs/guide/major-version-migrations" label="Read the major version migration guide" aria-label="Read the major version migration guide" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
diff --git a/docs/content/docs/1.getting-started/8.changelog.md b/docs/content/docs/1.getting-started/8.changelog.md
deleted file mode 100644
index 2b5191a96..000000000
--- a/docs/content/docs/1.getting-started/8.changelog.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-head.title: 'Changelog - Docker PHP - Server Side Up'
-description: 'See the latest releases and changes for the PHP Docker Image project.'
-layout: docs
----
-::lead-p
-All our changes are documented and published on our GitHub. [See our release notes on GitHub →](https://github.com/serversideup/docker-php/releases){target="_blank"}
-::
-
-### Choosing a version
-You may want to review [our guide on selecting the right image](/docs/getting-started/choosing-an-image) to determine which version and image tag is best for you.
-
-:u-button{to="https://github.com/serversideup/docker-php/releases" target="_blank" label="See our release notes on GitHub" aria-label="See our release notes on GitHub" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
\ No newline at end of file
diff --git a/docs/content/docs/1.getting-started/9.about.md b/docs/content/docs/1.getting-started/9.about.md
deleted file mode 100644
index f58cc1f4a..000000000
--- a/docs/content/docs/1.getting-started/9.about.md
+++ /dev/null
@@ -1,60 +0,0 @@
----
-head.title: 'About - Docker PHP - Server Side Up'
-description: 'Learn more about how this project got started and who inspired it.'
-layout: docs
----
-
-::lead-p
-We're taking the extra effort to open source as much as we can. Not only could this potentially help someone learn a little bit of Docker, but it makes it a *heck of a lot* easier for us to work with other people on our other open source projects.
-::
-
-::about
-::
-
-::tip{to="https://serversideup.net/products/" target="_blank"}
-If you appreciate our work, consider supporting us by buying our products or sponsoring us on GitHub.
-::
-
-## About Us
-We're [Dan](https://x.com/danpastori){target="_blank"} and [Jay](https://x.com/jaydrogers){target="_blank"} - a two person team with a passion for open source products. We created [Server Side Up](https://serversideup.net){target="_blank"} to help share what we learn.
-
-### Find us at:
-
-* **📖 [Blog](https://serversideup.net){target="_blank"}** - Get the latest guides and free courses on all things web/mobile development.
-* **🙋 [Community](https://community.serversideup.net){target="_blank"}** - Get friendly help from our community members.
-* **🤵♂️ [Get Professional Help](https://serversideup.net/professional-support){target="_blank"}** - Get video + screen-sharing support from the core contributors.
-* **💻 [GitHub](https://github.com/serversideup){target="_blank"}** - Check out our other open source projects.
-* **📫 [Newsletter](https://serversideup.net/subscribe){target="_blank"}** - Skip the algorithms and get quality content right to your inbox.
-* **🐥 [Twitter](https://x.com/serversideup){target="_blank"}** - You can also follow [Dan](https://x.com/danpastori){target="_blank"} and [Jay](https://x.com/jaydrogers){target="_blank"}.
-* **❤️ [Sponsor Us](https://serversideup.net/sponsor/){target="_blank"}** - Please consider sponsoring us so we can create more helpful resources.
-
-## Our products
-If you appreciate this project, be sure to check out our other projects.
-
-### 📚 Books
-- **[The Ultimate Guide to Building APIs & SPAs](https://serversideup.net/ultimate-guide-to-building-apis-and-spas-with-laravel-and-nuxt3/){target="_blank"}**: Build web & mobile apps from the same codebase.
-- **[Building Multi-Platform Browser Extensions](https://serversideup.net/building-multi-platform-browser-extensions/){target="_blank"}**: Ship extensions to all browsers from the same codebase.
-
-### 🛠️ Software-as-a-Service
-- **[Bugflow](https://bugflow.io/){target="_blank"}**: Get visual bug reports directly in GitHub, GitLab, and more.
-- **[SelfHost Pro](https://selfhostpro.com/){target="_blank"}**: Connect Stripe or Lemonsqueezy to a private docker registry for self-hosted apps.
-
-### 🌍 Open Source
-- **[AmplitudeJS](https://serversideup.net/open-source/amplitudejs){target="_blank"}**: Open-source HTML5 & JavaScript Web Audio Library.
-- **[Spin](https://serversideup.net/open-source/spin/){target="_blank"}**: Laravel Sail alternative for running Docker from development → production.
-- **[Financial Freedom](https://github.com/serversideup/financial-freedom){target="_blank"}**: Open source alternative to Mint, YNAB, & Monarch Money.
-
-## Special thanks
-This project wouldn't be possible without the help of some amazing people. We're grateful for their support and contributions. We wanted to give them a special shoutout here (in no particular order).
-
-### [Chris Fidao](https://github.com/fideloper){target="_blank"}
-Majority of our knowledge came from Chris' course, [Shipping Docker](https://serversforhackers.com/shipping-docker). If you have yet to discover his content, you will be very satisfied with every course he has to offer. He's a great human being and excellent educator. Chris has continuously provided quality feedback that has deeply inspired the direction of this project.
-
-### [Joel Clermont](https://github.com/joelclermont/){target="_blank"}
-Joel is one of the friendliest people that you'll ever meet. When he's not podcasting on his own show, [No Compromises](https://show.nocompromises.io/), he's busy helping other people in the PHP community. Joel's deep experience with PHP is rare to find, and we're grateful that we were able to pick his brain to bring you the best PHP Docker experience possible.
-
-### [Patricio](https://github.com/ijpatricio){target="_blank"}
-If there's a new framework or tool in the web development world, Patricio probably has content for it on his [YouTube Channel](https://www.youtube.com/@PatricioOnCode). Patricio is a great resource for all things web development. Frontend, backend, DevOps -- it doesn't matter. Patricio graciously shared his wide spectrum of experiences with us and is a huge supporter of this project.
-
-### [linuxserver.io](https://www.linuxserver.io/){target="_blank"}
-These guys are absolute aces when it comes to Docker development. They are a great resource for tons of open source Docker images. Check out their work, especially if you're a homelabber.
\ No newline at end of file
diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md
index 8ef91c2c5..e691b8ac6 100644
--- a/docs/content/docs/1.getting-started/99.contributing.md
+++ b/docs/content/docs/1.getting-started/99.contributing.md
@@ -1,158 +1,120 @@
---
head.title: 'Contributing - Docker PHP - Server Side Up'
-description: 'Learn how you can make a difference and contribute to the Docker PHP project.'
+description: 'Build and test serversideup/php images locally, run the same checks as CI, and open a pull request.'
layout: docs
+title: Contributing
---
::lead-p
-Thanks for your interest in contributing to this project! Please use read this entire guide before submitting a pull request.
+Thanks for helping out. This page covers how to build an image locally, test it, and run the checks CI runs, so your pull request passes on the first try. Read it before you open one.
::
-## Improve the docs
-Improving the docs is very easy. If you find a simple mistake, look at the bottom of the Table Of Contents section and click "Edit this page".
+## Fix the docs
-If you'd like to contribute bigger documentation changes, take a look at the `/docs` directory. Our entire site is available in that directory and you can see the process to easily install this on your machine by reading `/docs/README.md`.
+For a small fix, click "Edit this page" at the bottom of the table of contents. For bigger changes, the whole site lives in [`docs/`](https://github.com/serversideup/docker-php/tree/main/docs){target="_blank"}. `docs/README.md` explains how to run it locally. Follow `docs/AGENTS.md` for style.
-:u-button{to="https://github.com/serversideup/docker-php/tree/main/docs" target="_blank" label="Learn how to contribute to the docs" aria-label="Learn how to contribute to the docs" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+## What you need
+- Docker with `buildx`
+- [`yq`](https://github.com/mikefarah/yq){target="_blank"}, which the build and test scripts use to read the version config
+- Bash. The build scripts in `scripts/` work on macOS, Linux, and WSL2.
-## Project dependencies
-You must have these installed on your system.
-* Docker (container engine): https://www.docker.com/products/docker-desktop
+Image source lives in `src/`. Each variation has one Dockerfile in `src/variations/`, and the other folders hold shared scripts and config. Scripts in `src/` run inside containers on Debian and Alpine. They must be POSIX `sh` with no Bash syntax.
-## How things work
-1. All files are stored in the `/src` folder
-1. GitHub Actions will automatically build and deploy the images
+## Build an image
-## Running things locally
-To run a build, simply run `./scripts/dev.sh` (with Docker Desktop running). This will show you a help menu with all the available options.
+`scripts/dev.sh` builds one image. Run it with no options to see them all.
-#### Example: Build a FPM-NGINX image running PHP 8.2.12 on Debian Bookworm
```bash [Terminal]
-bash scripts/dev.sh --variation fpm-nginx --version 8.2.12 --os bookworm
+bash scripts/dev.sh --variation fpm-nginx --version 8.5 --os trixie
```
-This will build `serversideup/php:8.2.12-fpm-nginx-bookworm` locally on your machine for testing and inspection.
+This builds `serversideup/php:8.5-fpm-nginx-trixie` on your machine.
-### Published Beta Images
-We also have beta images that are published to our Docker Hub and GitHub Packages repositories.
+Local builds start from the official PHP image on Docker Hub, which is fast. CI compiles PHP from source instead (see [How images are built](/docs/security/how-images-are-built)). To build exactly what CI builds, add `--build-php-base` with a full patch version, such as `--version 8.5.12`. Compiling PHP takes several minutes. It needs Docker's default builder and doesn't work with `--push`.
-| ⚙️ Variation | 🚀 Version |
-| ------------ | ---------- |
-| cli | **Debian Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-cli&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-cli&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-cli&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-cli&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-cli-alpine&page=1&ordering=-name) |
-| fpm | **Debian Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-fpm&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-fpm&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-fpm&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-fpm&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-fpm-alpine&page=1&ordering=-name) |
-| fpm-apache | **Debian Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-fpm-apache&page=1&ordering=-name) |
-| fpm-nginx | **Debian Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-fpm-nginx&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.2-fpm-nginx-alpine&page=1&ordering=-name) |
-| frankenphp | **Debian Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-frankenphp&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-frankenphp&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-frankenphp&page=1&ordering=-name) **Alpine Based** [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.5-frankenphp-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.4-frankenphp-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.3-frankenphp-alpine&page=1&ordering=-name) |
+## Test it
-#### Running a test web server:
-Sometimes you need to just run a test web server locally to see if your changes work. Below is a good example on how to quickly do this.
+`scripts/test-image.sh` runs the same image tests CI runs before it publishes anything:
```bash [Terminal]
-docker run --rm -v $(pwd):/var/www/html -p 80:8080 -p 443:8443 serversideup/php:8.4-fpm-nginx
-```
-
-## How PHP Versions are selected for distribution
-We use the official PHP versions as our base image. To identify which versions should be built, we use a file called `scripts/conf/php-versions-base-config.yml` to explicitly select what versions should be built and any special rules/settings for each version (like base OS, default versions, etc).
-
-We then use a `scripts/get-php-versions.sh` script to download the [latest active releases from PHP](https://www.php.net/releases/active.php) and merge them into a final file called `scripts/conf/php-versions.yml`.
-
-The `php-versions.yml` file will include all final versions for tagging and building.
-
-We generate our tags with a file called `scripts/assemble-docker-tags.sh` which handles all the advanced logic of compiling our tags together.
-
-All the scripts above are designed to run locally and in GitHub Actions. Feel free to execute these scripts to see the help menus and how they work.
+bash scripts/test-image.sh serversideup/php:8.5-fpm-nginx-trixie
-## GitHub Actions
-We use GitHub Actions exclusively to publish all of our releases. If the image exists from DockerHub or GitHub Packages, it will never be published from a local machine.
-
-See `.github/workflows/action_publish-images-beta.yml` for an example of how we publish our beta images.
+# Run one feature area
+TEST_ONLY=web-server bash scripts/test-image.sh serversideup/php:8.5-fpm-nginx-trixie
+```
-### Linting
-Every pull request starts with a `lint` group that runs before any image is built: [actionlint](https://github.com/rhysd/actionlint) for the workflows, [ShellCheck](https://www.shellcheck.net/) for the scripts, and [hadolint](https://github.com/hadolint/hadolint) for the Dockerfiles, plus fixture tests for the helper scripts in `scripts/` that CI relies on. Run the same checks locally before you push:
+Every test runs even after one fails. The summary lists each failure with its output. To check a startup change by hand, run the image with a shell:
```bash [Terminal]
-docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -color
-
-docker run --rm -v "$PWD:/repo" -w /repo koalaman/shellcheck:stable --severity=warning \
- scripts/*.sh scripts/tests/*.sh src/common/usr/local/bin/* src/s6/usr/local/bin/* .oss-scanner/run-variation $(find src -path '*entrypoint.d*' -name '*.sh')
-
-docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile
-
-bash scripts/tests/run.sh
+docker run --rm -it serversideup/php:8.5-fpm-nginx-trixie sh
```
-The script tests live in one file, `scripts/tests/run.sh`, as plain assertions over a small set of sample images. CI runs the same file on a GitHub runner, whose `jq` and `yq` versions can differ from your machine, so a green run there is the one that counts.
+Test entrypoint changes on both a Debian and an Alpine build.
-The Depot runner labels we use are listed in `.github/actionlint.yaml` so actionlint recognizes them. New linters belong in `.github/workflows/service_lint.yml`.
+### Add a test for new behavior
-### What happens when you open a pull request
-Every pull request that touches `src/`, `scripts/`, or the workflows builds the full image matrix for both `amd64` and `arm64`. Jobs are grouped by variation, so you can see at a glance whether `cli`, `fpm`, `fpm-apache`, `fpm-nginx`, and `frankenphp` all build.
+Tests live in `scripts/tests/image/`, one file per feature area (`php.sh`, `entrypoint.sh`, `web-server.sh`, `ssl.sh`, and so on). Add a `test_` function to the file for your area. A new area gets a new file. Never add checks to `scripts/test-image.sh` itself.
-- **Pull requests from forks** are built on isolated builders and never leave them. Each build job also loads its amd64 image and runs the image tests against it, so a green check means your change builds everywhere and passes the tests on amd64.
-- **Pull requests from this repository** go through the same three phases as a release: build, test, publish. Images are published to `serversideup/php-dev` with your PR number as the tag prefix (for example `serversideup/php-dev:699-8.4-fpm-nginx-alpine`) only after the image tests pass. A comment on the PR shows the status and the pull commands.
-- **Every run ends with a summary table** listing each image with its compressed size per architecture, so a size regression is visible before merge.
-- **Nothing is published until it passes the image tests.** Every build is saved to a private registry first. Every image is pulled from there on native amd64 and arm64 runners and checked: it starts, runs unprivileged, has no world-writable files or directories, reports the right PHP version, loads the default extensions, becomes healthy, and serves a PHP file through its web server. Only then is the exact same image promoted to Docker Hub, and the published digest is checked against the tested one, so what you pull is what was tested. If any image fails to build or fails its test, nothing from that run is published. You can run the same check locally with `bash scripts/test-image.sh serversideup/php-dev:`.
-- **Transient failures heal themselves.** If a few jobs fail because a registry or mirror hiccupped, CI re-runs just those jobs once. If a release or weekly rebuild is still red after that, CI opens an issue for the maintainers with the failed jobs and their errors.
-- **Maintainers** can publish a fork's images for testing by running the "Docker Publish (PR Images)" workflow manually with the PR number.
+```bash [scripts/tests/image/web-server.sh]
+requires healthcheck web_server
-## NGINX Versions
-We use the official NGINX repos to install the latest version of NGINX for each OS. The version to install is set by a build argument, which is loaded from the `scripts/conf/php-versions-base-config.yml` file.
+setup() {
+ start_container
+}
-To view the current NGINX versions, run the following command:
-
-#### View NGINX versions
-```bash [Terminal]
-./scripts/get-nginx-versions.sh
-```
-
-This script will look at the official NGINX repos to find the latest version of NGINX for each OS. If you want to update the version, you can run the script with the `--write` flag.
-
-#### Update NGINX versions
-```bash [Terminal]
-./scripts/get-nginx-versions.sh --write
+test_serves_php() {
+ expect_body / "serversideup-php-ok:${php_version}"
+}
```
-### NGINX repository key verification
+- `requires` skips the file, or one test when called inside it, unless the image has the feature (`healthcheck`, `web_server`, `https`, `frankenphp`, `fpm`). Features come from the image's environment variables, not its name, so tests stay generic across variations.
+- `setup` starts one container that every test in the file shares.
+- A test fails when a command fails or it calls `fail "message"`. `skip "reason"` skips it.
+- Helpers (`start_container`, `run_php`, `expect_body`) and image facts (`php_version`, `http_port`, `container`) are in `scripts/tests/image/lib.sh`.
+- Mounted files are in `scripts/tests/image/fixtures/`: `public/` is the web root, `laravel/` is a stand-in app whose `artisan` records each command it receives, and `entrypoint.d/` holds sample startup scripts.
-- **Debian (APT)**: We import the official NGINX GPG key from `https://nginx.org/keys/nginx_signing.key` and verify it against a pinned fingerprint via the `SIGNING_FINGERPRINT` build arg.
-- **Alpine (APK)**: APK uses a raw RSA public key (`nginx_signing.rsa.pub`). We verify this key by pinning the SHA‑256 of the DER‑encoded public key via the `SIGNING_ALPINE_RSA_PUB_SHA256` build arg. You can provide multiple comma‑separated hashes to support key rotation.
+## Run the linters
-#### Compute the Alpine key hash when updating:
+CI runs the same checks on every pull request before it builds any image. Run them all from the repository root:
```bash [Terminal]
-curl -sS https://nginx.org/keys/nginx_signing.rsa.pub -o /tmp/nginx_signing.rsa.pub
-
-# macOS
-openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -outform DER 2>/dev/null | shasum -a 256 | awk '{print $1}'
-
-# Linux
-openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -outform DER 2>/dev/null | sha256sum | awk '{print $1}'
+bash scripts/lint.sh
```
-#### Build with the new hash (optionally include the old hash during rotation)
+Each check runs in Docker at the version CI uses, and the summary lists every failure. To run one, name it:
+| Check | What it covers |
+|---|---|
+| `actionlint` | GitHub Actions workflow syntax |
+| `zizmor` | Workflow security, such as unpinned actions and risky triggers |
+| `shellcheck` | Shell scripts in `scripts/` and `src/` |
+| `hadolint` | Dockerfiles |
+| `scripts` | The build script tests, and that the environment variable and command docs match `src/` |
```bash [Terminal]
-docker build \
- --build-arg SIGNING_ALPINE_RSA_PUB_SHA256="," \
- -f src/variations/fpm-nginx/Dockerfile .
+bash scripts/lint.sh shellcheck hadolint
```
-Reference: [Installing NGINX Open Source → Alpine packages](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-open-source/#prebuilt_alpine).
+- **Document every environment variable and command** in the same change that adds or changes it: variables in `docs/content/data/env-vars.yml`, commands in `/usr/local/bin` in `docs/content/data/commands.yml`. The check scripts fail when one is missing, or when a default or variation list doesn't match the Dockerfiles. Keep both files sorted A–Z.
+
+- **Pin every GitHub Action** to a full commit SHA with its version in a comment, such as `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`. zizmor fails on unpinned actions.
+- **Record lint exceptions with a reason**: zizmor in `.github/zizmor.yml`, hadolint in `.hadolint.yaml`.
+- **Add an assertion to `scripts/tests/run.sh`** when you change a build script it covers, such as `scripts/build-summary.sh`. CI runs it with an older `jq` and `yq` than most laptops have, so a green CI run is the one that counts.
+- **New linters** go in `scripts/lint.sh` and as a job in `.github/workflows/service_lint.yml`.
+
+## Open a pull request
-Why allow multiple hashes? This is optional, but useful during a short rotation window:
+1. Build the variations your change touches and run `scripts/test-image.sh` against each.
+2. Add a test in `scripts/tests/image/` for new behavior.
+3. Run the linters above.
+4. Update the docs in the same pull request if your change affects users.
-- Ensure CI builds across branches/runners succeed while the upstream key change propagates.
-- Avoid flakes from CDN/caching delays where some environments still see the old key.
-- Let you pre-stage the new value before the official switch, then remove the old afterwards.
+A pull request that touches `src/`, `scripts/`, or the image workflows builds and tests every image for `amd64` and `arm64`. A fork's images are built and tested on amd64 but never published. A maintainer can publish them to `serversideup/php-dev` for you to try. [How images are built](/docs/security/how-images-are-built) explains each step.
-If you control all builds centrally and can update quickly, pass a single hash.
+## Other ways to help
-## Helping out
-If you're really eager to help out, here are a few places to get started:
-- Help answer questions on [our GitHub Discussions](https://github.com/serversideup/docker-php/discussions) and [our Discord](https://serversideup.net/discord)
-- Chime in on [issues labeled "Help Wanted"](https://github.com/serversideup/docker-php/issues?q=is%3Aissue+is%3Aopen+label%3A%22%F0%9F%99%8F+Help+Wanted%22)
-- [Open a feature request](https://github.com/serversideup/docker-php/discussions/66) and tell us how we can improve
-- Run performance tests and share your results
-- Say good things and tag us on X (formerly Twitter): [@serversideup](https://x.com/serversideup), [@danpastori](https://x.com/danpastori), [@jaydrogers](https://x.com/jaydrogers)
+- Answer questions in [GitHub Discussions](https://github.com/serversideup/docker-php/discussions){target="_blank"} and on [Discord](https://serversideup.net/discord){target="_blank"}.
+- Pick up an [issue labeled "Help Wanted"](https://github.com/serversideup/docker-php/issues?q=is%3Aissue+is%3Aopen+label%3A%22%F0%9F%99%8F+Help+Wanted%22){target="_blank"}.
+- [Request a feature](https://github.com/serversideup/docker-php/discussions/66){target="_blank"} and tell us what you need.
+- Run performance tests and share your results.
diff --git a/docs/content/docs/2.image-variations/cli.md b/docs/content/docs/2.image-variations/cli.md
index e5bd4a3bd..2121e9628 100644
--- a/docs/content/docs/2.image-variations/cli.md
+++ b/docs/content/docs/2.image-variations/cli.md
@@ -1,81 +1,62 @@
---
title: CLI
-description: 'Learn how to use the CLI variation of the serversideup/php image.'
+description: 'Run PHP and Composer from the command line with the cli variation, which has no web server.'
---
::lead-p
-The CLI variation is a minimal image designed for running PHP from the command line only. It does not include a web server.
-
-Use this variation for running commands like Composer, running one-off scripts, or executing PHP commands that don't require a web server.
+The `cli` variation runs PHP from the command line, with no web server and no process supervisor. Choose it for Composer, one-off scripts, running PHP without installing it on your machine, or [Laravel Octane on Swoole or RoadRunner](/docs/framework-guides/laravel/octane#run-octane-on-swoole-or-roadrunner). [Compare every variation →](/docs/getting-started/choosing-an-image)
::
-## When to Use CLI
-Use the CLI variation when you need to:
+## At a glance
-- Run Composer for dependency management
-- Execute one-off PHP scripts
-- Run queue workers, the scheduler, or Horizon
-- Run Laravel Octane on Swoole, Open Swoole, or RoadRunner
-- Need a very small image size
+| | `cli` |
+|---|---|
+| Processes | One: the command you pass. No supervisor. |
+| Ports | None |
+| Stop signal | `SIGTERM` |
+| Default health check | None |
+| Webroot variable | Not used |
+| PHP versions | 8.1 to 8.5, plus the 8.6 release candidate |
+| Operating systems | Debian and Alpine |
-#### Perfect for
-- Running PHP locally without needing to install PHP on your host system.
-- Serving a Laravel Octane application when you do not want [FrankenPHP](/docs/image-variations/frankenphp). See [Running Octane Without FrankenPHP](/docs/framework-guides/laravel/octane#running-octane-without-frankenphp).
+The image also includes `composer` and `install-php-extensions`. See [default configurations](/docs/getting-started/default-configurations) for the full list of extensions and settings.
-#### What's Inside
+## Quick start
+Mount your project into `/var/www/html` and open a shell:
-| Item | Status |
-|------|--------|
-| PHP CLI binary | ✅ |
-| Common PHP extensions | ✅ |
-| `composer` executable | ✅ |
-| `install-php-extensions` script | ✅ |
-| Essential system utilities | ✅ |
-| Native health checks | ❌ |
-| Web server | ❌ (no web server included) |
-| Process management | Single entrypoint, single process |
-| Exposed Ports | None |
-| Stop Signal | `SIGTERM` |
+```bash [Terminal]
+docker run --rm -it -v "$(pwd):/var/www/html" serversideup/php:8.5-cli bash
+```
-## Quick Start
-Here are a few quick examples to get you started.
+Or run one command and exit:
-### Docker CLI
```bash [Terminal]
-docker run -it -v $(pwd):/var/www/html serversideup/php:cli bash
+docker run --rm -v "$(pwd):/var/www/html" serversideup/php:8.5-cli composer install
```
-The above command will mount your current directory as the `/var/www/html` directory in the container and open a bash shell inside the container where PHP is installed. To exit, just type `exit`.
+Alpine images have no `bash`. Use `sh` with `serversideup/php:8.5-cli-alpine`.
-### Docker Compose
-If you want something more repeatable, you can use Docker Compose to start a container with the CLI variation and mount your current directory as the `/var/www/html` directory in the container.
+### With Docker Compose
+Define the service once:
-```yml [compose.yml]
+```yaml [compose.yml]
services:
php:
- image: serversideup/php:cli
+ image: serversideup/php:8.5-cli
volumes:
- ./:/var/www/html
```
-Once you have your `compose.yml` file set, you can use the `docker compose` cli to start a container with your configuration.
-
-```bash [Terminal]
-docker compose run -it php bash
-```
-
-Or you can pass commands directly to the container without starting a shell.
-
-::note
-Don't get confused. `php` is in this command twice because it's the name of the service and the command to run inside the container. If this is too confusing, you can set your service name to something else like `app` in your `compose.yml` file.
-::
+Then run commands through it. The first `php` is the service name and the second is the command:
```bash [Terminal]
-docker compose run php php my-script.php
+docker compose run --rm php php my-script.php
```
-### Further Customization
-If you need to customize the container further, reference the docs below:
+## Laravel workers and the scheduler
+Queue workers, Horizon, and the scheduler usually run from the same image as your web app with a different command, so every container has the same code and extensions. See [Laravel workers](/docs/framework-guides/laravel/workers) for the commands and health checks.
-- [Environment Variable Specifications](/docs/reference/environment-variable-specification) - See which environment variables are available to customize common PHP settings.
-- [Command Reference](/docs/reference/command-reference) - See which commands are available to run inside the container.
+## Related pages
+- [Environment variable specification](/docs/reference/environment-variable-specification): every `PHP_*` setting
+- [Command reference](/docs/reference/command-reference): helper commands inside the container
+- [Installing additional PHP extensions](/docs/customizing-the-image/installing-additional-php-extensions)
diff --git a/docs/content/docs/2.image-variations/fpm-apache.md b/docs/content/docs/2.image-variations/fpm-apache.md
index 58cab7791..89f2763b9 100644
--- a/docs/content/docs/2.image-variations/fpm-apache.md
+++ b/docs/content/docs/2.image-variations/fpm-apache.md
@@ -1,251 +1,110 @@
---
title: FPM-Apache
-description: 'Learn how to use the FPM-Apache variation of the serversideup/php image.'
+description: 'Run Apache and PHP-FPM together in one container with the fpm-apache variation.'
---
::lead-p
-The FPM-Apache variation combines PHP-FPM with Apache as a reverse proxy in a single container. Apache serves static content directly and forwards PHP requests to PHP-FPM for processing.
-
-Use this variation when you need Apache-specific features, `.htaccess` support, or want an all-in-one solution for running PHP applications.
+The `fpm-apache` variation runs Apache and PHP-FPM in one container, supervised by S6 Overlay. Choose it when your app depends on `.htaccess` files or Apache modules, as WordPress and many older PHP apps do. [Compare every variation →](/docs/getting-started/choosing-an-image)
::
-## When to Use FPM-Apache
-Use the FPM-Apache variation when you need to:
-
-- Run WordPress sites that rely on `.htaccess` configurations
-- Use Apache-specific modules like `mod_rewrite` or `mod_security`
-- Deploy applications that require `.htaccess` support
-- Want an all-in-one container with both web server and PHP processing
-- Need Apache's mature ecosystem and widespread documentation
-
-#### Perfect for
-- WordPress hosting with Docker
-- Legacy PHP applications that depend on Apache
-- Teams familiar with Apache configuration
-- Applications requiring `.htaccess` support
-
-#### What's Inside
-
-| Item | Status |
-|------|--------|
-| Apache web server | ✅ |
-| PHP-FPM process manager | ✅ |
-| PHP CLI binary | ✅ |
-| Common PHP extensions | ✅ |
-| `composer` executable | ✅ |
-| `install-php-extensions` script | ✅ |
-| Essential system utilities | ✅ |
-| S6 Overlay (process supervisor) | ✅ |
-| Native health checks | ✅ (via HTTP endpoint) |
-| `.htaccess` support | ✅ |
-| SSL/TLS support | ✅ (self-signed certificates or bring your own) |
-| Process management | S6 Overlay supervising both Apache and PHP-FPM |
-| Exposed Ports | `8080` (HTTP), `8443` (HTTPS) |
-| Stop Signal | `SIGQUIT` |
-
-## How FPM-Apache Works
-This variation runs both Apache and PHP-FPM in a single container, managed by S6 Overlay (for the most accurate process supervision). Here's how requests flow:
+## At a glance
-::steps{level="4"}
+| | `fpm-apache` |
+|---|---|
+| Processes | Apache (event MPM) and PHP-FPM, supervised by S6 Overlay |
+| Ports | `8080` (HTTP), `8443` (HTTPS) |
+| Stop signal | `SIGQUIT` |
+| Default health check | `curl` to `http://localhost:${APACHE_HTTP_PORT}${HEALTHCHECK_PATH}` (`/healthcheck` by default) |
+| Webroot variable | `APACHE_DOCUMENT_ROOT` (default `/var/www/html/public`) |
+| PHP versions | 8.1 to 8.5, plus the 8.6 release candidate |
+| Operating systems | Debian only. There is no Alpine build. |
-#### Client sends HTTP request
-The container listens on port 8080 (or 8443 for HTTPS) for incoming HTTP requests.
+Requests flow the same way as in [`fpm-nginx`](/docs/image-variations/fpm-nginx#how-a-request-flows-through-nginx-and-php-fpm), with Apache in place of NGINX. One difference: Apache has no built-in rule that sends unknown paths to `index.php`. Your app's `.htaccess` does that, such as the one Laravel ships in `public/.htaccess`. Without it, routes other than real files return `404`.
-#### Apache receives the request
-Apache receives the request and determines if it's a static file or PHP script.
-
-#### Check for static files
-Static files (CSS, JavaScript, images) are served directly by Apache.
-
-#### Forward PHP requests to PHP-FPM
-PHP requests are forwarded to PHP-FPM via FastCGI protocol.
-
-#### Process PHP requests with PHP-FPM
-PHP-FPM processes the PHP script and returns the result to Apache.
-
-#### Send the response back to the client
-Apache sends the response back to the client.
-
-::
+## Quick start
+Put your app's public files in `public/`, which is the default document root:
-S6 Overlay ensures both Apache and PHP-FPM are running and automatically restarts them if either process fails.
+::code-tree{defaultValue="compose.yml"}
-::note
-If you don't specifically need Apache, consider using the [`fpm-nginx`](/docs/image-variations/fpm-nginx) or [`frankenphp`](/docs/image-variations/frankenphp) variations instead. They offer better performance for modern PHP applications.
-::
-
-## Quick Start
-Here are a few examples to help you get started with the FPM-Apache variation.
-
-### Docker CLI
-```bash [Terminal]
-docker run -p 80:8080 -v $(pwd):/var/www/html/public serversideup/php:8.5-fpm-apache
-```
-
-Your application will be available at `http://localhost`. The default document root is `/var/www/html/public`.
-
-### Docker Compose
-::warning
-Notice how we're mapping the current directory to `/var/www/html/`, but the actual default document root is `/var/www/html/public`. We're assuming you're creating the `public` directory and putting your PHP code in there. It's not best practice to expose your `compose.yml` file. See the [Installation guide](/docs/getting-started/installation) for a full example.
-::
-
-This is the recommended approach for local development and production deployments.
-
-```yml [compose.yml]
+```yaml [compose.yml]
services:
php:
image: serversideup/php:8.5-fpm-apache
ports:
- "80:8080"
- - "443:8443"
volumes:
- ./:/var/www/html
- environment:
- PHP_OPCACHE_ENABLE: "1"
- SSL_MODE: "full"
```
-::tip
-The FPM-Apache variation uses ports 8080 and 8443 (instead of 80 and 443) to allow the container to run as a non-root user for better security.
-::
+```php [public/index.php]
+
+ Require ip 10.0.0.0/8
+
```
-Available SSL modes:
-- `off` - SSL disabled (default)
-- `mixed` - Both HTTP (8080) and HTTPS (8443) enabled
-- `full` - HTTPS only on port 8443
-
-### Custom SSL Certificates
-For production, use your own SSL certificates:
+::code-group
-```yml [compose.yml]
+```yaml [compose.yml]
services:
php:
image: serversideup/php:8.5-fpm-apache
ports:
- - "443:8443"
+ - "80:8080"
volumes:
- ./:/var/www/html
- - ./certs/server.crt:/etc/ssl/private/self-signed-web.crt:ro
- - ./certs/server.key:/etc/ssl/private/self-signed-web.key:ro
- environment:
- SSL_MODE: "full"
+ - ./custom.conf:/etc/apache2/conf-enabled/custom.conf:ro
```
-::warning
-For production deployments, consider using a reverse proxy like Traefik or Caddy to handle SSL termination instead of managing certificates in the container.
-::
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-apache
+COPY custom.conf /etc/apache2/conf-enabled/
+```
-## Environment Variables
-The FPM-Apache variation supports extensive customization through environment variables.
-
-### Apache Configuration
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `APACHE_DOCUMENT_ROOT` | `/var/www/html/public` | Document root for Apache |
-| `APACHE_START_SERVERS` | `2` | Number of Apache server processes to start |
-| `APACHE_MIN_SPARE_THREADS` | `10` | Minimum idle threads |
-| `APACHE_MAX_SPARE_THREADS` | `75` | Maximum idle threads |
-| `APACHE_THREADS_PER_CHILD` | `25` | Number of threads per child process |
-| `APACHE_MAX_REQUEST_WORKERS` | `150` | Maximum simultaneous connections |
-| `APACHE_MAX_CONNECTIONS_PER_CHILD` | `0` | Requests before child process restarts (0 = unlimited) |
-| `SSL_MODE` | `off` | SSL mode: `off`, `mixed`, or `full` |
-| `SSL_CERTIFICATE_FILE` | `/etc/ssl/private/self-signed-web.crt` | Path to SSL certificate |
-| `SSL_PRIVATE_KEY_FILE` | `/etc/ssl/private/self-signed-web.key` | Path to SSL private key |
-| `HEALTHCHECK_PATH` | `/healthcheck` | Path for health check endpoint |
-
-::tip{to="/docs/reference/environment-variable-specification"}
-For a complete list of available environment variables, see the [Environment Variable Specification →](/docs/reference/environment-variable-specification).
::
-### PHP-FPM Configuration
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PHP_FPM_POOL_NAME` | `www` | Name of the PHP-FPM pool |
-| `PHP_FPM_PM_CONTROL` | `ondemand` | Process manager control (`dynamic`, `static`, `ondemand`) |
-| `PHP_FPM_PM_MAX_CHILDREN` | `20` | Maximum number of child processes |
-| `PHP_FPM_PM_START_SERVERS` | `2` | Number of child processes created on startup |
-| `PHP_FPM_PM_MIN_SPARE_SERVERS` | `1` | Minimum number of idle processes |
-| `PHP_FPM_PM_MAX_SPARE_SERVERS` | `3` | Maximum number of idle processes |
-| `PHP_MEMORY_LIMIT` | `256M` | Maximum memory a script can use |
-| `PHP_MAX_EXECUTION_TIME` | `99` | Maximum time a script can run (seconds) |
-| `PHP_UPLOAD_MAX_FILE_SIZE` | `100M` | Maximum upload file size |
-| `PHP_FILE_UPLOADS` | `On` | Whether HTTP file uploads are allowed |
-| `PHP_MAX_FILE_UPLOADS` | `20` | Maximum number of files per request |
-| `PHP_POST_MAX_SIZE` | `100M` | Maximum POST request size |
-
-::tip{to="/docs/reference/environment-variable-specification"}
-For a complete list of available environment variables, see the [Environment Variable Specification →](/docs/reference/environment-variable-specification).
-::
+The image's own rules are in `conf-enabled/security.conf`, `remoteip.conf`, and `serversideup.conf`. A file with the same name replaces the image's file.
+
+### Replace a site
+Each `SSL_MODE` has its own virtual host file in `/etc/apache2/sites-available/`: `ssl-off.conf`, `ssl-mixed.conf`, and `ssl-full.conf`. Mount your own file over the one for your mode to replace it. You then own the health check route and the PHP handler. Start from a copy of the [image's files](https://github.com/serversideup/docker-php/tree/main/src/variations/fpm-apache/etc/apache2){target="_blank"}.
-## Further Customization
-If you need to customize the container further, reference the docs below:
+## Health checks
+PHP-FPM answers the `/healthcheck` route. The default health check fails if either Apache or PHP-FPM is down. Set `HEALTHCHECK_PATH=/up` to check Laravel's health route instead. [Health checks](/docs/deployment-and-production/health-checks) covers timing and orchestrators. [Laravel health checks](/docs/framework-guides/laravel/health-checks) covers `/up` and worker containers.
-- [Environment Variable Specification](/docs/reference/environment-variable-specification) - See which environment variables are available to customize PHP and Apache settings.
-- [Command Reference](/docs/reference/command-reference) - See which commands are available to run inside the container.
+## SSL
+`SSL_MODE=mixed` serves HTTP on `8080` and HTTPS on `8443`. `SSL_MODE=full` serves HTTPS on `8443` and redirects HTTP on `8080` to it. The image generates a self-signed certificate unless you provide one. See [configuring SSL](/docs/deployment-and-production/configuring-ssl).
+## Related pages
+- [Using WordPress with Docker](/docs/framework-guides/wordpress/using-wordpress-with-docker)
+- [Configuring trusted proxies](/docs/deployment-and-production/configuring-trusted-proxies)
+- [Production performance tuning](/docs/deployment-and-production/production-performance-tuning)
+- [Command reference](/docs/reference/command-reference)
diff --git a/docs/content/docs/2.image-variations/fpm-nginx.md b/docs/content/docs/2.image-variations/fpm-nginx.md
index 7e635a734..4636bdaff 100644
--- a/docs/content/docs/2.image-variations/fpm-nginx.md
+++ b/docs/content/docs/2.image-variations/fpm-nginx.md
@@ -1,94 +1,39 @@
---
title: FPM-NGINX
-description: 'Learn how to use the FPM-NGINX variation of the serversideup/php image.'
+description: 'Run NGINX and PHP-FPM together in one container with the fpm-nginx variation.'
---
::lead-p
-The FPM-NGINX variation combines PHP-FPM with NGINX as a reverse proxy in a single container. This is the traditional setup widely adopted for many PHP applications and is currently is the best balance of performance, stability, and compatibility. If you want the latest and greatest, consider using the [FrankenPHP variation →](/docs/image-variations/frankenphp).
+The `fpm-nginx` variation runs NGINX and PHP-FPM in one container, supervised by S6 Overlay. Choose it for Laravel and other PHP apps that run on PHP-FPM when you want the web server included and don't need Apache's `.htaccess` files or FrankenPHP's worker mode. [Compare every variation →](/docs/getting-started/choosing-an-image)
::
-## When to Use FPM-NGINX
-Use the FPM-NGINX variation when you need to:
+## At a glance
-- Run Laravel applications with excellent performance and stability
-- Want an all-in-one container with both web server and PHP processing
-- Need a fast, lightweight web server with low resource consumption
-- Serve static assets efficiently while processing PHP requests
+| | `fpm-nginx` |
+|---|---|
+| Processes | NGINX and PHP-FPM, supervised by S6 Overlay |
+| Ports | `8080` (HTTP), `8443` (HTTPS) |
+| Stop signal | `SIGQUIT` |
+| Default health check | `curl` to `http://localhost:${NGINX_HTTP_PORT}${HEALTHCHECK_PATH}` (`/healthcheck` by default) |
+| Webroot variable | `NGINX_WEBROOT` (default `/var/www/html/public`) |
+| PHP versions | 8.1 to 8.5, plus the 8.6 release candidate |
+| Operating systems | Debian and Alpine |
-#### Perfect for
-- Laravel applications (this is our most popular variation for Laravel)
-- Modern PHP frameworks (Symfony, etc.)
-- API-first applications
-- Production deployments requiring high performance and stability
+## How a request flows through NGINX and PHP-FPM
-#### What's Inside
+1. NGINX receives the request on `8080` or `8443`.
+2. If the path matches a file in the webroot, such as a CSS file or image, NGINX serves it directly.
+3. Any other path is rewritten to `/index.php`, and NGINX passes `.php` requests to PHP-FPM over FastCGI on `127.0.0.1:9000`.
+4. PHP-FPM runs the script and returns the response through NGINX.
-| Item | Status |
-|------|--------|
-| NGINX web server | ✅ |
-| PHP-FPM process manager | ✅ |
-| PHP CLI binary | ✅ |
-| Common PHP extensions | ✅ |
-| `composer` executable | ✅ |
-| `install-php-extensions` script | ✅ |
-| Essential system utilities | ✅ |
-| S6 Overlay (process supervisor) | ✅ |
-| Native health checks | ✅ (via HTTP endpoint) |
-| SSL/TLS support | ✅ (self-signed certificates) |
-| Process management | S6 Overlay supervising both NGINX and PHP-FPM |
-| Exposed Ports | `8080` (HTTP), `8443` (HTTPS) |
-| Stop Signal | `SIGQUIT` |
+S6 Overlay starts PHP-FPM before NGINX and restarts either one if it exits. PHP-FPM answers the `/healthcheck` route. The default health check fails if either process is down.
-## How FPM-NGINX Works
-This variation runs both NGINX and PHP-FPM in a single container, managed by S6 Overlay. Here's how requests flow:
+## Quick start
+Put your app's public files in `public/`, which is the default webroot:
-::steps{level="4"}
+::code-tree{defaultValue="compose.yml"}
-#### Client sends HTTP request
-The container listens on port 8080 (or 8443 for HTTPS) for incoming HTTP requests.
-
-#### NGINX receives the request
-NGINX receives the request and determines if it's a static file or PHP script.
-
-#### Check for static files
-Static files (CSS, JavaScript, images) are served directly by NGINX.
-
-#### Forward PHP requests to PHP-FPM
-PHP requests are forwarded to PHP-FPM via FastCGI protocol.
-
-#### Process PHP requests with PHP-FPM
-PHP-FPM processes the PHP script and returns the result to NGINX.
-
-#### Send the response back to the client
-NGINX sends the response back to the client.
-
-::
-
-S6 Overlay ensures both NGINX and PHP-FPM are running and automatically restarts them if either process fails.
-
-::tip
-This variation offers better performance than FPM-Apache for most modern PHP applications. NGINX is designed to handle high concurrency with lower resource consumption.
-::
-
-## Quick Start
-Here are a few examples to help you get started with the FPM-NGINX variation.
-
-### Docker CLI
-```bash [Terminal]
-docker run -p 80:8080 -v $(pwd):/var/www/html/public serversideup/php:8.5-fpm-nginx
-```
-
-Your application will be available at `http://localhost`. The default webroot is `/var/www/html/public`.
-
-### Docker Compose
-
-::warning
-Notice how we're mapping the current directory to `/var/www/html/`, but the actual default document root is `/var/www/html/public`. We're assuming you're creating the `public` directory and putting your PHP code in there. It's not best practice to expose your `compose.yml` file. See the [Installation guide](/docs/getting-started/installation) for a full example.
-::
-
-This is the recommended approach for local development and production deployments.
-
-```yml [compose.yml]
+```yaml [compose.yml]
services:
php:
image: serversideup/php:8.5-fpm-nginx
@@ -96,210 +41,69 @@ services:
- "80:8080"
volumes:
- ./:/var/www/html
- environment:
- PHP_OPCACHE_ENABLE: "1"
```
-::tip
-The FPM-NGINX variation uses ports 8080 and 8443 (instead of 80 and 443) to allow the container to run as a non-root user for better security.
-::
-
-### Laravel Example
-The FPM-NGINX variation is perfectly suited for Laravel applications:
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - "80:8080"
- - "443:8443"
- volumes:
- - .:/var/www/html
- environment:
- SSL_MODE: "full"
- PHP_OPCACHE_ENABLE: "1"
- depends_on:
- - mariadb
- - redis
-
- mariadb:
- image: mariadb:11
- environment:
- MYSQL_ROOT_PASSWORD: rootpassword
- MYSQL_DATABASE: laravel
- MYSQL_USER: laravel
- MYSQL_PASSWORD: laravel
- volumes:
- - db_data:/var/lib/mysql
-
- redis:
- image: redis:alpine
- command: redis-server --appendonly yes
- volumes:
- - redis_data:/data
-
-volumes:
- db_data:
- redis_data:
+```php [public/index.php]
+
-```
-::
-
-::tip
-The FrankenPHP variation uses ports 8080 and 8443 (instead of 80 and 443) to allow the container to run as a non-root user for better security.
-::
-
-### Laravel Octane
-Laravel Octane natively supports FrankenPHP. Pass `--caddyfile=/etc/frankenphp/Caddyfile` to `octane:start` and our Caddyfile switches into worker mode while keeping the same production configuration as classic mode. Use our guide below to learn more.
-
-:u-button{to="/docs/framework-guides/laravel/octane" label="Learn more about Laravel Octane" aria-label="Learn more about Laravel Octane" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### Health Check
-The FrankenPHP variation includes a built-in health check that verifies the server is responding:
-
-::note
-The health check endpoint is configurable via the `HEALTHCHECK_PATH` environment variable, which defaults to `/healthcheck`.
-::
-
-If you are using Laravel, you can use the `/up` route to validate that Laravel is running and healthy.
-
-:u-button{to="/docs/guide/using-healthchecks-with-laravel" label="Learn more about using healthchecks with Laravel" aria-label="Learn more about using healthchecks with Laravel" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Automatic HTTPS
-One of FrankenPHP's standout features is automatic HTTPS powered by Caddy. It can automatically obtain and renew SSL certificates from Let's Encrypt.
-
-::tip{to="/docs/deployment-and-production/configuring-ssl"}
-See our [Configuring SSL](/docs/deployment-and-production/configuring-ssl) guide for more information on the best strategies for running SSL in production.
-::
-
-### Enabling Automatic HTTPS
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-frankenphp
- ports:
- - "80:8080"
- - "443:8443"
- volumes:
- - ./:/var/www/html
- environment:
- CADDY_AUTO_HTTPS: "on"
- # Your domain for automatic certificate
- SERVER_NAME: "example.com"
- SSL_MODE: "full"
-```
-
-::warning
-Automatic HTTPS requires a public domain name and ports 80/443 accessible from the internet for Let's Encrypt validation. For local development, use self-signed certificates with `SSL_MODE`.
-::
-
-Need Let's Encrypt short-lived certificates or IP-address certificates? Set `CADDY_ACME_PROFILE: "shortlived"`. See [Short-lived & IP-address certificates](/docs/deployment-and-production/configuring-ssl#short-lived--ip-address-certificates) for the trade-offs and the `default_sni` setup for SNI-less access.
-
-### SSL Modes for Development
-For local development, use the `SSL_MODE` environment variable:
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-frankenphp
- ports:
- - "80:8080"
- - "443:8443"
- volumes:
- - ./:/var/www/html
- environment:
- SSL_MODE: "full"
```
-Available SSL modes:
-- `off` - SSL disabled (default)
-- `mixed` - Both HTTP (8080) and HTTPS (8443) enabled
-- `full` - HTTPS only on port 8443
-
-Learn more about SSL modes in the [Configuring SSL](/docs/deployment-and-production/configuring-ssl) guide.
-
-:u-button{to="/docs/deployment-and-production/configuring-ssl" label="Learn more about SSL modes" aria-label="Learn more about SSL modes" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Logging
-FrankenPHP is built on Caddy, and Caddy handles logs differently from NGINX and Apache. Caddy does have an [access log](https://caddyserver.com/docs/caddyfile/directives/log){target="_blank"}, but it is a named logger that shares the same [structured format](https://caddyserver.com/docs/logging){target="_blank"} and default output as Caddy's runtime log. Every entry carries its own level: requests are logged at `INFO` and problems at `ERROR`.
-
-Because both logs share one format and one default output, the FrankenPHP variation sends everything to `stderr`. That is [Caddy's default](https://caddyserver.com/docs/caddyfile/directives/log#output){target="_blank"}, it is what the official FrankenPHP image does, and it is what Laravel Octane expects. Our NGINX and Apache variations keep the traditional split of access logs on `stdout` and error logs on `stderr`, because that is what the official images for those servers do. [Read how we approach logging across all variations →](/docs/getting-started/default-configurations#logging)
-
-`docker logs`, Docker Compose, and Kubernetes capture both streams, so nothing changes in day-to-day use.
-
-The format follows Caddy's default as well. Caddy [writes human-readable `console` lines when `stderr` is an interactive terminal and JSON otherwise](https://caddyserver.com/docs/caddyfile/directives/log#format){target="_blank"}. A container started by Docker Compose, Docker Swarm, or Kubernetes has no terminal, so it gets one JSON object per line. That is what log collectors expect, and every entry carries a `level` field your log pipeline can map to a severity instead of guessing from the stream (for example, [GKE tags `stderr` as `ERROR`](https://docs.cloud.google.com/kubernetes-engine/docs/concepts/about-logs){target="_blank"} unless it can read a severity). `docker run -it` and `tty: true` give you the console lines instead. Set `CADDY_LOG_FORMAT` if you want the same format everywhere:
-- `CADDY_LOG_FORMAT=console` if you read logs by eye with `docker compose logs` or `docker service logs` and want the colored, human-readable lines whether or not a terminal is attached.
-- `CADDY_LOG_FORMAT=json` if a container runs with a terminal attached but you still want structured logs.
-
-In both formats the request log redacts the `authorization` query parameter, so the JWT that Mercure 0.x subscribers pass in the URL never lands in your logs. This is the same filter that [FrankenPHP's own Caddyfile](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile){target="_blank"} recommends.
-
-::warning
-Laravel Octane only relays FrankenPHP's `stderr` and only understands JSON, so leave `CADDY_LOG_OUTPUT` and `CADDY_LOG_FORMAT` at their defaults when you run Octane. See [Logging with Octane](/docs/framework-guides/laravel/octane#logging).
::
-Control the verbosity with `LOG_OUTPUT_LEVEL`. It defaults to `info` for FrankenPHP so request logs are included. Set it to `warn` to log problems only.
+Run `docker compose up` and open `http://localhost`. Mounting your code like this is for development. In production, copy the code into your own image instead; see [packaging your app for deployment](/docs/deployment-and-production/packaging-your-app-for-deployment).
-## Mercure
-[Mercure](https://mercure.rocks){target="_blank"} pushes real-time updates from your app to the browser. FrankenPHP has a Mercure hub built in, and you turn it on with environment variables instead of editing a Caddyfile. The hub answers at `/.well-known/mercure` on the same ports as your app, in classic mode and with Laravel Octane.
+## Configuration
+These variables are specific to this variation. Defaults and the full list, including every `PHP_*` option, are in the [environment variable specification](/docs/reference/environment-variable-specification).
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-frankenphp
- ports:
- - "80:8080"
- volumes:
- - ./:/var/www/html
- environment:
- MERCURE_ENABLED: "true"
- MERCURE_TRUSTED_ISSUERS: "https://example.com"
- MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_JWT_SECRET}"
- MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_JWT_SECRET}"
-```
-
-Docker Compose reads `MERCURE_JWT_SECRET` from your `.env` file. Generate a secret with `openssl rand -base64 32`.
+| To change | Set |
+|---|---|
+| Document root | `CADDY_SERVER_ROOT` |
+| Listening ports | `CADDY_HTTP_PORT`, `CADDY_HTTPS_PORT` |
+| Let's Encrypt certificates | `CADDY_AUTO_HTTPS`, `CADDY_HTTPS_SERVER_ADDRESS`, `CADDY_ACME_PROFILE` |
+| Log format and destination | `CADDY_LOG_FORMAT`, `CADDY_LOG_OUTPUT` |
+| Caddy admin API | `CADDY_ADMIN` |
+| FrankenPHP and `php_server` options | `FRANKENPHP_CONFIG`, `CADDY_PHP_SERVER_OPTIONS` |
+| Raw Caddyfile rules | `CADDY_GLOBAL_OPTIONS`, `CADDY_SERVER_EXTRA_DIRECTIVES`, or the folders in [Adding your own Caddyfile rules](#adding-your-own-caddyfile-rules) |
+| Mercure hub | `MERCURE_*`, see [Mercure](#mercure) |
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `MERCURE_ENABLED` | `false` | Set to `true` to turn on the Mercure hub |
-| `MERCURE_TRUSTED_ISSUERS` | `https://localhost` | The `iss` claim your tokens carry, usually your app's URL. The hub rejects tokens from any other issuer |
-| `MERCURE_PUBLISHER_JWT_KEY` | | Shared secret or PEM public key that verifies publisher tokens. Required when the hub is on |
-| `MERCURE_PUBLISHER_JWT_ALG` | `HS256` | Algorithm for the publisher key. A PEM key needs an asymmetric algorithm such as `RS256` |
-| `MERCURE_SUBSCRIBER_JWT_KEY` | | Shared secret or PEM public key that verifies subscriber tokens. Required when the hub is on |
-| `MERCURE_SUBSCRIBER_JWT_ALG` | `HS256` | Algorithm for the subscriber key |
-| `MERCURE_EXTRA_DIRECTIVES` | `""` | More [Mercure directives](https://mercure.rocks/docs/deployment/configuration){target="_blank"}, one per line |
+## Adding your own Caddyfile rules
+When the variables above don't cover a rule, add raw Caddyfile config at the level where it belongs. Every `.caddyfile` file in a folder is imported at that level:
-The hub only accepts subscribers with a valid token. Use `MERCURE_EXTRA_DIRECTIVES` to allow anonymous subscribers to public updates or to set CORS origins:
-
-```yml [compose.yml]
- environment:
- MERCURE_EXTRA_DIRECTIVES: |
- anonymous
- cors_origins https://example.com
-```
-
-::note
-FrankenPHP 1.13 includes Mercure 1.0, which expects [OAuth 2.0 access tokens](https://github.com/dunglas/mercure/blob/v1.0.3/docs/UPGRADE.md#migrate-your-tokens){target="_blank"} with `iss`, `aud`, and `exp` claims. If your app or library still signs Mercure 0.x tokens, set `MERCURE_EXTRA_DIRECTIVES: "protocol_version_compatibility 8"` while you migrate. Compatibility mode relaxes token checks, so remove it once your tokens are updated.
-::
-
-## Environment Variables
-The FrankenPHP variation supports extensive customization through environment variables.
-
-### FrankenPHP/Caddy Configuration
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `FRANKENPHP_CONFIG` | `""` | FrankenPHP-specific configuration (e.g., worker mode) |
-| `CADDY_SERVER_ROOT` | `/var/www/html/public` | Document root for the application |
-| `CADDY_AUTO_HTTPS` | `off` | Enable automatic HTTPS (`on`/`off`) |
-| `CADDY_ACME_PROFILE` | `off` | Let's Encrypt certificate profile: `off`, `shortlived`, `tlsserver`, or `classic` |
-| `CADDY_HTTP_PORT` | `8080` | HTTP port |
-| `CADDY_HTTPS_PORT` | `8443` | HTTPS port |
-| `CADDY_ADMIN` | `off` | Caddy admin API endpoint |
-| `CADDY_LOG_FORMAT` | `auto` | Log format: `auto` (Caddy's default, `console` on a terminal and `json` otherwise), `console`, or `json` |
-| `CADDY_LOG_OUTPUT` | `stderr` | Log output destination |
-| `CADDY_GLOBAL_OPTIONS` | `""` | Additional Caddy global options |
-| `CADDY_SERVER_EXTRA_DIRECTIVES` | `""` | Additional Caddy server directives |
-| `SSL_MODE` | `off` | SSL mode: `off`, `mixed`, or `full` |
-| `SSL_CERTIFICATE_FILE` | `/etc/ssl/private/self-signed-web.crt` | Path to SSL certificate |
-| `SSL_PRIVATE_KEY_FILE` | `/etc/ssl/private/self-signed-web.key` | Path to SSL private key |
-| `HEALTHCHECK_PATH` | `/healthcheck` | Path for health check endpoint |
-| `SERVER_NAME` | `""` | Domain name for automatic HTTPS |
-
-::tip{to="/docs/reference/environment-variable-specification"}
-For a complete list of available environment variables, see the [Environment Variable Specification →](/docs/reference/environment-variable-specification).
-::
-
-### PHP Configuration
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `PHP_MEMORY_LIMIT` | `256M` | Maximum memory a script can use |
-| `PHP_MAX_EXECUTION_TIME` | `99` | Maximum time a script can run (seconds) |
-| `PHP_UPLOAD_MAX_FILE_SIZE` | `100M` | Maximum upload file size |
-| `PHP_FILE_UPLOADS` | `On` | Whether HTTP file uploads are allowed |
-| `PHP_MAX_FILE_UPLOADS` | `20` | Maximum number of files per request |
-| `PHP_POST_MAX_SIZE` | `100M` | Maximum POST request size |
-| `PHP_OPCACHE_ENABLE` | `0` | Enable OPcache (`0`/`1`) |
-| `PHP_OPCACHE_REVALIDATE_FREQ` | `2` | How often to check for file changes (seconds), only when timestamps are validated |
-| `PHP_OPCACHE_VALIDATE_TIMESTAMPS` | `0` | Whether to check files for changes (`0`/`1`). Set to `1` when mounting code as a volume with OPcache enabled |
-
-## Caddy Configuration
-FrankenPHP uses Caddy's configuration format (Caddyfile) instead of NGINX configuration.
-
-### Adding your own Caddyfile rules
-Most changes only need one of the [environment variables](#frankenphpcaddy-configuration) above, such as `CADDY_SERVER_ROOT`, `SSL_MODE`, or the port settings. When you need a rule the variables don't cover, add raw Caddyfile config at the level where it belongs:
-
-| Level | What goes here | Environment variable | Folder |
-|-------|----------------|----------------------|--------|
+| Level | What goes here | Variable | Folder |
+|-------|----------------|----------|--------|
| Global | [Global options](https://caddyserver.com/docs/caddyfile/options){target="_blank"}, including Caddy's `servers` option | `CADDY_GLOBAL_OPTIONS` | `/etc/frankenphp/caddyfile-global.d/` |
-| Server | [Directives](https://caddyserver.com/docs/caddyfile/directives){target="_blank"} for your app, like `header`, `redir`, and request matchers | `CADDY_SERVER_EXTRA_DIRECTIVES` | `/etc/frankenphp/caddyfile-server.d/` |
-| New sites | Extra site blocks, like a second domain or a reverse proxy | None | `/etc/frankenphp/caddyfile.d/` |
-
-Every `.caddyfile` file in a folder is imported at that level. Use the environment variables for a quick one-liner and the folders for anything longer. The folders also work with [Laravel Octane](/docs/framework-guides/laravel/octane#octane-options), which replaces `CADDY_GLOBAL_OPTIONS` and `CADDY_SERVER_EXTRA_DIRECTIVES` with its own values.
+| Server | [Directives](https://caddyserver.com/docs/caddyfile/directives){target="_blank"} for your app, such as `header`, `redir`, and request matchers | `CADDY_SERVER_EXTRA_DIRECTIVES` | `/etc/frankenphp/caddyfile-server.d/` |
+| New sites | Extra site blocks, such as a second domain or a reverse proxy | None | `/etc/frankenphp/caddyfile.d/` |
-Two more variables cover FrankenPHP itself:
-
-| Variable | What goes here | Official Documentation |
-|----------|----------------|------------------------|
-| `FRANKENPHP_CONFIG` | Options inside the global `frankenphp` block, like `num_threads` | [FrankenPHP Configuration](https://frankenphp.dev/docs/config/#caddyfile-config){target="_blank"} |
-| `CADDY_PHP_SERVER_OPTIONS` | Options inside your app's `php_server` block | [FrankenPHP PHP Server Options](https://frankenphp.dev/docs/config/#caddyfile-config){target="_blank"} |
+Use a variable for a one-liner and a folder for anything longer. Only the folders work with [Laravel Octane](/docs/framework-guides/laravel/octane), which replaces `CADDY_GLOBAL_OPTIONS` and `CADDY_SERVER_EXTRA_DIRECTIVES` with its own values.
This is where each one lands in `/etc/frankenphp/Caddyfile`:
@@ -465,8 +86,8 @@ This is where each one lands in `/etc/frankenphp/Caddyfile`:
frankenphp {
FRANKENPHP_CONFIG
}
- CADDY_GLOBAL_OPTIONS
caddyfile-global.d/*.caddyfile
+ CADDY_GLOBAL_OPTIONS
}
your app's site (one per listener that SSL_MODE creates) {
@@ -480,18 +101,18 @@ your app's site (one per listener that SSL_MODE creates) {
caddyfile.d/*.caddyfile
```
-#### Add rules to your app
-Put directives for your app in `caddyfile-server.d/`. This example gives one JavaScript file a short cache, overriding the year-long cache the image sets for static assets:
+### Add rules to your app
+Put directives for your app in `caddyfile-server.d/`. This example gives one JavaScript file a one-hour cache instead of the one-year cache the image sets for static assets:
```caddyfile [embed-cache.caddyfile]
@embed path /embed.js
header @embed >Cache-Control "public, max-age=3600"
```
-Mount the file in development, or copy it into your image for production:
+Mount the file in development. For production, copy it into your image:
::code-group
-```yml [compose.yml]
+```yaml [compose.yml]
services:
php:
image: serversideup/php:8.5-frankenphp
@@ -505,10 +126,10 @@ COPY embed-cache.caddyfile /etc/frankenphp/caddyfile-server.d/
```
::
-Rules in `caddyfile-server.d/` apply on every listener `SSL_MODE` creates. With `SSL_MODE=mixed`, that includes plain HTTP, so match on `protocol https` for anything that only belongs on HTTPS.
+Rules in `caddyfile-server.d/` apply on every listener `SSL_MODE` creates. With `SSL_MODE=mixed` that includes plain HTTP. Match on `protocol https` for rules that belong only on HTTPS.
::warning
-Docker's health check goes through your app's site too. Leave the health check path out of any rule that matches every request, like `basic_auth` or `redir`, or the container will be marked unhealthy:
+Docker's health check goes through your app's site too. Leave the health check path out of any rule that matches every request, such as `basic_auth` or `redir`, or the container is marked unhealthy:
```caddyfile [basic-auth.caddyfile]
@protected not path /healthcheck {$HEALTHCHECK_PATH:/healthcheck}
@@ -520,7 +141,7 @@ basic_auth @protected {
Generate the password hash with `docker run --rm -it serversideup/php:8.5-frankenphp frankenphp hash-password`.
::
-#### Add another site
+### Add another site
Files in `caddyfile.d/` sit at the top level of the Caddyfile, outside your app's site. Use them for a whole new site block:
```caddyfile [docs-proxy.caddyfile]
@@ -529,15 +150,60 @@ http://docs.example.com:{$CADDY_HTTP_PORT:8080} {
}
```
-Rules for your app don't work here. The `embed-cache.caddyfile` example above fails in this folder with "request matchers may not be defined globally", so put rules like that in `caddyfile-server.d/` instead.
+Rules for your app fail here with "request matchers may not be defined globally". Put those in `caddyfile-server.d/`.
-#### Replace the whole Caddyfile
-If you need a completely different setup, copy your own file over `/etc/frankenphp/Caddyfile`. You then own everything the image's Caddyfile does, including SSL modes, security headers, and the health check endpoint, so reach for the folders first.
+### Replace the whole Caddyfile
+Copy your own file over `/etc/frankenphp/Caddyfile` only if the folders can't do what you need. You then own everything the image's Caddyfile does, including SSL modes, security headers, and the health check endpoint.
+
+## Automatic HTTPS
+FrankenPHP can get and renew a Let's Encrypt certificate itself. Set `CADDY_AUTO_HTTPS=on` and `SSL_MODE=full`, and put your domain (such as `example.com`) in `CADDY_HTTPS_SERVER_ADDRESS`. The server must be reachable from the internet on ports 80 and 443. See [configuring SSL](/docs/deployment-and-production/configuring-ssl) for the full Compose file and the volumes that keep certificates across deployments.
+
+Without automatic HTTPS, `SSL_MODE=mixed` serves HTTP on `8080` and HTTPS on `8443`. `SSL_MODE=full` serves HTTPS on `8443` and redirects HTTP on `8080` to it. Both use a self-signed certificate unless you provide one.
+
+## Health checks
+The default health check works without configuration. Set `HEALTHCHECK_PATH=/up` to check Laravel's health route instead. [Health checks](/docs/deployment-and-production/health-checks) covers timing and orchestrators. [Laravel health checks](/docs/framework-guides/laravel/health-checks) covers `/up` and worker containers.
+
+## Logging
+- Caddy writes request logs and errors to `stderr`, as JSON when no terminal is attached (Compose, Swarm, Kubernetes) and as readable console lines with `docker run -it`. Set `CADDY_LOG_FORMAT` to `json` or `console` to use one format everywhere. Under Laravel Octane, leave `CADDY_LOG_FORMAT` and `CADDY_LOG_OUTPUT` at their defaults, because Octane only reads JSON on `stderr`.
+- `LOG_OUTPUT_LEVEL` defaults to `info` in this variation, so request logs are included. Set it to `warn` to log problems only.
+
+See [default configurations](/docs/getting-started/default-configurations) for how logging differs across variations.
+
+## Mercure
+[Mercure](https://mercure.rocks){target="_blank"} pushes real-time updates from your app to the browser. FrankenPHP has a Mercure hub built in. Turn it on with environment variables. The hub answers at `/.well-known/mercure` on the same ports as your app, in classic mode and with Laravel Octane.
+
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-frankenphp
+ ports:
+ - "80:8080"
+ volumes:
+ - ./:/var/www/html
+ environment:
+ MERCURE_ENABLED: "true"
+ MERCURE_TRUSTED_ISSUERS: "https://example.com"
+ MERCURE_PUBLISHER_JWT_KEY: "${MERCURE_JWT_SECRET}"
+ MERCURE_SUBSCRIBER_JWT_KEY: "${MERCURE_JWT_SECRET}"
+```
+
+Docker Compose reads `MERCURE_JWT_SECRET` from your `.env` file. Generate one with `openssl rand -base64 32`. The hub only accepts subscribers with a valid token; to allow anonymous subscribers or set CORS origins, add [Mercure directives](https://mercure.rocks/docs/deployment/configuration){target="_blank"} with `MERCURE_EXTRA_DIRECTIVES`:
+
+```yaml [compose.yml]
+ environment:
+ MERCURE_EXTRA_DIRECTIVES: |
+ anonymous
+ cors_origins https://example.com
+```
+
+::note
+FrankenPHP 1.13 includes Mercure 1.0, which expects [OAuth 2.0 access tokens](https://github.com/dunglas/mercure/blob/v1.0.3/docs/UPGRADE.md#migrate-your-tokens){target="_blank"} with `iss`, `aud`, and `exp` claims. If your app still signs Mercure 0.x tokens, set `MERCURE_EXTRA_DIRECTIVES: "protocol_version_compatibility 8"` while you migrate, then remove it, because it relaxes token checks.
+::
-## Further Customization
-If you need to customize the container further, reference the docs below:
+The other `MERCURE_*` variables, such as the JWT algorithms, are in the [environment variable specification](/docs/reference/environment-variable-specification).
-- [Environment Variable Specification](/docs/reference/environment-variable-specification) - See which environment variables are available to customize PHP and Caddy settings.
-- [Command Reference](/docs/reference/command-reference) - See which commands are available to run inside the container.
-- [FrankenPHP Documentation](https://frankenphp.dev/){target="_blank"} - Official FrankenPHP documentation for advanced features.
-- [Caddy Documentation](https://caddyserver.com/docs/){target="_blank"} - Official Caddy documentation for web server configuration.
+## Related pages
+- [Laravel Octane](/docs/framework-guides/laravel/octane)
+- [Configuring SSL](/docs/deployment-and-production/configuring-ssl)
+- [Configuring trusted proxies](/docs/deployment-and-production/configuring-trusted-proxies)
+- [FrankenPHP documentation](https://frankenphp.dev/docs/){target="_blank"} and [Caddy documentation](https://caddyserver.com/docs/){target="_blank"}
diff --git a/docs/content/docs/2.image-variations/unit.md b/docs/content/docs/2.image-variations/unit.md
index f31d0a3ef..b7acda29e 100644
--- a/docs/content/docs/2.image-variations/unit.md
+++ b/docs/content/docs/2.image-variations/unit.md
@@ -1,73 +1,15 @@
---
-title: Unit (Deprecated)
-description: 'NGINX Unit has been archived. This guide helps you understand what happened and how to migrate to actively maintained alternatives.'
+title: NGINX Unit (removed)
+description: 'The unit variation was removed in v4.1.0 after NGINX archived the NGINX Unit project.'
---
-## NGINX Unit Has Been Archived
-
-In October 2025, NGINX officially archived the NGINX Unit project and stopped all maintenance. If you're seeing this page, you're likely using our Unit-based images and wondering what to do next.
-
-::caution{to="https://github.com/nginx/unit?tab=readme-ov-file#nginx-unit" target="_blank"}
-**The Unit variation is deprecated and has been removed from our project.** [View official NGINX announcement →](https://github.com/nginx/unit?tab=readme-ov-file#nginx-unit){target="_blank"}
+::lead-p
+The `unit` variation was removed in [v4.1.0](https://github.com/serversideup/docker-php/releases/tag/v4.1.0){target="_blank"} (November 21, 2025), after NGINX [archived the NGINX Unit project](https://github.com/nginx/unit?tab=readme-ov-file#nginx-unit){target="_blank"} in October 2025.
::
-**The good news:** Your application will continue to work, and you have time to plan your migration. Below, we'll answer your most important questions and guide you through your options.
-
-## Common Questions
-
-### Will my application stop working immediately?
-
-No. Your existing containers will continue to run without interruption. However:
-
-- **No security updates:** NGINX Unit will not receive security patches
-- **No bug fixes:** Any issues with Unit itself won't be resolved
-- **No new PHP versions:** Unit may not support future PHP releases
-
-You should prioritize your migration, but you're not in a downtime situation.
-
-### What should I migrate to?
-
-We recommend **FrankenPHP** as the best alternative because it offers:
-
-- **Single-process architecture** (similar to Unit's design)
-- **Built-in HTTP/2 and HTTP/3 support**
-- **Active development** by the PHP community
-- **Laravel Octane support** for enhanced performance
-- **Better performance** than traditional PHP-FPM setups
-
-:u-button{to="/docs/image-variations/frankenphp" label="Learn about FrankenPHP" aria-label="Learn about FrankenPHP variation" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### What if FrankenPHP doesn't work for me?
-
-You have other proven options:
-
-- **[FPM + NGINX](/docs/image-variations/fpm-nginx)** - Traditional, highly scalable setup (recommended for most production apps)
-- **[FPM + Apache](/docs/image-variations/fpm-apache)** - If you need `.htaccess` support or prefer Apache
-- **[CLI](/docs/image-variations/cli)** - For queue workers, scheduled tasks, and CLI-only workloads
-
-All of these variations are actively maintained and production-ready.
-
-### How urgent is this migration?
-
-**Timeline:**
-- **Now:** You can still pull `serversideup/php:unit` images, but they will not receive any future feature or security updates.
-
-**Recommendation:** Start planning your migration now. Don't rush, but don't delay indefinitely.
-
-### Where can I get help?
-
-We're here to support you through this transition:
-
-- **Community Support:** [Post on our forum](https://serversideup.net/php/community) or [join our Discord](https://serversideup.net/discord) for migration questions
-- **Migration Assistance:** Ask questions specific to your setup
-- **Documentation:** Follow our comprehensive guides for each variation
-
-## Need More Information?
-
-Explore the documentation for your chosen variation:
+Existing `unit` tags can still be pulled, but they are frozen at v4.0.0 and get no PHP, operating system, or security updates. Move to one of these variations:
-- [FrankenPHP Documentation](/docs/image-variations/frankenphp)
-- [FPM + NGINX Documentation](/docs/image-variations/fpm-nginx)
-- [FPM + Apache Documentation](/docs/image-variations/fpm-apache)
+- [`fpm-nginx`](/docs/image-variations/fpm-nginx): NGINX and PHP-FPM in one container. The closest match for most apps.
+- [`frankenphp`](/docs/image-variations/frankenphp): The application server runs PHP itself, with no PHP-FPM, as Unit did. Requires PHP 8.3 or newer.
-Each variation includes detailed configuration examples, performance tuning tips, and deployment guides.
\ No newline at end of file
+Both listen on `8080` and `8443`, as Unit did. Questions about migrating are welcome in the [community forum](https://serversideup.net/php/community){target="_blank"}.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/1.automations.md b/docs/content/docs/3.framework-guides/1.laravel/1.automations.md
index c320ba38e..c9d33002f 100644
--- a/docs/content/docs/3.framework-guides/1.laravel/1.automations.md
+++ b/docs/content/docs/3.framework-guides/1.laravel/1.automations.md
@@ -1,166 +1,138 @@
---
-head.title: 'Laravel Automations Script - Docker PHP - Server Side Up'
-description: 'Automate your deployments and minimize your efforts with Laravel.'
+head.title: 'Laravel Automations - Docker PHP - Server Side Up'
+description: 'Link storage, run migrations, and cache your Laravel app each time the container starts, with one environment variable.'
layout: docs
title: Automations
---
::lead-p
-`serversideup/php` has a "Laravel Automations" script that helps automate common tasks to maintain your Laravel application and improve it's performance. By default, the script is **DISABLED**. We only recommend enabling this script in production environments.
+A new Laravel release isn't ready the moment its container starts. Storage needs its link. The database needs its migrations. Config, routes, views, and events need caching. Set one environment variable, and the container does all of it before it serves a request.
::
-## What the script does
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+ environment:
+ AUTORUN_ENABLED: "true"
+```
-::note
-In order for this script to run,`AUTORUN_ENABLED` must be set to `true`. Once the main part of the script is enabled, you can control the individual tasks by setting the corresponding environment variables to `true` or `false`. See our [variable reference document](/docs/reference/environment-variable-specification) for more details.
-::
+That's the whole setup. There's no entrypoint script to write and no deploy step to remember. The official PHP images leave this work to you.
-| Environment Variable | Default | Description |
-| -------------------- | -------------- | ----------- |
-| `AUTORUN_ENABLED` | `false` | Enables the Laravel Automations script. **ℹ️ Note:** This must be set to `true` for the script to run. |
-| `AUTORUN_DEBUG` | `false` | Enables a special debug mode, specifically for the Laravel Automations script. |
-| `AUTORUN_LARAVEL_CONFIG_CACHE` | `true` | `php artisan config:cache`: Caches the configuration files into a single file. |
-| `AUTORUN_LARAVEL_EVENT_CACHE` | `true` | `php artisan event:cache`: Creates a manifest of all your application's events and listeners. |
-| `AUTORUN_LARAVEL_MIGRATION` | `true` | `php artisan migrate`: Runs migrations. |
-| `AUTORUN_LARAVEL_MIGRATION_DATABASE` | `null` | Run migrations on a specific database. In the rare case you need to use multiple databases, you can provide a comma-delimited list of connection names (e.g., "mysql,pgsql"). If `null`, it will use the default database connection. |
-| `AUTORUN_LARAVEL_MIGRATION_FORCE` | `true` | Force migrations to run in production without confirmation. Set to `false` to disable the `--force` flag. |
-| `AUTORUN_LARAVEL_MIGRATION_ISOLATION` | `false` | Run your migrations with the [`--isolated`](https://laravel.com/docs/12.x/migrations#running-migrations) flag. **ℹ️ Note:** Requires Laravel v9.38.0+. Only works with `default` migration mode. |
-| `AUTORUN_LARAVEL_MIGRATION_MODE` | `default` | Migration mode: `default`, `fresh`, or `refresh`. **⚠️ Warning:** `fresh` and `refresh` drop all tables. |
-| `AUTORUN_LARAVEL_MIGRATION_SEED` | `false` | Automatically seed the database after migrations using the `--seed` flag. |
-| `AUTORUN_LARAVEL_MIGRATION_SKIP_DB_CHECK` | `false` | Skip the database connection check before running migrations. |
-| `AUTORUN_LARAVEL_MIGRATION_TIMEOUT` | `30` | Number of seconds to wait for database connection before timing out during migrations. |
-| `AUTORUN_LARAVEL_OPTIMIZE` | `true` | `php artisan optimize`: Optimizes the application. |
-| `AUTORUN_LARAVEL_ROUTE_CACHE` | `true` | `php artisan route:cache`: Caches the routes. |
-| `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND` | `false` | When `true`, the script will exit gracefully (without error) if Laravel is not detected in `APP_BASE_DIR`, instead of failing the container. Useful when `AUTORUN_ENABLED=true` is set on a shared image where Laravel may not always be present (e.g. before the first `composer install`). |
-| `AUTORUN_LARAVEL_STORAGE_LINK` | `true` | `php artisan storage:link`: Creates a symbolic link from `public/storage` to `storage/app/public`. |
-| `AUTORUN_LARAVEL_VIEW_CACHE` | `true` | `php artisan view:cache`: Caches the views. |
-
-## Database Connection Checks
-Before running migrations, the automation script performs connection checks to ensure your database is ready. Understanding this process helps you configure timeouts and troubleshoot connection issues.
-
-::note
-You can skip database connection checks entirely by setting `AUTORUN_LARAVEL_MIGRATION_SKIP_DB_CHECK=true`. This is useful when you're certain your database is ready or when using alternative connection verification methods.
-::
+## Three steps run on every start
::steps{level="3"}
+### Link storage
+`php artisan storage:link` connects `public/storage` to your app's public files. When the link already exists, the script moves on.
-### Clear configuration cache
-The script runs `php artisan config:clear` to ensure fresh database configuration is loaded before attempting any connections.
-
-### Attempt database connection
-The script tests the database connection using a retry mechanism:
-- Attempts to connect every second
-- Continues for up to `AUTORUN_LARAVEL_MIGRATION_TIMEOUT` seconds (default: 30)
-- Shows connection progress in real-time
-- Logs detailed attempts every 5 seconds when `AUTORUN_DEBUG=true`
-
-### Verify each database connection
-If you've specified multiple databases via `AUTORUN_LARAVEL_MIGRATION_DATABASE`, the script waits for each database connection individually before proceeding with migrations for that database.
-
-### Run migrations
-Once the database connection is confirmed, the script executes the appropriate migration command based on your configuration.
+### Migrate the database
+The script waits until your database accepts connections. It clears the config cache, then runs `php artisan migrate --force`.
+### Cache the app
+`php artisan optimize` caches your config, routes, views, and events. It's the command [Laravel recommends for every production deploy](https://laravel.com/docs/13.x/deployment#optimization){target="_blank"}.
::
-::tip
-Set `AUTORUN_DEBUG=true` to see detailed connection attempt logs, which is helpful for troubleshooting connection issues.
-::
+Then the web server starts, or your own command runs. Every step is on by default. Set a step's variable to `false` to skip it.
-## Laravel Artisan Commands
-Below is an overview of what Laravel Artisan commands do and how they can be configured.
+| Step | Variable | Default |
+|---|---|---|
+| Link storage | `AUTORUN_LARAVEL_STORAGE_LINK` | `true` |
+| Migrate the database | `AUTORUN_LARAVEL_MIGRATION` | `true` |
+| Cache the app | `AUTORUN_LARAVEL_OPTIMIZE` | `true` |
-### php artisan storage:link
-Creates a symbolic link from `public/storage` to `storage/app/public`.
+## Your logs show each step
-:u-button{to="https://laravel.com/docs/12.x/filesystem#the-public-disk" target="_blank" label="Read more about storage links" aria-label="Read more about storage links" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+The container prints a line as each step starts. Laravel's own output appears between them.
-### php artisan migrate
-Before running migrations, we ensure the database is online and ready to accept connections. By default, we will wait 30 seconds before timing out.
+```text [docker compose logs app]
+🔐 Running storage link: "php artisan storage:link"...
+🚀 Clearing Laravel cache before attempting migrations...
+⚡️ Attempting connection to default database...
+✅ Database connection successful
+🚀 Running migrations for default database
+🚀 Running optimize command: "php artisan optimize"...
+```
-#### Migration Modes
-You can control how migrations run using `AUTORUN_LARAVEL_MIGRATION_MODE`:
+The startup banner above these lines shows `Automations: true` whenever the script is on.
-| Mode | Description |
-|------|-------------|
-| `default` | Runs `php artisan migrate` - standard forward migrations |
-| `fresh` | Runs `php artisan migrate:fresh` - drops all tables and re-runs migrations |
-| `refresh` | Runs `php artisan migrate:refresh` - rolls back and re-runs migrations |
+## Turn it on for one service
-::caution
-Using `fresh` or `refresh` modes will **drop all tables** in your database. Only use these in development or testing environments.
-::
-
-#### Force Flag
-By default, migrations run with the `--force` flag to bypass production warnings. You can disable this by setting `AUTORUN_LARAVEL_MIGRATION_FORCE` to `false`.
-
-#### Seeding
-You can automatically seed your database after migrations by setting `AUTORUN_LARAVEL_MIGRATION_SEED` to `true`. This adds the `--seed` flag to your migration command.
+The script runs in every container that starts with `AUTORUN_ENABLED=true`, whatever its command. Set it on your web service and leave it off your workers. Each deployment then migrates once.
-#### Specific Database Migrations
-If you need to specify the exact database connection to use for migrations, you can set `AUTORUN_LARAVEL_MIGRATION_DATABASE` to the name of the database connection you want to use.
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+ environment:
+ AUTORUN_ENABLED: "true"
-| Use case | Description | Value |
-|----------|-------|-------------|
-| Single database | Run migrations on the `mysql` database connection. | `AUTORUN_LARAVEL_MIGRATION_DATABASE=mysql` |
-| Multiple databases | In the rare case you need to use multiple databases, you can provide a comma-delimited list of connection names (e.g., "mysql,pgsql"). | `AUTORUN_LARAVEL_MIGRATION_DATABASE=mysql,pgsql` |
+ queue:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "queue:work"]
+```
-#### Isolated Migrations
-You can enable the [`--isolated`](https://laravel.com/docs/12.x/migrations#running-migrations) flag by setting `AUTORUN_LARAVEL_MIGRATION_ISOLATION` to `true`, which will ensure no other containers are running a migration.
+One-off commands count too. `docker compose run app php artisan tinker` starts a new `app` container, and that container runs the automations first. [Running workers](/docs/framework-guides/laravel/workers) has a full example with Horizon and the scheduler.
-**Special Notes for Isolated Migrations:**
-- Requires Laravel v9.38.0+
-- Only works with `default` migration mode (not compatible with `fresh` or `refresh`)
-- Your application must be using the memcached, redis, dynamodb, database, file, or array cache driver as your application's default cache driver. In addition, all servers must be communicating with the same central cache server.
+## Migrations wait for your database
-:u-button{to="https://laravel.com/docs/12.x/migrations#running-migrations" target="_blank" label="Read more about migrations" aria-label="Read more about migrations" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Your database container may still be starting when your app comes up. The script tries to connect once a second for up to 30 seconds. As soon as the database answers, it migrates.
-### php artisan optimize
-Laravel comes with an artisan command called `optimize`, which will optimize the application by caching the configuration, routes, views, and events all in one command.
+| Variable | Default | What it does |
+|---|---|---|
+| `AUTORUN_LARAVEL_MIGRATION_TIMEOUT` | `30` | Seconds to wait for the database. |
+| `AUTORUN_LARAVEL_MIGRATION_SKIP_DB_CHECK` | `false` | `true` migrates without waiting. |
+| `AUTORUN_LARAVEL_MIGRATION_DATABASE` | `""` | Connections to migrate in turn, such as `mysql,pgsql`. Empty uses your default connection. |
+| `AUTORUN_LARAVEL_MIGRATION_ISOLATION` | `false` | `true` adds `--isolated`. |
+| `AUTORUN_LARAVEL_MIGRATION_SEED` | `false` | `true` adds `--seed`. |
+| `AUTORUN_LARAVEL_MIGRATION_FORCE` | `true` | `false` removes `--force`. |
+| `AUTORUN_LARAVEL_MIGRATION_MODE` | `default` | `fresh` or `refresh` runs `migrate:fresh` or `migrate:refresh`. |
-You can disable any cache features by setting the corresponding environment variable to `false` (for example, `AUTORUN_LARAVEL_CONFIG_CACHE` would disable configuration caching).
+### Run several replicas safely
-If your application is running Laravel v11.38.0 or higher, we will utilize the `optimize --except` parameter to exclude any cache features you have disabled. Otherwise, we will run the individual optimizations separately.
+When you scale a service to several containers, they all start at once. [`--isolated`](https://laravel.com/docs/13.x/migrations#isolating-migration-execution){target="_blank"} takes a lock, and only one container migrates at a time. It needs Laravel 9.38.0 or newer, a cache driver that supports locks, and the `default` migration mode.
-It's possible to disable the `optimize` command by setting `AUTORUN_LARAVEL_OPTIMIZE` to `false`, but the major advantage of using the `optimize` command is other dependencies may hook into this action and run other commands.
+The flag needs the migrations table to exist. On your first deployment, the script leaves it off and adds it from the next one.
-:u-button{to="https://laravel.com/docs/12.x/deployment#optimization" target="_blank" label="Read more about optimizing Laravel" aria-label="Read more about optimizing Laravel" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+::caution
+`fresh` drops every table, and `refresh` rolls back every migration. Use them only in development or testing.
+::
-### php artisan config:cache
-This command caches all configuration files into a single file, which can then be quickly loaded by Laravel. Once the configuration is cache, the `.env` file will no longer be loaded.
+## Choose what gets cached
-:u-button{to="https://laravel.com/docs/12.x/configuration#configuration-caching" target="_blank" label="Read more about configuration caching" aria-label="Read more about configuration caching" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Each cache has its own variable. All four are on by default.
-### php artisan route:cache
-This command caches the routes, dramatically decrease the time it takes to register all of your application's routes. After running this command, your cached routes file will be loaded on every request.
+| Variable | Default | Caches |
+|---|---|---|
+| `AUTORUN_LARAVEL_CONFIG_CACHE` | `true` | [Config](https://laravel.com/docs/13.x/configuration#configuration-caching){target="_blank"} |
+| `AUTORUN_LARAVEL_ROUTE_CACHE` | `true` | [Routes](https://laravel.com/docs/13.x/routing#route-caching){target="_blank"} |
+| `AUTORUN_LARAVEL_VIEW_CACHE` | `true` | [Blade views](https://laravel.com/docs/13.x/views#optimizing-views){target="_blank"} |
+| `AUTORUN_LARAVEL_EVENT_CACHE` | `true` | [Events](https://laravel.com/docs/13.x/events#event-discovery-in-production){target="_blank"} |
-:u-button{to="https://laravel.com/docs/12.x/routing#route-caching" target="_blank" label="Read more about route caching" aria-label="Read more about route caching" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+With every cache on, the script runs `php artisan optimize`. Packages can add their own steps to that command, and they run too.
-### php artisan view:cache
-This command caches all of the views in your application, which can greatly decrease the time it takes to render your views.
+Turn one cache off, and the script runs `php artisan optimize --except=...` without it. Laravel added `--except` in 11.38.0. On older versions, the script runs one `cache` command for each cache that's still on.
-:u-button{to="https://laravel.com/docs/12.x/views#optimizing-views" target="_blank" label="Read more about view caching" aria-label="Read more about view caching" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+Set `AUTORUN_LARAVEL_OPTIMIZE=false` to skip `optimize` and its package steps. The script still runs a `cache` command for each cache variable set to `true`. Set those to `false` too, and nothing gets cached.
-### php artisan event:cache
-This command creates a manifest of all your application's events and listeners, which can greatly speed up the process of registering them with Laravel.
+## A broken release never serves traffic
-:u-button{to="https://laravel.com/docs/12.x/events#event-discovery-in-production" target="_blank" label="Read more about event caching" aria-label="Read more about event caching" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+If a step fails, the script exits with an error. The container stops before the web server starts. A failing migration shows up as a container that won't start, with the error in its logs.
-## Debugging the AUTORUN script
-It's very important to understand the nature of how containerized environments work when debugging the AUTORUN script. In some cases, some users may become frustrated when they push an update but their changes are never deployed.
+The script also stops when it can't find Laravel. It looks for `artisan` and a `vendor` directory in `APP_BASE_DIR`. Run `composer install` when you build the image. If a container can start before Laravel is installed, set `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND=true` to skip the automations instead.
-In most cases, this is due to a bug in their application code that causes a migration or some other process to fail.
+Two variables help you find the cause:
-::note
-If a failure occurs in the Laravel Automations script, it will exit with a non-zero exit code -- preventing the container from starting.
-::
+- `AUTORUN_DEBUG=true` prints the script's settings and each connection attempt.
+- `LOG_OUTPUT_LEVEL=debug` turns on debug output for every startup script.
-If you need the container to start even when Laravel is not yet present (for example, before the first `composer install` in development), set `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND=true`. The script will exit silently with a zero exit code instead of failing. The skip will be logged when `AUTORUN_DEBUG=true` or `LOG_OUTPUT_LEVEL=debug` is set.
+## Keep it for production
-If you are experiencing issues, you can enable the `AUTORUN_DEBUG` environment variable to get more detailed output of what could be going wrong.
+In development, you change routes and config all day. Cached copies hide those changes until the container restarts. Leave `AUTORUN_ENABLED` off on your machine, its default, and run `artisan` yourself.
-If you need even more information, you can set `LOG_OUTPUT_LEVEL` to `debug` to get **A TON** of output of what's exactly happening.
+To turn the automations off anywhere, set `AUTORUN_ENABLED=false`.
-### Preventing issues with the AUTORUN script
-- Ensure you are running the latest version of `serversideup/php`
-- Ensure you have dependencies installed (ie. `composer install`) before calling this script
-- Use automated testing to catch issues before deploying
\ No newline at end of file
+Every `AUTORUN_*` variable is in the [environment variable specification](/docs/reference/environment-variable-specification?q=AUTORUN).
diff --git a/docs/content/docs/3.framework-guides/1.laravel/2.task-scheduler.md b/docs/content/docs/3.framework-guides/1.laravel/2.task-scheduler.md
deleted file mode 100644
index fbf75f4ff..000000000
--- a/docs/content/docs/3.framework-guides/1.laravel/2.task-scheduler.md
+++ /dev/null
@@ -1,126 +0,0 @@
----
-head.title: 'Laravel Task Scheduler with Docker - Docker PHP - Server Side Up'
-description: 'Learn how to configure a Laravel Task Scheduler with Docker.'
-layout: docs
-title: Task Scheduler
----
-
-## Laravel Task Scheduler with Docker
-Run Laravel's task scheduler by using the `schedule:work` command. This runs the scheduler in the foreground and checks for scheduled tasks every minute—perfect for containers.
-
-::tip{to="https://getspin.pro/docs/services/laravel-scheduler" target="_blank"}
-Want to skip the setup? [Spin Pro](https://getspin.pro/docs/services/laravel-scheduler){target="_blank"} handles Laravel schedulers on your VPS with Docker and zero-downtime deployments—all configured for you.
-::
-
-## Why not use cron?
-
-::note
-Unlike traditional server setups, we don't use `cron` in containers. Instead, Laravel's [schedule:work](https://laravel.com/docs/12.x/scheduling#running-the-scheduler-locally){target="_blank"} command keeps the process running in the foreground and checks for tasks every minute. You define the actual schedule (daily, hourly, etc.) in your Laravel application using [scheduled tasks](https://laravel.com/docs/12.x/scheduling#scheduling-artisan-commands){target="_blank"}.
-::
-
-
-## Docker Compose example
-
-This example runs a dedicated scheduler container using the same image as your web service. Use the full path to Artisan (`/var/www/html/artisan`) when defining the container command.
-
-**Key points:**
-- Use the same image for both your web and scheduler services
-- Set `SIGTERM` as the stop signal for graceful shutdown (especially for `fpm-apache` and `fpm-nginx`)
-- Include a health check to monitor scheduler status
-- Define your actual schedule times in Laravel, not in Docker
-
-```yml [compose.yml]
-services:
- php:
- image: my/laravel-app
-
- task:
- image: my/laravel-app
- command: ["php", "/var/www/html/artisan", "schedule:work"]
- stop_signal: SIGTERM
- healthcheck:
- test: ["CMD", "healthcheck-schedule"]
- start_period: 10s
-```
-
-## Defining your schedule in Laravel
-
-Configure your scheduled tasks in Laravel. The file location depends on your Laravel version:
-
-::tabs
-
-:::tabs-item{label="Laravel 11+"}
-
-
-```php [routes/console.php]
-daily() // Run this task once per day
- ->at('02:00') // At 2:00 AM
- ->timezone('America/Chicago'); // In the Chicago timezone
-
-Schedule::command('app:process-latefees')
- ->daily() // Run this task once per day
- ->at('04:00') // At 4:00 AM
- ->timezone('America/Chicago'); // In the Chicago timezone
-```
-
-:::
-
-:::tabs-item{label="Laravel 10 and below" }
-
-```php [app/Console/Kernel.php]
-command('process:invoices')->daily()->at('02:00')->timezone('America/Chicago');
- $schedule->command('process:latefees')->daily()->at('04:00')->timezone('America/Chicago');
- }
-
- /**
- * Register the commands for the application.
- *
- * @return void
- */
- protected function commands()
- {
- $this->load(__DIR__.'/Commands');
-
- require base_path('routes/console.php');
- }
-}
-```
-
-:::
-
-::
-
-::tip
-**Multiple processes in one container:** If you're running `fpm-nginx` or `fpm-apache` and you'd like to have everything in a single container, you can [write your own S6 Overlay service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} to properly manage multiple processes in a single container. Learn more about about this in our [Using S6 Overlay guide](/docs/guide/using-s6-overlay).
-::
\ No newline at end of file
diff --git a/docs/content/docs/3.framework-guides/1.laravel/2.workers.md b/docs/content/docs/3.framework-guides/1.laravel/2.workers.md
new file mode 100644
index 000000000..9c4b69d6b
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/2.workers.md
@@ -0,0 +1,75 @@
+---
+head.title: 'Laravel Workers with Docker - Docker PHP - Server Side Up'
+title: Running workers
+description: 'Run the Laravel scheduler, queue workers, Horizon, Reverb, and Nightwatch in their own containers from the same image as your web service.'
+layout: docs
+---
+
+::lead-p
+Run each Laravel background process in its own container, from the same image as your web service. Each worker container needs three settings: the Artisan `command`, a stop signal, and a health check for that process.
+::
+
+::tip{to="https://getspin.pro/docs/services/laravel-queues" target="_blank"}
+Spin Pro sets up the scheduler, queues, and Horizon on your own server with zero-downtime deployments.
+::
+
+## Workers
+
+| Worker | Command | Health check |
+|--------|---------|--------------|
+| [Task scheduler](/docs/framework-guides/laravel/task-scheduler) | `php artisan schedule:work` | `healthcheck-schedule` |
+| [Queue](/docs/framework-guides/laravel/queue) | `php artisan queue:work` | `healthcheck-queue` |
+| [Horizon](/docs/framework-guides/laravel/horizon) | `php artisan horizon` | `healthcheck-horizon` |
+| [Reverb](/docs/framework-guides/laravel/reverb) | `php artisan reverb:start` | `healthcheck-reverb` |
+| [Nightwatch](/docs/framework-guides/laravel/nightwatch) | `php artisan nightwatch:agent` | `healthcheck-nightwatch` |
+
+## Every worker needs three settings
+
+- **Command.** The images set `WORKDIR` to `/var/www/html` (`APP_BASE_DIR`), so `php artisan` works as written. The container runs only this process: no web server, no PHP-FPM.
+- **`stop_signal: SIGTERM`.** The `fpm`, `fpm-nginx`, and `fpm-apache` images set `STOPSIGNAL SIGQUIT`, the graceful stop signal for PHP-FPM and S6 Overlay. Laravel's workers stop gracefully on `SIGTERM`, and Horizon doesn't handle `SIGQUIT` at all, so set `SIGTERM` on every worker. The `cli` and `frankenphp` images already stop with `SIGTERM`.
+- **A health check.** The image's own health check tests the web server or PHP-FPM, which isn't running in a worker container. Without an override, a worker built from a web variation is marked unhealthy. The `cli` variation has no health check. See [Health checks](/docs/framework-guides/laravel/health-checks) for the scripts.
+
+Docker kills a container 10 seconds after the stop signal. If your jobs run longer, raise `stop_grace_period` on the worker so it can finish the current job.
+
+::caution
+[Laravel automations](/docs/framework-guides/laravel/automations), including migrations, run in every container that starts with `AUTORUN_ENABLED=true`, whatever its command. If your workers share the web service's environment through an `env_file` or YAML anchor, each one migrates the database at startup. Set `AUTORUN_ENABLED=true` on one service only.
+::
+
+## Full Compose example
+
+This example builds on an application image made from `serversideup/php:8.5-fpm-nginx`. Run `queue` or `horizon`, not both: Horizon starts its own queue workers.
+
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+ environment:
+ AUTORUN_ENABLED: "true"
+
+ horizon:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "horizon"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-horizon"]
+ start_period: 10s
+ depends_on:
+ - redis
+
+ scheduler:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "schedule:work"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-schedule"]
+ start_period: 10s
+
+ redis:
+ image: redis:8-alpine
+```
+
+## Run a worker inside the web container
+
+To run a worker in the same container as the web server instead, add it as an S6 Overlay service in `fpm-nginx` or `fpm-apache`. See [Using S6 Overlay](/docs/customizing-the-image/using-s6-overlay). Separate containers are easier to scale and restart on their own.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/3.health-checks.md b/docs/content/docs/3.framework-guides/1.laravel/3.health-checks.md
new file mode 100644
index 000000000..18169713c
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/3.health-checks.md
@@ -0,0 +1,73 @@
+---
+head.title: 'Laravel Health Checks with Docker - Docker PHP - Server Side Up'
+title: Health checks
+description: 'Point the Docker health check at Laravel with one line, and check queue workers, Horizon, the scheduler, and Reverb with built-in scripts.'
+layout: docs
+---
+
+::lead-p
+A container can look healthy while your app is broken. A bad `.env` value or a crashing service provider still leaves the web server answering. Add one line, and Docker checks that Laravel itself boots.
+::
+
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+ environment:
+ HEALTHCHECK_PATH: "/up"
+```
+
+Laravel 11 and newer ship the [`/up` health route](https://laravel.com/docs/13.x/deployment#the-health-route){target="_blank"}. It returns `200` when your app boots and `500` when it throws an exception. With the official PHP images, you'd write this health check yourself.
+
+:youtube-video{id="cuYIB5VrH1Q" title="Docker Health Checks with PHP & Laravel: New Feature for serversideup/php"}
+
+## `/up` checks what your users hit
+
+| What gets checked | Default `/healthcheck` | `HEALTHCHECK_PATH: "/up"` |
+|---|:-:|:-:|
+| The web server responds | ✅ | ✅ |
+| Laravel boots | ❌ | ✅ |
+
+The default check never reaches your app. That's fine for a plain PHP site. For Laravel, `/up` catches the failures that matter.
+
+When `/up` fails, Docker marks the container unhealthy. Docker Swarm replaces it. During a rolling update, Swarm waits for each new container to pass. If one fails, the update pauses instead of rolling a broken release onto every replica.
+
+`HEALTHCHECK_PATH` works in `fpm-nginx`, `fpm-apache`, and `frankenphp`. [Health checks](/docs/deployment-and-production/health-checks) explains the timing and what each variation checks.
+
+## Add your own checks to `/up`
+
+Laravel fires a `DiagnosingHealth` event on each request to `/up`. Listen for it to check your database, cache, or anything else your app needs. Throw an exception, and `/up` returns `500`.
+
+Keep the checks fast. Docker runs the health check every 10 seconds and gives it 3 seconds to answer. Check only what the container can't run without: when a database check fails, Swarm replaces every app container at once.
+
+## Every worker gets a check
+
+Queue, Horizon, and scheduler containers don't run a web server. The image's built-in check fails in them. Every image ships a script for each Laravel worker instead:
+
+| Script | What it runs | Use it for |
+|---|---|---|
+| `healthcheck-horizon` | `php artisan horizon:status` | [Horizon](/docs/framework-guides/laravel/horizon) |
+| `healthcheck-nightwatch` | `php artisan nightwatch:status` | [Nightwatch](/docs/framework-guides/laravel/nightwatch) |
+| `healthcheck-octane` | `php artisan octane:status` | [Octane](/docs/framework-guides/laravel/octane) |
+| `healthcheck-queue` | `pgrep -f "queue:work"` | [Queue](/docs/framework-guides/laravel/queue) |
+| `healthcheck-reverb` | `pgrep -f "reverb:start"` | [Reverb](/docs/framework-guides/laravel/reverb) |
+| `healthcheck-schedule` | `pgrep -f "schedule:work"` | [Task scheduler](/docs/framework-guides/laravel/task-scheduler) |
+
+Set the script as the container's `healthcheck`:
+
+```yaml [compose.yml]
+services:
+ queue:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "queue:work"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-queue"]
+ start_period: 10s
+```
+
+The `pgrep` scripts confirm the process is running. They don't check that it's processing jobs. The Artisan scripts ask Laravel for the worker's status, and they boot your app on every check.
+
+[Running workers](/docs/framework-guides/laravel/workers) has a full Compose file with the web server, Horizon, and the scheduler, each with its health check.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/3.queue.md b/docs/content/docs/3.framework-guides/1.laravel/3.queue.md
deleted file mode 100644
index 7671a63dd..000000000
--- a/docs/content/docs/3.framework-guides/1.laravel/3.queue.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-head.title: 'Laravel Queue with Docker - Docker PHP - Server Side Up'
-description: 'Learn how to configure a Laravel Queue with Docker.'
-layout: docs
-title: Queue
----
-
-## Laravel Queue with Docker
-Run Laravel queue workers by passing the Artisan queue command as the container's command. This allows you to scale queue workers independently from your web server.
-
-## Docker Compose example
-
-::tip{to="https://getspin.pro/docs/services/laravel-scheduler" target="_blank"}
-Want to skip the setup? [Spin Pro](https://getspin.pro/docs/services/laravel-scheduler){target="_blank"} handles Laravel queue workers on your VPS with Docker and zero-downtime deployments—all configured for you.
-::
-
-This example runs a dedicated queue container using the same image as your web service. Use the full path to Artisan (`/var/www/html/artisan`) when defining the container command.
-
-**Key points:**
-- Use the same image for both your web and queue services
-- Set `SIGTERM` as the stop signal for graceful shutdown (especially for `fpm-apache` and `fpm-nginx`)
-- Include a health check to monitor queue worker status
-
-```yml [compose.yml]
-services:
- php:
- image: my/laravel-app
-
- queue:
- image: my/laravel-app
- command: ["php", "/var/www/html/artisan", "queue:work", "--tries=3"]
- stop_signal: SIGTERM
- healthcheck:
- # This is our native healthcheck script for the queue
- test: ["CMD", "healthcheck-queue"]
- start_period: 10s
-```
-
-## Advanced configuration
-
-**Graceful shutdown:** The `SIGTERM` signal ensures queue workers finish processing current jobs before stopping. This is especially important for `fpm-apache` and `fpm-nginx` images.
-
-::tip
-**Multiple processes in one container:** If you're running `fpm-nginx` or `fpm-apache` and you'd like to have everything in a single container, you can [write your own S6 Overlay service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} to properly manage multiple processes in a single container. Learn more about about this in our [Using S6 Overlay guide](/docs/guide/using-s6-overlay).
-::
diff --git a/docs/content/docs/3.framework-guides/1.laravel/4.horizon.md b/docs/content/docs/3.framework-guides/1.laravel/4.horizon.md
index 79da35826..9ce078573 100644
--- a/docs/content/docs/3.framework-guides/1.laravel/4.horizon.md
+++ b/docs/content/docs/3.framework-guides/1.laravel/4.horizon.md
@@ -1,50 +1,45 @@
---
head.title: 'Laravel Horizon with Docker - Docker PHP - Server Side Up'
-description: 'Learn how to configure Laravel Horizon with Docker.'
-layout: docs
title: Horizon
+description: 'Run Laravel Horizon in its own Docker container to process and monitor Redis queues.'
+layout: docs
---
-## Laravel Horizon with Docker
-Run Laravel Horizon by passing the Artisan command as the container's command. Horizon provides a dashboard and monitoring for your Redis queues.
-
-## Docker Compose example
+::lead-p
+Run [Laravel Horizon](https://laravel.com/docs/13.x/horizon){target="_blank"} as its own container with `php artisan horizon`. Horizon starts and supervises your Redis queue workers, so it replaces `queue:work` containers.
+::
-::tip{to="https://getspin.pro/docs/services/laravel-scheduler" target="_blank"}
-Want to skip the setup? [Spin Pro](https://getspin.pro/docs/services/laravel-scheduler){target="_blank"} handles Laravel Horizon on your VPS with Docker and zero-downtime deployments—all configured for you.
+::tip{to="https://getspin.pro/docs/services/laravel-horizon" target="_blank"}
+Spin Pro sets up Laravel Horizon on your own server with zero-downtime deployments.
::
-This example runs a dedicated Horizon container using the same image as your web service. Horizon requires Redis to be running and properly configured in your `.env` file.
+## Compose example
-**Key points:**
-- Use the same image for both your web and Horizon services
-- Ensure Redis is running before starting Horizon
-- Configure Redis connection details in your `.env` file
-- Set `SIGTERM` as the stop signal for graceful shutdown (especially for `fpm-apache` and `fpm-nginx`)
-- Include a health check to monitor Horizon status
+Horizon needs Redis. Configure the connection in your application's `.env`.
-```yml [compose.yml]
+```yaml [compose.yml]
services:
- php:
- image: my/laravel-app
-
- redis:
- image: redis:6
- command: "redis-server --appendonly yes --requirepass redispassword"
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
horizon:
- image: my/laravel-app
- command: ["php", "/var/www/html/artisan", "horizon"]
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "horizon"]
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "healthcheck-horizon"]
start_period: 10s
+ depends_on:
+ - redis
+
+ redis:
+ image: redis:8-alpine
```
-## Advanced configuration
+`stop_signal` and the health check are required on every worker. With `SIGTERM`, Horizon finishes its current jobs before it exits. [Running workers](/docs/framework-guides/laravel/workers) explains why.
-**Graceful shutdown:** The `SIGTERM` signal ensures Horizon finishes processing current jobs before stopping. This is especially important for `fpm-apache` and `fpm-nginx` images.
+## Health check
-::tip
-**Multiple processes in one container:** If you're running `fpm-nginx` or `fpm-apache` and you'd like to have everything in a single container, you can [write your own S6 Overlay service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} to properly manage multiple processes in a single container. Learn more about about this in our [Using S6 Overlay guide](/docs/guide/using-s6-overlay).
-::
\ No newline at end of file
+`healthcheck-horizon` runs `php artisan horizon:status`. It fails when no Horizon process is running or when Horizon is paused.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/4.reverb.md b/docs/content/docs/3.framework-guides/1.laravel/4.reverb.md
deleted file mode 100644
index 3e5ef32fd..000000000
--- a/docs/content/docs/3.framework-guides/1.laravel/4.reverb.md
+++ /dev/null
@@ -1,75 +0,0 @@
----
-head.title: 'Laravel Reverb with Docker - Docker PHP - Server Side Up'
-description: 'Learn how to configure Laravel Reverb with Docker.'
-layout: docs
-title: Reverb
----
-
-## Laravel Reverb with Docker
-Run Laravel Reverb by passing the Artisan command as the container's command. Reverb is Laravel's WebSocket server for real-time communication.
-
-::tip{to="https://getspin.pro/docs/services/laravel-scheduler" target="_blank"}
-Want to skip the setup? [Spin Pro](https://getspin.pro/docs/services/laravel-scheduler){target="_blank"} handles Laravel Reverb on your VPS with Docker and zero-downtime deployments—all configured for you.
-::
-
-::note
-Before using Reverb with Docker, follow the [Laravel Reverb setup instructions](https://laravel.com/docs/12.x/reverb) to install and configure the Reverb package in your Laravel application.
-::
-
-## Docker Compose example
-
-This example runs Reverb as a separate container using the same image as your web service. Reverb requires a reverse proxy (like Traefik) to route WebSocket traffic correctly.
-
-**Key points:**
-- Use the same image for both your web and Reverb services
-- Reverb runs on a different port than your web server (8000 vs 8080)
-- Set `SIGTERM` as the stop signal for graceful shutdown (especially for `fpm-apache` and `fpm-nginx`)
-- Include a health check to monitor Reverb status
-- Configure a reverse proxy to route traffic to the correct container
-```yml [compose.yml]
-services:
- php:
- image: my/laravel-app
- labels:
- - "traefik.enable=true"
- - "traefik.http.routers.laravel.tls=true"
- - "traefik.http.routers.laravel.entrypoints=websecure"
- - "traefik.http.routers.laravel.rule=Host(`https://app.example.com`)"
- - "traefik.http.services.laravel.loadbalancer.server.port=8080"
- - "traefik.http.services.laravel.loadbalancer.server.scheme=http"
-
- reverb:
- image: my/laravel-app
- command: ["php", "/var/www/html/artisan", "reverb:start", "--port=8000"]
- stop_signal: SIGTERM
- healthcheck:
- test: ["CMD", "healthcheck-reverb"]
- start_period: 10s
- labels:
- - "traefik.enable=true"
- - "traefik.http.routers.reverb.tls=true"
- - "traefik.http.routers.reverb.entrypoints=websecure"
- - "traefik.http.routers.reverb.rule=Host(`https://reverb.example.com`)"
- - "traefik.http.services.reverb.loadbalancer.server.port=8000"
- - "traefik.http.services.reverb.loadbalancer.server.scheme=http"
-```
-
-## Environment variable configuration
-
-::warning
-Don't confuse **CLIENT** variables with **SERVER** variables. The `REVERB_SERVER_*` variables configure the Reverb daemon itself, while `REVERB_*` variables tell your frontend clients how to connect.
-::
-
-Configure these environment variables in your Laravel application:
-
-| **Laravel ENV Variable** | **Description** | **Value (matching example above)** |
-| ------------------------- | --------------- | ---------------------------------- |
-| `REVERB_HOST` | The hostname the **CLIENT** will connect to | `reverb.example.com` |
-| `REVERB_PORT` | The port the **CLIENT** will connect to | `443` |
-| `REVERB_SCHEME` | The scheme the **CLIENT** will connect to | `https` |
-
-## Advanced configuration
-
-::tip
-**Multiple processes in one container:** If you're running `fpm-nginx` or `fpm-apache` and you'd like to have everything in a single container, you can [write your own S6 Overlay service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} to properly manage multiple processes in a single container. Learn more about about this in our [Using S6 Overlay guide](/docs/guide/using-s6-overlay).
-::
\ No newline at end of file
diff --git a/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md b/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md
index 2d5e82e54..99ec6b43e 100644
--- a/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md
+++ b/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md
@@ -1,51 +1,49 @@
---
head.title: 'Laravel Nightwatch with Docker - Docker PHP - Server Side Up'
-description: 'Learn how to configure Laravel Nightwatch with Docker.'
-layout: docs
title: Nightwatch
+description: 'Run the Laravel Nightwatch agent in its own Docker container and point your app at it.'
+layout: docs
---
-## Laravel Nightwatch with Docker
-Run Laravel Nightwatch by passing the Artisan command as the container's command. Nightwatch is Laravel's observability platform for monitoring application performance, errors, and queries in real-time.
-
-::note
-Before using Nightwatch with Docker, follow the [Laravel Nightwatch setup instructions](https://nightwatch.laravel.com/docs/start-guide) to install and configure the Nightwatch package in your Laravel application.
+::lead-p
+Run the [Laravel Nightwatch](https://nightwatch.laravel.com){target="_blank"} agent as its own container with `php artisan nightwatch:agent`. Your web and worker containers send their data to the agent over TCP port `2407`.
::
-## Docker Compose example
+Install and configure the Nightwatch package first, following the [Nightwatch start guide](https://nightwatch.laravel.com/docs/start-guide){target="_blank"}.
-This example runs Nightwatch as a separate container using the same image as your web service.
+## Compose example
-**Key points:**
-- Use the same image for both your web and Nightwatch services
-- Set `SIGTERM` as the stop signal for graceful shutdown (especially for `fpm-apache` and `fpm-nginx`)
-- Include a health check to monitor Nightwatch status
-
-```yml [compose.yml]
+```yaml [compose.yml]
services:
- php:
- image: my/laravel-app
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+ environment:
+ NIGHTWATCH_INGEST_URI: "nightwatch:2407"
nightwatch:
- image: my/laravel-app
- command: ["php", "/var/www/html/artisan", "nightwatch:agent"]
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "nightwatch:agent", "--listen-on=0.0.0.0:2407"]
stop_signal: SIGTERM
healthcheck:
test: ["CMD", "healthcheck-nightwatch"]
start_period: 10s
```
-## How the health check works
+`stop_signal` and the health check are required on every worker. [Running workers](/docs/framework-guides/laravel/workers) explains why.
-The `healthcheck-nightwatch` command runs `php artisan nightwatch:status` to verify that the Nightwatch agent is running and connected. Docker uses this to determine container health and can automatically restart unhealthy containers.
+## Connect your app to the agent
-## Advanced configuration
+By default the app sends to `127.0.0.1:2407` and the agent listens there, which only works inside one container. With a separate agent container:
-**Graceful shutdown:** The `SIGTERM` signal ensures the Nightwatch agent finishes processing current events before stopping. This is especially important for `fpm-apache` and `fpm-nginx` images.
+- Set `NIGHTWATCH_INGEST_URI` to the agent's service name and port (`nightwatch:2407`) on every container that runs your app, including queue and scheduler workers.
+- Start the agent with `--listen-on=0.0.0.0:2407` so other containers can reach it.
+- Set the same `NIGHTWATCH_TOKEN` on the app and the agent.
+- Don't publish port `2407`. Only your app containers should reach the agent.
-::tip
-**Multiple processes in one container:** If you're running `fpm-nginx` or `fpm-apache` and you'd like to have everything in a single container, you can [write your own S6 Overlay service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} to properly manage multiple processes in a single container. Learn more about about this in our [Using S6 Overlay guide](/docs/guide/using-s6-overlay).
-::
+Nightwatch's [Docker guide](https://nightwatch.laravel.com/docs/guides/docker){target="_blank"} covers sidecar and Kubernetes setups.
+
+## Health check
-## Learn More
-- [Laravel Nightwatch Documentation](https://nightwatch.laravel.com/docs/start-guide)
+`healthcheck-nightwatch` runs `php artisan nightwatch:status` to check the agent's status. It boots your application on every check.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/6.octane.md b/docs/content/docs/3.framework-guides/1.laravel/6.octane.md
new file mode 100644
index 000000000..e4b206faa
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/6.octane.md
@@ -0,0 +1,334 @@
+---
+head.title: 'Laravel Octane with Docker - Docker PHP - Server Side Up'
+description: 'Run Laravel Octane on FrankenPHP, Swoole, Open Swoole, or RoadRunner with serversideup/php.'
+layout: docs
+title: Octane
+---
+
+::lead-p
+[Laravel Octane](https://laravel.com/docs/13.x/octane){target="_blank"} boots your application once and keeps it in memory between requests. Run it on the `frankenphp` variation, which keeps the image's security headers, trusted proxies, SSL modes, and health check. Use Swoole on the `cli` image only when you need a Swoole-only feature.
+::
+
+## Choose an Octane server
+
+| Server | How to run it | What you get |
+|--------|---------------|--------------|
+| FrankenPHP | Built into the [`frankenphp`](/docs/image-variations/frankenphp) variation | Nothing to install. The image's Caddyfile and every `CADDY_*` variable work. |
+| Swoole or Open Swoole | [`cli`](/docs/image-variations/cli) plus the extension | Four [Octane features FrankenPHP lacks](#swoole-only-octane-features). You add the HTTP hardening yourself. |
+| RoadRunner | [`cli`](/docs/image-variations/cli) plus the `rr` binary | A Go server with its own `.rr.yaml`. You add the HTTP hardening yourself. |
+
+::warning
+Octane replaces PHP-FPM, so it can't run in the `fpm`, `fpm-nginx`, or `fpm-apache` variations.
+::
+
+## Run Octane on FrankenPHP
+
+These steps assume a Laravel application in the current directory. To create a new one with Docker, see [Spin](https://serversideup.net/open-source/spin/docs){target="_blank"}.
+
+::steps{level="3"}
+
+### Point the service at Octane
+
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-frankenphp
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html/
+ command: ["php", "artisan", "octane:start", "--server=frankenphp", "--port=8080", "--caddyfile=/etc/frankenphp/Caddyfile"]
+```
+
+- `--server=frankenphp` starts FrankenPHP in worker mode through Octane.
+- `--port=8080` must match `CADDY_HTTP_PORT` (default `8080`). The exec form (the JSON array) [doesn't expand variables](https://docs.docker.com/reference/dockerfile/#exec-form){target="_blank"}, so write the number.
+- `--caddyfile=/etc/frankenphp/Caddyfile` uses the image's Caddyfile. Without it, Octane uses [its own minimal Caddyfile](https://github.com/laravel/octane/blob/2.x/src/Commands/stubs/Caddyfile){target="_blank"}, and you lose trusted proxies, security headers, asset caching, SSL modes, the health check endpoint, and most `CADDY_*` variables.
+
+Octane sets `LARAVEL_OCTANE=1` when it starts FrankenPHP. The Caddyfile then loads `frankenphp-worker.php` as a worker and routes every request to it instead of `index.php`. It also turns on the Caddy admin API on `localhost`, which `octane:status`, `octane:reload`, and `octane:stop` need. Everything else matches classic mode.
+
+### Install Octane
+
+```bash [Terminal]
+docker compose run --rm php composer require laravel/octane
+docker compose run --rm php php artisan octane:install --server=frankenphp
+```
+
+The install command publishes `config/octane.php`, sets `OCTANE_SERVER=frankenphp` in `.env`, and creates `public/frankenphp-worker.php`, the script FrankenPHP keeps in memory.
+
+It also adds `frankenphp-worker.php` to `.gitignore`. Remove that line and commit the file. Every image you build then contains it. Otherwise `octane:start` has to recreate it at startup, which only works if `public` is writable.
+
+### Start the application
+
+```bash [Terminal]
+docker compose up
+```
+
+Your application answers at `http://localhost`, served by Octane.
+
+::
+
+### Logging
+
+Octane relays only FrankenPHP's `stderr` and only parses JSON. The defaults satisfy both: `CADDY_LOG_OUTPUT` is `stderr`, and with `CADDY_LOG_FORMAT=auto`, Caddy [writes JSON when `stderr` is not a terminal](https://caddyserver.com/docs/caddyfile/directives/log#format){target="_blank"}, which is always true under Octane.
+
+::warning
+Leave `CADDY_LOG_OUTPUT` and `CADDY_LOG_FORMAT` at their defaults. With `CADDY_LOG_OUTPUT=stdout`, Octane discards your logs. With `CADDY_LOG_FORMAT=console`, Octane can't parse the lines and wraps each one, warnings and errors included, in an `INFO` message.
+::
+
+Octane also sets the log level. It sets `CADDY_SERVER_LOG_LEVEL` to `INFO` when `APP_ENV` is `local` and to `WARN` everywhere else, which overrides `LOG_OUTPUT_LEVEL`. Caddy [logs requests at `INFO`](https://caddyserver.com/docs/caddyfile/directives/log#level){target="_blank"}. What you see depends on the environment:
+
+- In `local`, Octane prints one line per request with the method, path, status, and duration.
+- Everywhere else, it prints only Caddy's warnings and errors.
+- With `--log-level=INFO` on `octane:start`, it passes Caddy's JSON through untouched: one object per request, ready for a log collector.
+
+### PHP settings still apply
+
+FrankenPHP reads the same `php.ini` files from `/usr/local/etc/php/conf.d/` in every mode. All `PHP_*` variables and any `.ini` files you mount there work as they do in classic mode. Set `PHP_OPCACHE_ENABLE=1` in production; see [Production performance tuning](/docs/deployment-and-production/production-performance-tuning).
+
+### Octane options
+
+Octane passes its `octane:start` options to FrankenPHP as environment variables. With the image's Caddyfile:
+
+| Option | Behavior |
+|--------|----------|
+| `--port` | Match `CADDY_HTTP_PORT`, which is the port Caddy listens on. Octane also derives the admin port from it: `--port=8080` puts the admin API on `2099`. |
+| `--host` | Not used. The container listens on all interfaces. |
+| `--workers` | Sets the number of PHP threads for the worker. Defaults to [twice the number of CPUs](https://frankenphp.dev/docs/config/#caddyfile-config){target="_blank"}. |
+| `--max-requests` | Restarts a worker after this many requests (default `500`) to [limit memory leaks](https://laravel.com/docs/13.x/octane#specifying-the-max-request-count){target="_blank"}. |
+| `--watch` | Uses [FrankenPHP's file watcher](https://frankenphp.dev/docs/config/#watching-for-file-changes){target="_blank"}, built into the image, with the `watch` paths from `config/octane.php`. No Node or Chokidar needed. `--poll` has no effect. |
+| `--admin-port` | Used by `octane:status`, `octane:reload`, and `octane:stop`. `CADDY_ADMIN` is ignored. The admin API listens on `localhost` only. |
+| `--log-level` | Sets `CADDY_SERVER_LOG_LEVEL`. See [Logging](#logging). |
+| `--https`, `--http-redirect` | Not used. Configure HTTPS with `SSL_MODE` and `CADDY_AUTO_HTTPS`, as in [classic mode](/docs/image-variations/frankenphp#automatic-https). |
+
+::caution
+Keep the admin API on `localhost`, even though the hidden `octane:frankenphp` command accepts `--admin-host`. The [Caddy admin API has no authentication](https://caddyserver.com/docs/api){target="_blank"} and can reconfigure the web server. The image lists port `2019` in `EXPOSE`, so with `--admin-host=0.0.0.0 --admin-port=2019`, `docker run -P` publishes it to the host.
+::
+
+The `max_execution_time` setting in `config/octane.php` works as [Laravel documents](https://laravel.com/docs/13.x/octane#specifying-the-max-execution-time){target="_blank"}, because Octane passes it to the worker script.
+
+#### Variables Octane replaces
+
+Octane sets these for its own Caddyfile. Any value you set is replaced while Octane runs:
+
+- `CADDY_SERVER_EXTRA_DIRECTIVES` carries the `mercure` array from `config/octane.php`. Put your own directives in a `.caddyfile` mounted into `/etc/frankenphp/caddyfile-server.d/`.
+- `CADDY_GLOBAL_OPTIONS` is ignored under Octane, because Octane sets it to `auto_https disable_redirects`, which would override `CADDY_AUTO_HTTPS`. Put global options in `/etc/frankenphp/caddyfile-global.d/`.
+- `CADDY_SERVER_LOGGER` isn't read, because Caddy already writes JSON under Octane.
+
+See [Adding your own Caddyfile rules](/docs/image-variations/frankenphp#adding-your-own-caddyfile-rules) for the folder layout.
+
+::tip
+Octane 2.14 and newer let you override any variable it sets through `caddy.env` in `config/octane.php`. This keeps request logs on in every environment without `--log-level`:
+
+```php [config/octane.php]
+'caddy' => [
+ 'env' => [
+ 'CADDY_SERVER_LOG_LEVEL' => 'INFO',
+ ],
+],
+```
+
+See the [Octane 2.14.0 release notes](https://github.com/laravel/octane/releases/tag/v2.14.0){target="_blank"}.
+::
+
+The Caddyfile loads `frankenphp-worker.php` from `CADDY_SERVER_ROOT`, not from the `APP_PUBLIC_PATH` that Octane sets. If you changed `APP_BASE_DIR`, set `CADDY_SERVER_ROOT` to match, as in classic mode.
+
+### Mercure
+
+Turn on FrankenPHP's Mercure hub with the `MERCURE_*` variables, as in [classic mode](/docs/image-variations/frankenphp#mercure). Leave the `mercure` array out of `config/octane.php`.
+
+[FrankenPHP's Laravel docs](https://frankenphp.dev/docs/laravel/#mercure-support){target="_blank"} use that array instead. Octane passes it in `CADDY_SERVER_EXTRA_DIRECTIVES`, which lands in every site block. With `SSL_MODE=mixed` or `full` there is more than one site block, which means more than one unnamed hub. Mercure 1.0 refuses to start in that case. The `MERCURE_*` variables name the hub, and every site block shares it.
+
+## Run Octane on Swoole or RoadRunner
+
+Run these servers from the [`cli`](/docs/image-variations/cli) image, which has no web server, so the HTTP protections are yours to add.
+
+### Swoole-only Octane features
+
+Swoole and Open Swoole support four Octane features that FrankenPHP and RoadRunner don't:
+
+- [Concurrent tasks](https://laravel.com/docs/13.x/octane#concurrent-tasks){target="_blank"} with `Octane::concurrently()`, run by Swoole's task workers. Set how many with `--task-workers` on `octane:start`.
+- [Ticks and intervals](https://laravel.com/docs/13.x/octane#ticks-and-intervals){target="_blank"} that run a callback every N seconds.
+- [The Octane cache driver](https://laravel.com/docs/13.x/octane#the-octane-cache){target="_blank"}, stored in shared memory and flushed when the server restarts.
+- [Tables](https://laravel.com/docs/13.x/octane#tables){target="_blank"} that share structured data between workers, also flushed on restart.
+
+### What the cli image does and doesn't give you
+
+The `cli` image has what Octane needs on the PHP side: the `pcntl` extension, Composer, `install-php-extensions`, the startup scripts (including [Laravel automations](/docs/framework-guides/laravel/automations)), the `PHP_*` variables, and the unprivileged `www-data` user.
+
+It has no web server. In the `frankenphp` variation, the image's Caddyfile handles the HTTP layer. Swoole's HTTP server is the PHP process itself, with no place for caching rules or path blocking. RoadRunner has a config file that nothing in the image fills in. These protections move to you:
+
+| Protection | `frankenphp` | `cli` + Swoole or RoadRunner | Where to add it |
+|------------|:------------:|:----------------------------:|-----------------|
+| Blocking `.env`, `.git/`, `*.bak`, `*.log`, `*.sql`, `*.ini`, and dotfiles | ✅ | ❌ | Reverse proxy |
+| `Cache-Control: immutable` on static assets | ✅ | ❌ | Reverse proxy |
+| `Access-Control-Allow-Origin` on fonts | ✅ | ❌ | Reverse proxy |
+| `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` | ✅ | ❌ | Laravel middleware or reverse proxy |
+| Removing the `Server` and `X-Powered-By` headers | ✅ | ❌ | Reverse proxy |
+| Trusted proxies through `TRUSTED_PROXY` | ✅ | ❌ | Laravel's `TrustProxies` middleware |
+| `SSL_MODE`, automatic HTTPS, and ACME certificates | ✅ | ❌ | Reverse proxy |
+| The `/healthcheck` endpoint and `HEALTHCHECK_PATH` | ✅ | ❌ | `healthcheck-octane` or a Laravel route |
+| Access logs with the `authorization` parameter redacted | ✅ | ❌ | Reverse proxy |
+
+::caution
+Both servers expose files from your public directory that the Caddyfile would block with a `403`:
+
+- **Swoole** serves every non-PHP file in `public` as it is, including a stray `.env`, `.git` directory, database dump, or `*.bak`. It refuses `.php` files, so an uploaded PHP file returns `404` instead of running.
+- **RoadRunner**, as Octane starts it, serves everything in `public`, including `.php` files as plain-text source. See [Lock down RoadRunner's static files](#lock-down-roadrunners-static-files).
+
+Put a reverse proxy in front of Octane in production.
+::
+
+### Run Octane on Swoole
+
+Swoole is a PHP extension. Install it in your own `Dockerfile`. For Open Swoole, install `openswoole` instead; everything else is the same.
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-cli
+
+USER root
+RUN install-php-extensions swoole
+USER www-data
+```
+
+Install Octane and point it at Swoole:
+
+```bash [Terminal]
+docker compose run --rm php composer require laravel/octane
+docker compose run --rm php php artisan octane:install --server=swoole
+```
+
+Start it from your compose file:
+
+```yaml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ ports:
+ - "80:8000"
+ volumes:
+ - .:/var/www/html/
+ command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
+ healthcheck:
+ test: ["CMD", "healthcheck-octane"]
+ start_period: 10s
+```
+
+Octane binds to `127.0.0.1` by default, which Docker's published port can't reach. Always pass `--host=0.0.0.0` in a container. Port `8000` is Octane's default. It is above `1024`, which lets `www-data` bind it.
+
+### Run Octane on RoadRunner
+
+RoadRunner needs the `rr` binary and the `sockets` extension, which the `cli` image doesn't include:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-cli
+
+USER root
+RUN install-php-extensions sockets
+
+# TARGETARCH is set by BuildKit, so this works on amd64 and arm64
+ARG TARGETARCH
+ARG ROADRUNNER_VERSION=2025.1.15
+RUN docker-php-serversideup-download \
+ "https://github.com/roadrunner-server/roadrunner/releases/download/v${ROADRUNNER_VERSION}/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}.tar.gz" \
+ /tmp/roadrunner.tar.gz && \
+ tar -xzf /tmp/roadrunner.tar.gz -C /tmp && \
+ mv "/tmp/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}/rr" /usr/local/bin/rr && \
+ chmod +x /usr/local/bin/rr && \
+ rm -rf /tmp/roadrunner*
+
+USER www-data
+```
+
+::note
+Laravel's docs download the binary into your project directory with `spiral/roadrunner-cli` and `./vendor/bin/rr get-binary`. On Laravel 13 that fails: `spiral/roadrunner-cli` requires `symfony/console` 7 or older, Laravel 13 ships version 8, and `php artisan octane:install --server=roadrunner` calls `./vendor/bin/rr`. Installing the binary in the image avoids both, pins the version, and matches each image's architecture. Skip `octane:install` and set `OCTANE_SERVER=roadrunner` in `.env`.
+::
+
+Add the PHP side, then start the server:
+
+```bash [Terminal]
+docker compose run --rm php composer require laravel/octane spiral/roadrunner-http
+```
+
+```yaml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ ports:
+ - "80:8000"
+ volumes:
+ - .:/var/www/html/
+ command: ["php", "artisan", "octane:start", "--server=roadrunner", "--host=0.0.0.0", "--port=8000", "--rpc-port=6001", "--rr-config=/var/www/html/.rr.yaml"]
+ healthcheck:
+ test: ["CMD", "healthcheck-octane"]
+ start_period: 10s
+```
+
+#### Lock down RoadRunner's static files
+
+Create `.rr.yaml` in your project root. `--rr-config` in the command above points RoadRunner at it:
+
+```yaml [.rr.yaml]
+version: "3"
+
+http:
+ static:
+ forbid: [".php", ".env", ".bak", ".log", ".sql", ".ini", ".git"]
+```
+
+With this file, `/.env`, `/uploaded.php`, and `/config.bak` return `404` and your real assets still load. It covers less than a reverse proxy does.
+
+### Put NGINX in front for static files and TLS
+
+Laravel recommends running Octane behind NGINX or Apache in production, to serve static assets and terminate TLS. Add the protections from the table above there too.
+
+```yaml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ volumes:
+ - .:/var/www/html/
+ command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
+
+ web:
+ image: nginx:1.29-alpine
+ ports:
+ - "80:80"
+ volumes:
+ - .:/var/www/html/:ro
+ - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+ depends_on:
+ - php
+```
+
+Start from [Laravel's NGINX configuration for Octane](https://laravel.com/docs/13.x/octane#serving-your-application-via-nginx){target="_blank"} and set `proxy_pass` to `http://php:8000` instead of `http://127.0.0.1:8000`, because Octane runs in its own container.
+
+::warning
+Use a plain `nginx` image, not `fpm-nginx`. The NGINX in `fpm-nginx` passes requests to PHP-FPM over FastCGI and isn't set up to proxy to Octane.
+::
+
+### OPcache follows the CLI setting
+
+Octane on Swoole or RoadRunner is a CLI process. OPcache follows `PHP_OPCACHE_ENABLE_CLI` there, not `PHP_OPCACHE_ENABLE`. The `cli` image sets `PHP_OPCACHE_ENABLE_CLI=1` by default. See [Production performance tuning](/docs/deployment-and-production/production-performance-tuning) for the other settings.
+
+### File watching needs Node and Chokidar
+
+Outside FrankenPHP, Octane's `--watch` flag needs [Node](https://nodejs.org){target="_blank"} and [Chokidar](https://github.com/paulmillr/chokidar){target="_blank"}. Neither ships in the `cli` image. Add Node to your development image and install Chokidar in your project:
+
+```bash [Terminal]
+npm install --save-dev chokidar
+```
+
+## Health checks
+
+On FrankenPHP, the image's health check keeps working, because the Caddyfile still answers `/healthcheck`. Swoole and RoadRunner have no `/healthcheck` endpoint. On any server, `healthcheck-octane` checks Octane itself, as the Swoole and RoadRunner examples do. See [Laravel health checks](/docs/framework-guides/laravel/health-checks).
+
+## Code that stays in memory
+
+Code that worked under PHP-FPM can misbehave once it stays in memory:
+
+- Injecting the request or container into long-lived objects can leak data between requests. See [dependency injection and Octane](https://laravel.com/docs/13.x/octane#dependency-injection-and-octane){target="_blank"}.
+- Static arrays that grow on every request leak memory. See [managing memory leaks](https://laravel.com/docs/13.x/octane#managing-memory-leaks){target="_blank"}.
+- New code needs [`octane:reload`](https://laravel.com/docs/13.x/octane#reloading-the-workers){target="_blank"} or a new container. In production, replace the container. `octane:reload` also works in place, because the Caddyfile enables the admin API.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/7.queue.md b/docs/content/docs/3.framework-guides/1.laravel/7.queue.md
new file mode 100644
index 000000000..b61bef06d
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/7.queue.md
@@ -0,0 +1,46 @@
+---
+head.title: 'Laravel Queue with Docker - Docker PHP - Server Side Up'
+title: Queue
+description: 'Run Laravel queue workers in their own Docker containers and scale them separately from your web server.'
+layout: docs
+---
+
+::lead-p
+Run Laravel queue workers as their own containers with `php artisan queue:work`. Each worker uses the same image as your web service, so you scale queues by adding containers without touching the web server.
+::
+
+::tip{to="https://getspin.pro/docs/services/laravel-queues" target="_blank"}
+Spin Pro sets up Laravel queue workers on your own server with zero-downtime deployments.
+::
+
+## Compose example
+
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+
+ queue:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "queue:work", "--tries=3"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-queue"]
+ start_period: 10s
+```
+
+`stop_signal` and the health check are required on every worker. [Running workers](/docs/framework-guides/laravel/workers) explains why.
+
+## Worker options
+
+Pass [`queue:work` options](https://laravel.com/docs/13.x/queues#running-the-queue-worker){target="_blank"} in the command, such as `--tries`, `--timeout`, or `--queue=high,default`. To run more workers, raise the replica count (`docker compose up --scale queue=3`) or add another service with a different `--queue`.
+
+If a job can run longer than 10 seconds, set `stop_grace_period` above your longest job so a deploy doesn't kill it halfway.
+
+## Use `queue:work`, not `queue:listen`
+
+`healthcheck-queue` looks for a process named `queue:work`. `queue:listen` keeps restarting short-lived `queue:work` processes, so the health check fails intermittently. `queue:listen` also boots the framework for every job, so it's slower in production.
+
+Using Redis queues with a dashboard? Run [Horizon](/docs/framework-guides/laravel/horizon) instead of `queue:work`, not both.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/8.reverb.md b/docs/content/docs/3.framework-guides/1.laravel/8.reverb.md
new file mode 100644
index 000000000..93bfd3869
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/8.reverb.md
@@ -0,0 +1,60 @@
+---
+head.title: 'Laravel Reverb with Docker - Docker PHP - Server Side Up'
+description: 'Run the Laravel Reverb WebSocket server in its own container behind a reverse proxy.'
+layout: docs
+title: Reverb
+---
+
+::lead-p
+Run [Laravel Reverb](https://laravel.com/docs/13.x/reverb){target="_blank"}, Laravel's WebSocket server, as its own container from the same image as your web service. Unlike the other workers, Reverb listens on a port, so your reverse proxy needs a route to it.
+::
+
+::tip{to="https://getspin.pro/docs/services/laravel-reverb" target="_blank"}
+Spin Pro sets up Laravel Reverb on your own server with zero-downtime deployments.
+::
+
+Install and configure Reverb in your application first, following [Laravel's Reverb docs](https://laravel.com/docs/13.x/reverb#installation){target="_blank"}. The stop signal and health check follow the same rules as every other worker. See [Running workers](/docs/framework-guides/laravel/workers).
+
+## Compose example with Traefik
+
+This example routes `app.example.com` to the web service on port `8080` and `reverb.example.com` to Reverb on port `8000`.
+
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.laravel.tls=true"
+ - "traefik.http.routers.laravel.entrypoints=websecure"
+ - "traefik.http.routers.laravel.rule=Host(`app.example.com`)"
+ - "traefik.http.services.laravel.loadbalancer.server.port=8080"
+ - "traefik.http.services.laravel.loadbalancer.server.scheme=http"
+
+ reverb:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "reverb:start", "--port=8000"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-reverb"]
+ start_period: 10s
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.reverb.tls=true"
+ - "traefik.http.routers.reverb.entrypoints=websecure"
+ - "traefik.http.routers.reverb.rule=Host(`reverb.example.com`)"
+ - "traefik.http.services.reverb.loadbalancer.server.port=8000"
+ - "traefik.http.services.reverb.loadbalancer.server.scheme=http"
+```
+
+## Client variables point at the proxy, not the container
+
+Reverb reads two sets of variables. The `REVERB_SERVER_*` variables configure the Reverb process inside the container. The `REVERB_HOST`, `REVERB_PORT`, and `REVERB_SCHEME` variables tell clients where to connect: browsers, and your application when it broadcasts an event. They must match the public address that Traefik serves.
+
+| Variable | Value for the example above |
+|----------|-----------------------------|
+| `REVERB_HOST` | `reverb.example.com` |
+| `REVERB_PORT` | `443` |
+| `REVERB_SCHEME` | `https` |
+
+Don't set `REVERB_PORT` to `8000`. Clients would then connect to the container port, which Traefik doesn't expose.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/9.task-scheduler.md b/docs/content/docs/3.framework-guides/1.laravel/9.task-scheduler.md
new file mode 100644
index 000000000..cb99c4d6a
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/9.task-scheduler.md
@@ -0,0 +1,54 @@
+---
+head.title: 'Laravel Task Scheduler with Docker - Docker PHP - Server Side Up'
+title: Task scheduler
+description: 'Run the Laravel task scheduler in its own Docker container with schedule:work, no cron required.'
+layout: docs
+---
+
+::lead-p
+Run Laravel's task scheduler as its own container with `php artisan schedule:work`. It stays in the foreground and runs `schedule:run` every minute, so the container needs no `cron`.
+::
+
+::tip{to="https://getspin.pro/docs/services/laravel-scheduler" target="_blank"}
+Spin Pro sets up the Laravel scheduler on your own server with zero-downtime deployments.
+::
+
+## Compose example
+
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ ports:
+ - "80:8080"
+
+ scheduler:
+ image: my-org/my-app:1.0.0
+ command: ["php", "artisan", "schedule:work"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-schedule"]
+ start_period: 10s
+```
+
+`stop_signal` and the health check are required on every worker. [Running workers](/docs/framework-guides/laravel/workers) explains why.
+
+## Define the schedule in your app
+
+The container only runs the scheduler. Define the tasks in your application, as [Laravel's scheduling docs](https://laravel.com/docs/13.x/scheduling#scheduling-artisan-commands){target="_blank"} describe:
+
+```php [routes/console.php]
+dailyAt('02:00')
+ ->timezone('America/Chicago');
+```
+
+## Run one scheduler container
+
+Two scheduler containers run every task twice. Keep the scheduler at one replica, or add [`onOneServer()`](https://laravel.com/docs/13.x/scheduling#running-tasks-on-one-server){target="_blank"} to each task.
+
+`healthcheck-schedule` checks that a `schedule:work` process is running. It doesn't check that tasks succeed.
diff --git a/docs/content/docs/3.framework-guides/1.laravel/octane.md b/docs/content/docs/3.framework-guides/1.laravel/octane.md
deleted file mode 100644
index 24f113b62..000000000
--- a/docs/content/docs/3.framework-guides/1.laravel/octane.md
+++ /dev/null
@@ -1,459 +0,0 @@
----
-head.title: 'Laravel Octane with Docker - Docker PHP - Server Side Up'
-description: 'Learn how to configure Laravel Octane with Docker.'
-layout: docs
-title: Octane
----
-
-::lead-p
-Laravel Octane supercharges your application's performance by keeping it loaded in memory and serving requests at incredible speeds. The FrankenPHP variation of our images provides native Octane support with worker mode built in, and you can run Octane's other servers from our CLI image.
-::
-
-## What is Laravel Octane?
-
-Laravel Octane boots your Laravel application once and keeps it in memory, then processes thousands of requests without reloading. This dramatically improves performance compared to traditional PHP execution.
-
-**Traditional PHP:**
-Bootstrap → Handle Request → Teardown → Repeat for every request
-
-**With Octane:**
-Bootstrap once → Handle unlimited requests
-
-## Choosing an Octane Server
-
-Octane runs on four different application servers. One of them is built into our images, and the rest need some assembly:
-
-| Server | How to run it with our images | What you get |
-|--------|-------------------------------|--------------|
-| **FrankenPHP** | Built into the [`frankenphp`](/docs/image-variations/frankenphp) variation | Nothing to install. Worker mode, our production Caddyfile, and every `CADDY_*` variable work out of the box. |
-| **Swoole** | [`cli`](/docs/image-variations/cli) + `install-php-extensions swoole` | Octane's concurrent tasks, ticks, cache driver, and tables. You supply the HTTP hardening yourself. |
-| **Open Swoole** | [`cli`](/docs/image-variations/cli) + `install-php-extensions openswoole` | A fork of Swoole with the same Octane feature set. |
-| **RoadRunner** | [`cli`](/docs/image-variations/cli) + `spiral/roadrunner-cli` | A Go binary with its own `.rr.yaml` configuration. |
-
-We recommend FrankenPHP for almost everyone. It is the only server where the image does the hard part for you: the Caddyfile we ship handles asset caching, security headers, trusted proxies, SSL, the health check endpoint, and log redaction, and it switches itself into worker mode when Octane starts it. Everywhere else, those concerns become your job.
-
-::warning
-Octane **replaces** PHP-FPM, so you cannot run it inside the `fpm`, `fpm-nginx`, or `fpm-apache` variations. The NGINX and Apache in those images are PHP-FPM front ends, not reverse proxies. If you want Octane without FrankenPHP, start from the `cli` image instead. See [Running Octane Without FrankenPHP](#running-octane-without-frankenphp).
-::
-
-::tip
-There is one genuinely good reason to choose Swoole over FrankenPHP: four Octane features only exist there.
-
-- [Concurrent tasks](https://laravel.com/docs/13.x/octane#concurrent-tasks){target="_blank"} via `Octane::concurrently()`
-- [Ticks and intervals](https://laravel.com/docs/13.x/octane#ticks-and-intervals){target="_blank"} for running a callback every N seconds
-- [The Octane cache driver](https://laravel.com/docs/13.x/octane#the-octane-cache){target="_blank"}, backed by Swoole tables
-- [Tables](https://laravel.com/docs/13.x/octane#tables){target="_blank"} for sharing data between workers
-
-If you need any of these, Swoole is the right call. If you don't, FrankenPHP will serve you better.
-::
-
-## Quick Start with FrankenPHP
-Let's use this example project to get started.
-
-::warning{to="https://serversideup.net/open-source/spin/docs" target="_blank"}
-This example assumes you already have a Laravel application installed. If you need help installing a new Laravel project with Docker, check out [Spin](https://serversideup.net/open-source/spin/docs){target="_blank"} for a simple way to get started.
-::
-
-### Classic Mode
-By default, FrankenPHP runs in classic mode. Your compose file might look something like this:
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-frankenphp
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html/
-```
-
-We'll expand upon this classic mode file and modify it to run Laravel Octane (which uses FrankenPHP's worker mode).
-
-### Install Laravel Octane
-
-First, install Octane in your Laravel application and tell it to use FrankenPHP. These are the same steps from the [official Laravel documentation](https://laravel.com/docs/13.x/octane#installation){target="_blank"}:
-
-```bash [Terminal]
-docker compose run php composer require laravel/octane
-docker compose run php php artisan octane:install --server=frankenphp
-```
-
-The install command publishes `config/octane.php`, sets `OCTANE_SERVER=frankenphp` in your `.env` file, and creates a PHP file in your `/public` directory. This is the script FrankenPHP keeps running in memory, and Octane needs it to work.
-
-```php [public/frankenphp-worker.php]
- [
- 'env' => [
- 'CADDY_SERVER_LOG_LEVEL' => 'INFO',
- ],
-],
-```
-
-See the [Octane 2.14.0 release notes](https://github.com/laravel/octane/releases/tag/v2.14.0){target="_blank"} for details.
-::
-
-Octane sets `APP_PUBLIC_PATH` to your application's public directory, but our Caddyfile looks for `frankenphp-worker.php` in `CADDY_SERVER_ROOT` instead. This keeps the worker script and the document root in the same place. If you changed `APP_BASE_DIR`, set `CADDY_SERVER_ROOT` to match, just like classic mode.
-
-## Mercure
-Turn on FrankenPHP's Mercure hub with the `MERCURE_*` environment variables, the same as classic mode, and leave the `mercure` array out of `config/octane.php`. [Read how to set up Mercure →](/docs/image-variations/frankenphp#mercure)
-
-[FrankenPHP's Laravel docs](https://frankenphp.dev/docs/laravel/#mercure-support){target="_blank"} configure the hub through that array instead. Octane passes it to our Caddyfile in `CADDY_SERVER_EXTRA_DIRECTIVES`, which lands in every site block. With `SSL_MODE=mixed` or `full` your app is served from more than one site block, so the array creates more than one hub, and Mercure 1.0 refuses to start with more than one unnamed hub. The environment variables name the hub, so every site shares it.
-
-## Running Octane Without FrankenPHP
-
-Swoole, Open Swoole, and RoadRunner all run from the [`cli`](/docs/image-variations/cli) image. It already ships what Octane needs on the PHP side: the `pcntl` extension, Composer, `install-php-extensions`, our entrypoint scripts (including the [Laravel automations](/docs/framework-guides/laravel/automations)), and an unprivileged `www-data` user.
-
-What it does not ship is a web server, and that is the whole tradeoff. Read [What You Give Up](#what-you-give-up) first.
-
-### What You Give Up
-
-Our `frankenphp` variation hardens your application in the Caddyfile that ships inside the image. Swoole has no equivalent: its HTTP server is the PHP process itself, configured from your application's `config/octane.php`, with no place to express caching rules, header policies, or path matching. RoadRunner has a configuration file, but nothing in our images populates it for you.
-
-Here is what moves from the image back onto your plate:
-
-| Protection | `frankenphp` variation | `cli` + Swoole or RoadRunner | Where it has to live instead |
-|------------|:---------------------:|:---------------------------:|------------------------------|
-| Blocking `.env`, `.git/`, `*.bak`, `*.log`, `*.sql`, `*.ini`, and dotfiles | ✅ | ❌ | Reverse proxy |
-| `Cache-Control: immutable` on static assets | ✅ | ❌ | Reverse proxy |
-| `Access-Control-Allow-Origin` on fonts | ✅ | ❌ | Reverse proxy |
-| `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` | ✅ | ❌ | Laravel middleware or reverse proxy |
-| Removing the `Server` and `X-Powered-By` headers | ✅ | ❌ | Reverse proxy |
-| Trusted proxies via `TRUSTED_PROXY` | ✅ | ❌ | Laravel's `TrustProxies` middleware |
-| `SSL_MODE`, automatic HTTPS, and ACME certificates | ✅ | ❌ | Reverse proxy |
-| The `/healthcheck` endpoint and `HEALTHCHECK_PATH` | ✅ | ❌ | A Laravel route, or `healthcheck-octane` |
-| Access logs with the `authorization` parameter redacted | ✅ | ❌ | Reverse proxy |
-
-::warning
-The first row is the one that bites, and the two servers fail differently.
-
-**Swoole** serves every non-PHP file in your public directory exactly as it finds it, with no blocklist. A `.env`, a `.git` directory, a database dump, or a stray `*.bak` is handed to anyone who asks, with a `200` and the file's full contents. It does refuse `.php` files, so an uploaded PHP file returns `404` instead of running.
-
-**RoadRunner** is worse out of the box. Octane starts it with `http.static.dir` pointed at your public directory and no forbidden extensions, so it serves *everything*, including `.php` files as `text/plain` source. See [Locking down RoadRunner's static files](#locking-down-roadrunners-static-files) for the fix.
-
-Our Caddyfile answers `403` for all of these. Put a reverse proxy in front of Octane in production.
-::
-
-Everything below the HTTP layer still works exactly as it does everywhere else. All of the `PHP_*` environment variables, the `AUTORUN_*` [Laravel automations](/docs/framework-guides/laravel/automations), the unprivileged user, and the file permission handling are part of the `cli` image, not the web server.
-
-### Running Octane on Swoole
-
-Swoole is a PHP extension, so install it in your own `Dockerfile`:
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-cli
-
-USER root
-RUN install-php-extensions swoole
-USER www-data
-```
-
-For Open Swoole, use `install-php-extensions openswoole` instead. Everything else is identical.
-
-Install Octane and point it at Swoole:
-
-```bash [Terminal]
-docker compose run php composer require laravel/octane
-docker compose run php php artisan octane:install --server=swoole
-```
-
-Then start it from your compose file:
-
-```yml [compose.yml]{10}
-services:
- php:
- build:
- context: .
- ports:
- - "80:8000"
- volumes:
- - .:/var/www/html/
- # --host=0.0.0.0 is required so the port is reachable outside the container
- command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
- healthcheck:
- test: ["CMD", "healthcheck-octane"]
- start_period: 10s
-```
-
-::warning
-`--host=0.0.0.0` is not optional in a container. Octane binds to `127.0.0.1` by default, which is only reachable from inside the container, so Docker's published port answers nothing without it.
-::
-
-Port `8000` is Octane's default and is above `1024`, so the unprivileged `www-data` user can bind it without any changes.
-
-### Running Octane on RoadRunner
-
-RoadRunner needs the `sockets` extension, which is not in the `cli` image, plus the RoadRunner binary itself:
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-cli
-
-USER root
-RUN install-php-extensions sockets
-
-# Install the RoadRunner binary. TARGETARCH is provided by BuildKit,
-# so this works on both amd64 and arm64.
-ARG TARGETARCH
-ARG ROADRUNNER_VERSION=2025.1.15
-RUN docker-php-serversideup-download \
- "https://github.com/roadrunner-server/roadrunner/releases/download/v${ROADRUNNER_VERSION}/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}.tar.gz" \
- /tmp/roadrunner.tar.gz && \
- tar -xzf /tmp/roadrunner.tar.gz -C /tmp && \
- mv "/tmp/roadrunner-${ROADRUNNER_VERSION}-linux-${TARGETARCH}/rr" /usr/local/bin/rr && \
- chmod +x /usr/local/bin/rr && \
- rm -rf /tmp/roadrunner*
-
-USER www-data
-```
-
-::warning
-Laravel's documentation tells you to install `spiral/roadrunner-cli` and run `./vendor/bin/rr get-binary`. That package requires `symfony/console` version 7 or below, and Laravel 13 ships version 8, so Composer cannot install it. `php artisan octane:install --server=roadrunner` also fails, because it shells out to `./vendor/bin/rr`.
-
-Install the binary yourself as shown above, and skip `octane:install` for RoadRunner. Set `OCTANE_SERVER=roadrunner` in your `.env` file instead.
-::
-
-Then add the PHP side and start the server:
-
-```bash [Terminal]
-docker compose run php composer require laravel/octane spiral/roadrunner-http
-```
-
-```yml [compose.yml]{9,10}
-services:
- php:
- build:
- context: .
- ports:
- - "80:8000"
- volumes:
- - .:/var/www/html/
- # --rr-config points RoadRunner at the hardened config below
- command: ["php", "artisan", "octane:start", "--server=roadrunner", "--host=0.0.0.0", "--port=8000", "--rpc-port=6001", "--rr-config=/var/www/html/.rr.yaml"]
- healthcheck:
- test: ["CMD", "healthcheck-octane"]
- start_period: 10s
-```
-
-#### Locking Down RoadRunner's Static Files
-
-Octane starts RoadRunner with `http.static.dir` pointed at your public directory and no forbidden extensions. RoadRunner will happily return your `.env` file, and it serves `.php` files as plain text rather than refusing them. Create a `.rr.yaml` in your project root and pass it with `--rr-config`:
-
-```yml [.rr.yaml]
-version: "3"
-
-http:
- static:
- # RoadRunner serves everything in the public directory by default,
- # including .php files as plain text. Refuse these outright.
- forbid: [".php", ".env", ".bak", ".log", ".sql", ".ini", ".git"]
-```
-
-With this file in place, `/.env`, `/uploaded.php`, and `/config.bak` all return `404`, while your real assets keep working. This is the RoadRunner equivalent of the rules our Caddyfile applies for you, and it is still narrower than what a reverse proxy gives you.
-
-### Serving Static Assets and TLS
-
-Laravel recommends running Octane behind NGINX or Apache in production so the web server can serve your static assets and terminate TLS. That reverse proxy is also where the protections from [What You Give Up](#what-you-give-up) belong.
-
-```yml [compose.yml]
-services:
- php:
- build:
- context: .
- volumes:
- - .:/var/www/html/
- # --host=0.0.0.0 is required so the port is reachable outside the container
- command: ["php", "artisan", "octane:start", "--server=swoole", "--host=0.0.0.0", "--port=8000"]
-
- web:
- image: nginx:1.29-alpine
- ports:
- - "80:80"
- volumes:
- - .:/var/www/html/:ro
- - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- depends_on:
- - php
-```
-
-Laravel publishes a reference NGINX configuration for this setup. Point `proxy_pass` at `http://php:8000` instead of `http://127.0.0.1:8000`, since Octane runs in a separate container here.
-
-:u-button{to="https://laravel.com/docs/13.x/octane#serving-your-application-via-nginx" target="_blank" label="View Laravel's NGINX configuration" aria-label="Laravel NGINX configuration for Octane" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-::warning
-Do not reach for `fpm-nginx` as the proxy image. The NGINX in that variation is configured to pass requests to PHP-FPM over FastCGI, not to proxy them to an Octane server. Use a plain `nginx` image with your own configuration.
-::
-
-### Checking the Server Status
-
-There is no `/healthcheck` endpoint here, because that endpoint lives in our Caddyfile. Your options are to define a health check route in your Laravel application, or to use the `healthcheck-octane` command that ships in every one of our images:
-
-```yml [compose.yml]
-services:
- php:
- # ...
- healthcheck:
- test: ["CMD", "healthcheck-octane"]
- start_period: 10s
-```
-
-It runs [`php artisan octane:status`](https://laravel.com/docs/13.x/octane#checking-the-server-status){target="_blank"}, which inspects the server Octane started, whichever server that is.
-
-### OPcache and PHP Settings
-
-All of the `PHP_*` environment variables work the same way they do in every other variation. One detail is different: Octane on Swoole or RoadRunner is a CLI process, so OPcache follows `PHP_OPCACHE_ENABLE_CLI` rather than `PHP_OPCACHE_ENABLE`.
-
-::tip
-`PHP_OPCACHE_ENABLE_CLI` already defaults to `1` in the `cli` image, so OPcache is on. See [Production Performance Tuning](/docs/guide/production-performance-tuning) for the rest of the settings worth reviewing.
-::
-
-### Watching for File Changes
-
-The `--watch` flag needs [Node](https://nodejs.org){target="_blank"} and [Chokidar](https://github.com/paulmillr/chokidar){target="_blank"} when you are not using FrankenPHP:
-
-```bash [Terminal]
-npm install --save-dev chokidar
-```
-
-Neither Node nor Chokidar ships in the `cli` image, so add them to your development image if you want this. FrankenPHP's built-in file watcher is specific to the `frankenphp` variation.
-
-### Swoole-Only Octane Features
-
-These are the features that make Swoole worth the extra work. None of them are available on FrankenPHP or RoadRunner:
-
-- **[Concurrent tasks](https://laravel.com/docs/13.x/octane#concurrent-tasks){target="_blank"}** run operations in parallel with `Octane::concurrently()`, using Swoole's task workers. Set how many with `--task-workers` on `octane:start`.
-- **[Ticks and intervals](https://laravel.com/docs/13.x/octane#ticks-and-intervals){target="_blank"}** invoke a callback every N seconds without a cron entry.
-- **[The Octane cache driver](https://laravel.com/docs/13.x/octane#the-octane-cache){target="_blank"}** stores data in shared memory at extremely high throughput. The data is flushed whenever the server restarts, so treat it as a cache and nothing more.
-- **[Tables](https://laravel.com/docs/13.x/octane#tables){target="_blank"}** share arbitrary structured data between all workers, with the same restart caveat.
-
-## Things to Watch Out For
-Since Octane is a whole different way of running Laravel compared to traditional PHP-FPM, there are a few things to watch out for.
-
-### Dependency Injection
-Be careful with how you inject dependencies into long-lived objects. Injecting the wrong things into constructors can cause requests to "leak" between users. Review Laravel's [Dependency Injection and Octane](https://laravel.com/docs/13.x/octane#dependency-injection-and-octane){target="_blank"} documentation for details.
-
-### Memory Leaks
-Review Laravel's [Octane documentation on memory leaks](https://laravel.com/docs/13.x/octane#managing-memory-leaks){target="_blank"} to understand what to avoid.
-
-### Reloading After Deployments
-Octane keeps your code in memory, so a deployment needs [`php artisan octane:reload`](https://laravel.com/docs/13.x/octane#reloading-the-workers){target="_blank"} to pick up new code. In a container this usually means replacing the container instead. If you do reload in place, our Caddyfile exposes the admin API Octane needs, so `octane:reload` works as expected.
-
-## Learn More
-- [FrankenPHP Variation Documentation](/docs/image-variations/frankenphp)
-- [Laravel Octane Documentation](https://laravel.com/docs/13.x/octane)
-- [FrankenPHP Documentation](https://frankenphp.dev/docs/)
diff --git a/docs/content/docs/3.framework-guides/2.wordpress/4.using-wordpress-with-docker.md b/docs/content/docs/3.framework-guides/2.wordpress/4.using-wordpress-with-docker.md
index 3995e405b..333575f1d 100644
--- a/docs/content/docs/3.framework-guides/2.wordpress/4.using-wordpress-with-docker.md
+++ b/docs/content/docs/3.framework-guides/2.wordpress/4.using-wordpress-with-docker.md
@@ -1,173 +1,71 @@
---
-head.title: 'WordPress Optimizations - Docker PHP - Server Side Up'
-description: 'Learn how serversideup/php is optimized for running WordPress in production.'
+head.title: 'Using WordPress with Docker - Docker PHP - Server Side Up'
+description: 'Run WordPress on the fpm-apache variation with MariaDB, and choose how to deploy updates.'
layout: docs
-title: Using Docker with WordPress
+title: Using WordPress with Docker
---
::lead-p
-Docker can modernize your WordPress infrastructure with benefits like consistent environments, easier deployments, and better scalability.
-
-However, WordPress's architecture predates modern containerization, so you'll need to balance container best practices with WordPress conventions to maintain plugin compatibility.
-::
-
-## Security Optimizations
-These images include security hardening specifically for WordPress deployments:
-
-- Hardened Apache & NGINX configurations
-- XML-RPC disabled by default (prevents common attack vectors)
-- Protected access to sensitive files (.git, .env, CI configurations)
-- Additional protections against common WordPress exploits
-
-See our [Apache security.conf](https://github.com/serversideup/docker-php/blob/main/src/variations/fpm-apache/etc/apache2/conf-available/security.conf) for complete details.
-
-## "The WordPress Way"
-WordPress has evolved significantly over the years, but its architecture was designed before modern containerization practices became standard. This means you'll find the smoothest experience when you align with WordPress's conventions rather than trying to impose modern development patterns that conflict with how WordPress expects to operate.
-
-**What does "The WordPress Way" mean for Docker users?**
-
-WordPress was built with certain assumptions:
-- Files are directly accessible and writable by the web server
-- Plugins and themes can be updated through the admin interface
-- The application runs on traditional LAMP (Linux, Apache, MySQL, PHP) stacks
-- Core, plugins, and themes are typically deployed together
-
-When you try to modernize WordPress with approaches like immutable containers, NGINX instead of Apache, or separating core files from your custom code, you may encounter compatibility issues with popular plugins that expect traditional WordPress file structures and permissions.
-
-The more you work with WordPress's conventions, the fewer compatibility issues you'll face with community plugins and themes.
-
-### Common Compatibility Challenges
-
-Modern container practices that work well with Laravel or other frameworks may cause issues with WordPress:
-
-**Using NGINX instead of Apache**
-- Many WordPress plugins expect Apache's .htaccess functionality
-- URL rewriting rules often need manual translation to NGINX
-- Some plugins detect the web server and behave differently
-
-**Packaging WordPress core into images**
-- The WordPress admin expects to update core, plugins, and themes through the UI
-- Immutable containers conflict with this expectation
-- Popular plugins may break when they can't write to expected directories
-
-**Using newer PHP versions**
-- Some popular plugins haven't updated for newer PHP versions
-- WordPress core itself is conservative with PHP version requirements
-- Testing against your specific plugin stack is essential
-
-**Using modern project structures (like [Roots Bedrock](https://roots.io/){target="_blank"})**
-- Changes WordPress's default directory structure
-- Some plugins hardcode paths and break with non-standard layouts
-- Community support often assumes standard WordPress structure
-
-::tip
-Test thoroughly with your specific plugins before committing to a non-traditional setup. The WordPress ecosystem is vast, and compatibility varies widely.
+Run WordPress on the [`fpm-apache`](/docs/image-variations/fpm-apache) variation. WordPress expects to update its own files from the admin screen. Many plugins also write rewrite rules to `.htaccess`, which only Apache reads. The setup on this page keeps both working.
::
-## Recommended Approach
-
-Based on our experience running WordPress in production containers, we recommend these principles:
-
-**1. Align with WordPress conventions**
- - Use Apache as your web server for maximum plugin compatibility
- - Allow WordPress to manage its own updates when possible
- - Maintain the standard WordPress directory structure
-
-**2. Keep customizations minimal**
- - Limit plugin installation to essential functionality
- - Fewer plugins means fewer compatibility concerns
- - Each plugin is a potential maintenance burden
+## Run WordPress with MariaDB
+[Download WordPress](https://wordpress.org/download/){target="_blank"} and extract it into `public/`, which is the image's default document root. Then add this Compose file next to `public/`:
-**3. Separate your code from WordPress core**
- - Only commit your custom themes and plugins to version control
- - Exclude WordPress core and third-party plugins from your repository
- - This makes your codebase smaller and easier to manage
-
-::note
-These recommendations prioritize stability and community compatibility. If your project has specific requirements that conflict with these principles, thoroughly test your setup with all your required plugins.
-::
-
-## Production Stack Example
-Here's a proven WordPress stack running on Docker Swarm that balances containerization benefits with WordPress compatibility:
-
-**Infrastructure components:**
-1. [Traefik](https://traefik.io/traefik/){target="_blank"} - Automatic SSL with Let's Encrypt and request routing
-2. [serversideup/php:8.5-fpm-apache](https://hub.docker.com/r/serversideup/php/tags?name=fpm-apache){target="_blank"} - PHP with Apache for WordPress compatibility
-3. [MariaDB](https://hub.docker.com/_/mariadb){target="_blank"} - MySQL-compatible database without Oracle licensing
-
-::tip
-Apache is recommended over NGINX for WordPress because many plugins rely on .htaccess files and Apache-specific functionality.
-::
-
-## Deployment Strategies
-
-WordPress presents unique challenges when deciding how to deploy updates in containerized environments.
-
-### Immutable Container Approach (Advanced)
-
-Packaging WordPress into Docker images with tools like [Roots Bedrock](https://roots.io/){target="_blank"} enables modern deployment practices:
-
-**Benefits:**
-- Rolling updates with zero downtime
-- Immutable infrastructure (containers don't change after deployment)
-- Easy rollback to previous versions
-
-**Challenges:**
-- Many popular plugins expect to write to WordPress directories
-- Plugin updates must happen outside the WordPress admin interface
-- Requires thorough compatibility testing with your plugin stack
-- Non-standard directory structure breaks some plugins
-
-### Volume-Based Approach (Traditional)
-
-Treating containers as the runtime environment while keeping WordPress files on persistent volumes offers the most compatibility:
-
-**Benefits:**
-- WordPress admin can update core, plugins, and themes normally
-- Maximum compatibility with community plugins
-- Familiar workflow for WordPress developers
-
-**Challenges:**
-- Updates aren't versioned in your deployment pipeline
-- Requires traditional deployment methods for custom code
-- Less alignment with immutable infrastructure principles
-
-**Example implementation:**
-
-```yml [compose.yml]
+```yaml [compose.yml]
services:
wordpress:
image: serversideup/php:8.5-fpm-apache
+ ports:
+ - "80:8080"
volumes:
- ./:/var/www/html
- ports:
- - 80:8080
- - 443:8443
+ depends_on:
+ - mariadb
+
+ mariadb:
+ image: mariadb:11.8
+ environment:
+ MARIADB_ROOT_PASSWORD: rootpassword
+ MARIADB_DATABASE: wordpress
+ MARIADB_USER: wordpress
+ MARIADB_PASSWORD: wordpress
+ volumes:
+ - db_data:/var/lib/mysql
+
+volumes:
+ db_data:
```
-Deploy your custom theme/plugin updates:
+Run `docker compose up`, open `http://localhost`, and enter `mariadb` as the database host in the installer. Change the passwords before you use this anywhere but your own machine.
-```bash
-# On your deployment server
-cd /path/to/your/project
-git pull origin main
-```
+WordPress runs as `www-data` (UID 33 on Debian). It can only install plugins and updates if that user can write to the mounted files. See [understanding file permissions](/docs/customizing-the-image/understanding-file-permissions) if the admin screen asks for FTP credentials.
-::note
-This approach prioritizes plugin compatibility over modern deployment practices. It works reliably with the widest range of WordPress plugins while still benefiting from containerized infrastructure.
-::
+For HTTPS, put a reverse proxy such as Traefik in front of the container or set `SSL_MODE`. See [configuring SSL](/docs/deployment-and-production/configuring-ssl).
-::warning
-With `PHP_OPCACHE_ENABLE=1`, PHP files are cached until the container restarts because [`PHP_OPCACHE_VALIDATE_TIMESTAMPS`](/docs/reference/environment-variable-specification) defaults to `0`. Updates made through the WordPress admin still work because WordPress clears the cache for the files it writes with [`wp_opcache_invalidate()`](https://developer.wordpress.org/reference/functions/wp_opcache_invalidate/){target="_blank"}. Changes made with `git pull`, WP-CLI, SFTP, or plugins that write PHP files directly are not picked up until you restart the container. If you deploy this way, either restart the container after each update or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`. [Learn more in the production performance tuning guide →](/docs/guide/production-performance-tuning#php-opcache)
-::
+## What the image blocks
+- **`xmlrpc.php`**: blocked by `fpm-apache` only. `fpm-nginx` and `frankenphp` don't block it, so add a rule yourself if you use them and don't need XML-RPC.
+- **Dotfiles** such as `.env` and `.git/` (except `/.well-known/`), and files ending in `.sql`, `.log`, `.bak`, `.ini`, and similar: blocked by every web server variation.
+
+The full Apache rules are in [`security.conf`](https://github.com/serversideup/docker-php/blob/main/src/variations/fpm-apache/etc/apache2/conf-available/security.conf){target="_blank"}.
+
+## Choose how to deploy updates
+There are two ways to run WordPress in containers. Pick by how much of your site is third-party plugins.
-### Which approach should you choose?
+### Volume-based approach (traditional)
+WordPress core, plugins, and themes live on a volume, and the container only provides PHP and Apache. The admin screen updates everything as it would on a regular host. Only your own themes and plugins go in version control. Deploy them with `git pull` or your usual tool. The [example repository](https://github.com/serversideup/docker-wordpress){target="_blank"} uses this approach.
-- **Volume-based**: Best for sites heavily dependent on third-party plugins or when you need maximum WordPress ecosystem compatibility
-- **Immutable containers**: Best for custom WordPress applications with minimal third-party dependencies where you control the entire codebase
+Choose it for sites that rely on third-party plugins. The trade-off is that updates made in the admin screen are not versioned or reproducible.
+
+::warning
+With `PHP_OPCACHE_ENABLE=1`, PHP keeps cached files until the container restarts, because `PHP_OPCACHE_VALIDATE_TIMESTAMPS` defaults to `0`. Updates from the admin screen still apply, because WordPress clears the cache for files it writes with [`wp_opcache_invalidate()`](https://developer.wordpress.org/reference/functions/wp_opcache_invalidate/){target="_blank"}. Changes from `git pull`, WP-CLI, SFTP, or plugins that write PHP files directly don't apply until you restart the container or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`. See [production performance tuning](/docs/deployment-and-production/production-performance-tuning).
+::
-Always test your specific plugin stack thoroughly before committing to either approach.
+### Immutable images
+WordPress core and every plugin are built into the image, often with [Roots Bedrock](https://roots.io/bedrock/){target="_blank"} and Composer. You [package the image like any other app](/docs/deployment-and-production/packaging-your-app-for-deployment). You get rolling deployments and rollbacks to a previous image.
-## Example Repository
-Below is an example repository showing the "traditional" approach to using WordPress with Docker.
+Choose it for custom sites where you control the code. Updates happen in your build, not the admin screen. Plugins that write to their own folders or expect the standard WordPress layout can break. Test every plugin you rely on.
-:u-button{to="https://github.com/serversideup/docker-wordpress" target="_blank" label="View the Example WordPress + Docker Repository" aria-label="View the Example WordPress + Docker Repository" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+## Related pages
+- [FPM-Apache](/docs/image-variations/fpm-apache)
+- [Configuring trusted proxies](/docs/deployment-and-production/configuring-trusted-proxies)
+- [Changing common PHP settings](/docs/customizing-the-image/changing-common-php-settings)
diff --git a/docs/content/docs/4.deployment-and-production/1.packaging-your-app-for-deployment.md b/docs/content/docs/4.deployment-and-production/1.packaging-your-app-for-deployment.md
new file mode 100644
index 000000000..23c510be3
--- /dev/null
+++ b/docs/content/docs/4.deployment-and-production/1.packaging-your-app-for-deployment.md
@@ -0,0 +1,108 @@
+---
+head.title: 'Packaging your app for deployment - Docker PHP - Server Side Up'
+title: Packaging your app for deployment
+description: 'Build a production image that contains your code, dependencies, and compiled assets.'
+layout: docs
+---
+
+::lead-p
+In development you mount your code into the container. In production you build it into the image, so the same image runs in staging and production, can't be changed on the server, and rolls back by deploying the previous tag.
+::
+
+## Copy your code into the image
+Create a `Dockerfile` in the root of your project:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+ENV PHP_OPCACHE_ENABLE=1
+
+COPY --chown=www-data:www-data . /var/www/html
+```
+
+- `PHP_OPCACHE_ENABLE=1` turns on OPcache, which is off by default so code edits show up during development. See [production performance tuning](/docs/deployment-and-production/production-performance-tuning) for what it changes.
+- `--chown=www-data:www-data` gives the files to the user PHP runs as, so your app can write to directories like `storage/`. The image already runs as `www-data`, so you don't need a `USER` line unless you switch to `root` earlier in the file.
+
+Build and tag the image with a version you can trace back to a commit:
+
+```bash [Terminal]
+docker build -t my-app:$(git rev-parse --short HEAD) .
+```
+
+## Keep files out of the image with `.dockerignore`
+`COPY . /var/www/html` copies everything in the build context. Add a `.dockerignore` next to your `Dockerfile` so local dependencies and secrets stay out:
+
+```text [.dockerignore]
+.git
+.env
+node_modules
+vendor
+public/build
+```
+
+`vendor` and `public/build` are excluded because the build creates them, as shown below.
+
+## Install dependencies and build assets in the image
+A multi-stage build installs Composer dependencies, compiles your front-end assets in a Node image, and copies only the results into the PHP image. Node and `node_modules` never reach production.
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx AS vendor
+COPY --chown=www-data:www-data composer.json composer.lock ./
+RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
+
+FROM node:22-alpine AS assets
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+COPY --from=vendor /var/www/html/vendor ./vendor
+ARG VITE_APP_NAME
+RUN npm run build
+
+FROM serversideup/php:8.5-fpm-nginx
+
+ENV PHP_OPCACHE_ENABLE=1
+
+COPY --chown=www-data:www-data --from=vendor /var/www/html/vendor ./vendor
+COPY --chown=www-data:www-data . .
+COPY --chown=www-data:www-data --from=assets /app/public/build ./public/build
+RUN composer dump-autoload --no-dev --optimize
+```
+
+Composer is included in every image, and the working directory is `/var/www/html`. Copying `composer.json` and `composer.lock` before the rest of your code lets Docker reuse the `composer install` layer until your dependencies change. `composer dump-autoload` then runs your Composer scripts, such as Laravel's `package:discover`, once the code is in place.
+
+The assets stage gets a copy of `vendor` because some front-end builds import files from it, such as the CSS for Livewire's Flux components.
+
+Vite builds `VITE_*` values into your JavaScript, and `.env` isn't in the build context. Declare each one with `ARG` in the assets stage and pass it at build time:
+
+```bash [Terminal]
+docker build --build-arg VITE_APP_NAME="My App" -t my-app:$(git rev-parse --short HEAD) .
+```
+
+These values end up in public JavaScript, so never pass a secret this way.
+
+If you need extra PHP extensions, switch to `root` to install them and switch back before you copy your code. See [installing additional PHP extensions](/docs/customizing-the-image/installing-additional-php-extensions).
+
+## Pass secrets when the container starts
+Don't put secrets in the image with `ENV` or by copying `.env`. Anyone who can pull the image can read them. Provide them as environment variables or secrets from your orchestrator when the container starts.
+
+## Test the image locally
+Run the image you built, without a volume, to check that everything it needs is inside it:
+
+```yaml [compose.prod.yml]
+services:
+ php:
+ image: my-app:1.0.0
+ ports:
+ - 80:8080
+```
+
+```bash [Terminal]
+docker build -t my-app:1.0.0 .
+docker compose -f compose.prod.yml up
+```
+
+## Run it in production
+The image runs the same way under Docker Compose, Docker Swarm, or Kubernetes. If you want a tool that sets up servers, Swarm, and zero-downtime deployments for these images, see [Spin](https://serversideup.net/open-source/spin/){target="_blank"}.
+
+Next, [configure SSL](/docs/deployment-and-production/configuring-ssl) for your domain.
diff --git a/docs/content/docs/4.deployment-and-production/2.configuring-ssl.md b/docs/content/docs/4.deployment-and-production/2.configuring-ssl.md
new file mode 100644
index 000000000..b717b32f5
--- /dev/null
+++ b/docs/content/docs/4.deployment-and-production/2.configuring-ssl.md
@@ -0,0 +1,169 @@
+---
+head.title: 'Configuring SSL - Docker PHP - Server Side Up'
+title: Configuring SSL
+description: 'Serve HTTPS from fpm-nginx, fpm-apache, or FrankenPHP with a self-signed certificate, your own certificate, or Let''s Encrypt.'
+layout: docs
+---
+
+::lead-p
+`SSL_MODE` turns on HTTPS in the `fpm-nginx`, `fpm-apache`, and `frankenphp` images. The container can generate a self-signed certificate or load one you provide. FrankenPHP can also get a trusted certificate from Let's Encrypt.
+::
+
+## Supported variations
+
+| Variation | HTTPS in the container | Let's Encrypt in the container |
+|-----------|------------------------|--------------------------------|
+| `cli` | ❌ | ❌ |
+| `fpm` | ❌ | ❌ |
+| `fpm-nginx` | ✅ | ❌ Use a reverse proxy |
+| `fpm-apache` | ✅ | ❌ Use a reverse proxy |
+| `frankenphp` | ✅ | ✅ |
+
+## SSL modes
+
+| `SSL_MODE` | Port `8080` | Port `8443` |
+|------------|-------------|-------------|
+| `off` (default) | Serves HTTP | Not used |
+| `mixed` | Serves HTTP | Serves HTTPS |
+| `full` | Redirects to HTTPS | Serves HTTPS |
+
+In `full` mode, port `8080` keeps listening so it can redirect. The redirect goes to `https://` on your domain with no port, which means port 443. Publish `443:8443` on the host.
+
+The containers run unprivileged and can't bind to ports below 1024. Map your host ports to `8080` and `8443` (`80:8080`, `443:8443`).
+
+## Choose where HTTPS ends
+
+| Approach | Certificate | Renewal | Zero-downtime deployments | Containers |
+|----------|-------------|---------|---------------------------|------------|
+| [Reverse proxy](#reverse-proxy-recommended) | ✅ Let's Encrypt | ✅ Automatic | ✅ Yes | 2 |
+| [FrankenPHP automatic HTTPS](#frankenphp-automatic-https) | ✅ Let's Encrypt | ✅ Automatic | ❌ No | 1 |
+| [Bring a certificate](#use-your-own-certificate) | ✅ Any vendor | ❌ Manual | ❌ No | 1 |
+| [Self-signed](#self-signed-certificate) | ❌ Browser warning | ✅ Automatic | ❌ No | 1 |
+
+## Reverse proxy (recommended)
+{:zoom=false}
+
+A reverse proxy such as [Traefik](https://traefik.io/traefik/){target="_blank"} or [Caddy](https://caddyserver.com/){target="_blank"} listens on ports 80 and 443 and forwards requests to your container. It gets certificates from Let's Encrypt. Because the proxy stays up while you replace containers behind it, this is also how you get zero-downtime deployments.
+
+Point the proxy at port `8080` and leave `SSL_MODE` at `off`. If you want traffic between the proxy and the container encrypted too, set `SSL_MODE` to `mixed` or `full` and point the proxy at port `8443`. The container's self-signed certificate works for that hop if you set the proxy to accept it. Set [`TRUSTED_PROXY`](/docs/deployment-and-production/configuring-trusted-proxies) so your logs show the visitor's IP instead of the proxy's.
+
+Setting up the proxy is outside the scope of these docs. [Spin](https://serversideup.net/open-source/spin/){target="_blank"} sets up Traefik with these images if you want it done for you.
+
+## Certificate files
+The `fpm-nginx`, `fpm-apache`, and `frankenphp` images read the certificate and key from these paths:
+
+| Variable | Default |
+|----------|---------|
+| `SSL_CERTIFICATE_FILE` | `/etc/ssl/private/self-signed-web.crt` |
+| `SSL_PRIVATE_KEY_FILE` | `/etc/ssl/private/self-signed-web.key` |
+
+When `SSL_MODE` is `mixed` or `full`, the startup script `5-generate-ssl.sh` checks both paths:
+
+- Both files exist: the container uses them.
+- Neither exists: the container generates a self-signed certificate for `localhost`, valid for 365 days, at those paths.
+- Only one exists: the container stops with an error naming both paths.
+
+## Use your own certificate
+Mount your certificate and key, then point the two variables at them. The certificate file should hold your certificate followed by its intermediate certificates (the full chain), in PEM format.
+
+```yaml [compose.yml]{8-10,13}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - 80:8080
+ - 443:8443
+ environment:
+ SSL_MODE: "full"
+ SSL_CERTIFICATE_FILE: "/etc/ssl/custom/example.com.crt"
+ SSL_PRIVATE_KEY_FILE: "/etc/ssl/custom/example.com.key"
+ volumes:
+ - .:/var/www/html
+ - ./certs:/etc/ssl/custom:ro
+```
+
+The web server runs as `www-data` and must be able to read the key. A key that only your host user can read (`600` owned by your UID) stops NGINX with `Permission denied`. Give the key to `www-data`, which is UID 33 on Debian and UID 82 on Alpine:
+
+```bash [Terminal]
+sudo chown 33:33 certs/example.com.key
+chmod 600 certs/example.com.key
+```
+
+To build the certificate into your image instead, `COPY --chown=www-data:www-data` it to the same paths.
+
+You renew this certificate yourself. Restart the container after you replace the files so the web server loads them.
+
+## Self-signed certificate
+Set `SSL_MODE` without providing certificate files and the container generates one at startup:
+
+```yaml [compose.yml]{8}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - 80:8080
+ - 443:8443
+ environment:
+ SSL_MODE: "mixed"
+ volumes:
+ - .:/var/www/html
+```
+
+The container now serves HTTP on `8080` and HTTPS on `8443`, which this file publishes on host ports 80 and 443. Browsers show a warning for self-signed certificates. Use them for local HTTPS testing or to encrypt traffic behind a reverse proxy. A new container generates a new certificate.
+
+## FrankenPHP automatic HTTPS
+The `frankenphp` image can get and renew a Let's Encrypt certificate itself through Caddy. Your server must be reachable from the internet on ports 80 and 443 for the domain you set.
+
+| Variable | Default | Set to |
+|----------|---------|--------|
+| `CADDY_AUTO_HTTPS` | `off` | `on`, to turn on Caddy's [automatic HTTPS](https://caddyserver.com/docs/caddyfile/options#auto-https){target="_blank"} |
+| `CADDY_HTTPS_SERVER_ADDRESS` | `https://` | Your domain, such as `example.com` or `https://example.com` |
+| `SSL_MODE` | `off` | `full` or `mixed` |
+
+Caddy stores certificates in `/data` and its configuration in `/config`. Mount both as volumes so certificates survive a new container; otherwise every deployment requests new ones and can hit Let's Encrypt's rate limits. Named volumes work as is. A bind mount must be writable by `www-data`.
+
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-frankenphp
+ ports:
+ - 80:8080
+ - 443:8443
+ environment:
+ CADDY_AUTO_HTTPS: "on"
+ CADDY_HTTPS_SERVER_ADDRESS: "example.com"
+ SSL_MODE: "full"
+ volumes:
+ - .:/var/www/html
+ - caddy_config:/config
+ - caddy_data:/data
+
+volumes:
+ caddy_config:
+ caddy_data:
+```
+
+FrankenPHP binds the host ports itself. You can't start a new container alongside the old one. Put a reverse proxy in front if you need zero-downtime deployments.
+
+### Short-lived and IP address certificates
+Let's Encrypt's [`shortlived` profile](https://letsencrypt.org/docs/profiles/){target="_blank"} issues certificates that last about 6 days. It is required for [IP address certificates](https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability){target="_blank"}. Select it with `CADDY_ACME_PROFILE`:
+
+| Variable | Default | Values |
+|----------|---------|--------|
+| `CADDY_ACME_PROFILE` | `off` | `shortlived`, `tlsserver`, or `classic` |
+
+::warning
+Any value other than `off` makes Caddy request certificates from Let's Encrypt only, without its usual ZeroSSL fallback. Short-lived certificates renew about every 2 days. The container needs reliable outbound access to Let's Encrypt.
+::
+
+Clients that connect by IP address send no server name. Tell Caddy which certificate to serve with `default_sni`:
+
+```yaml [compose.yml]{6-7}
+services:
+ php:
+ image: serversideup/php:8.5-frankenphp
+ environment:
+ CADDY_AUTO_HTTPS: "on"
+ CADDY_ACME_PROFILE: "shortlived"
+ CADDY_GLOBAL_OPTIONS: "default_sni example.com"
+```
diff --git a/docs/content/docs/4.deployment-and-production/2.development-to-production.md b/docs/content/docs/4.deployment-and-production/2.development-to-production.md
deleted file mode 100644
index 074c537a9..000000000
--- a/docs/content/docs/4.deployment-and-production/2.development-to-production.md
+++ /dev/null
@@ -1,153 +0,0 @@
----
-title: Development to Production
-description: 'Learn how to take your PHP application from development to production with confidence using Docker.'
-layout: docs
----
-
-::lead-p
-The real power of Docker isn't just running containers locally — it's running the **exact same environment** from your laptop to production. This guide shows you how to achieve 100% environment replication and deploy with confidence.
-::
-
-## The traditional deployment problem
-If you've deployed applications before, you've probably experienced these frustrations:
-
-- **"It works on my machine"** - Something breaks in production that worked perfectly locally
-- **Environment drift** - Your development, staging, and production environments slowly become different
-- **Deployment anxiety** - Every deployment feels risky because you can't be sure what will happen
-- **Vendor lock-in** - You're stuck with a PaaS provider because migrating is too painful
-- **Downtime** - Users experience interruptions during deployments
-- **Configuration chaos** - Infrastructure settings scattered across multiple places
-
-These problems share a common root cause: **your environments aren't the same**.
-
-## How Docker solves this
-When you containerize your application properly, you package everything your app needs into a single, reproducible unit. This means:
-
-### 100% environment replication
-The container that runs on your MacBook is **identical** to the one running in production. Same PHP version, same extensions, same web server, same configurations. No surprises.
-
-### Version-controlled infrastructure
-Your `compose.yml` and Dockerfiles live in Git alongside your application code. Need to change PHP settings? Update a file, commit it, and deploy. Need to roll back? Just deploy the previous commit. Your infrastructure is now as manageable as your application code.
-
-### Freedom to choose your host
-Because your application is containerized, you can run it anywhere that supports Docker — DigitalOcean, Hetzner, Vultr, AWS, your own hardware, anywhere. If a host raises prices or doesn't meet your needs anymore, migration becomes straightforward instead of a massive project.
-
-### Simplified scaling
-Once you have one container running, scaling to multiple containers becomes much simpler. Add more resources by deploying more containers, not by re-configuring servers.
-
-## The development to production journey
-Let's walk through what this journey actually looks like when you use containers properly.
-
-### 1. Development (where you are now)
-You've already experienced this in our [installation guide](/docs/getting-started/installation). You're running containers locally with Docker Compose, your files are mounted as volumes, and changes appear instantly. This is perfect for development because:
-
-- You can edit files with your favorite tools
-- Changes appear immediately without rebuilding
-- You can experiment freely
-- Multiple developers work with identical environments
-
-### 2. Building production images
-For production, you'll create optimized images that have your application code **built into the image** instead of mounted as volumes. This ensures:
-
-- Your code can't be accidentally modified in production
-- Images are portable and can run anywhere
-- Deployments are atomic — either the new version runs or the old one does
-- You can roll back by deploying a previous image
-
-We cover this in detail in our [packaging guide](/docs/deployment-and-production/packaging-your-app-for-deployment).
-
-### 3. Choosing a deployment strategy
-You have several options for running containers in production:
-
-**Docker Compose** - Simple and effective for single-server deployments. Great for getting started.
-
-**Docker Swarm** - Built into Docker, provides zero-downtime deployments, automatic SSL with Let's Encrypt, load balancing, and easy scaling across multiple servers.
-
-**Kubernetes** - The most powerful option for large-scale deployments. More complex but extremely capable.
-
-Each option uses the same OCI-compliant container images, so you can start simple and graduate to more sophisticated setups as you grow.
-
-::tip
-We recommend starting with Docker Compose for development environments and Docker Swarm for production. Swarm gives you zero-downtime deployments without the complexity of Kubernetes.
-::
-
-### 4. Automated deployments
-The final piece is automation. Instead of manually SSHing into servers, you'll set up CI/CD pipelines (usually with GitHub Actions) that:
-
-1. Run your tests on every commit
-2. Build production images
-3. Deploy automatically to staging or production
-4. Roll back automatically if health checks fail
-
-This means you can deploy with a simple `git push` and have confidence that your application will deploy safely.
-
-## Simplifying the journey with Spin
-While everything described above is achievable with standard Docker tools, there's a learning curve. We've experienced these challenges ourselves, which is why we created [Spin](https://serversideup.net/open-source/spin/).
-
-::note
-**Spin is NOT required** to use these Docker images. They work with any Docker setup. But if you're looking for a simpler path to production, Spin can help significantly.
-::
-
-### What is Spin?
-Spin is a free and open source tool that simplifies Docker workflows from development to production. It helps you:
-
-- Set up local development environments with a single command
-- Provision and deploy to any VPS provider (DigitalOcean, Hetzner, Vultr, etc.)
-- Configure Docker Swarm for zero-downtime deployments
-- Set up automated SSL with Let's Encrypt
-- Deploy with GitHub Actions
-- Manage multiple environments (dev, staging, production)
-
-Think of Spin as a helpful wrapper around Docker that handles the complex bits while teaching you Docker best practices.
-
-### See it in action
-This video demonstrates the complete journey from creating a Laravel project to deploying it to production with zero-downtime deployments:
-
-:iframe{src="https://www.youtube-nocookie.com/embed/5z2JoEt5XIk?si=u5v-bDN-cMv0OE-C" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen style="aspect-ratio: 16/9; width: 100%;"}
-
-The best part? It uses these same `serversideup/php` images you're already familiar with.
-
-:u-button{to="https://serversideup.net/open-source/spin/" label="Learn more about Spin" aria-label="Learn more about Spin" target="_blank" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### Advanced Laravel features with Spin Pro
-For teams running advanced Laravel features (Horizon, Reverb, scheduled tasks, multiple databases), we offer [Spin Pro](https://getspin.pro/) — a paid starter kit that includes pre-configured templates for these services.
-
-It's a one-time purchase that gives you:
-- Project templates with Horizon, Reverb, queues, and more
-- Pre-configured GitHub Actions workflows
-- Zero-downtime deployment configurations
-- Support for multiple database engines (MySQL, PostgreSQL, MariaDB, SQLite)
-
-Spin Pro exists because we've deployed dozens of Laravel applications ourselves and kept running into the same setup challenges. We built the solution we wished existed, and we're sharing it with the community.
-
-::note
-All Spin Pro templates use the same open source `serversideup/php` images and Spin CLI you're already using. You're buying the templates and automation, not proprietary technology.
-::
-
-## Alternative approaches
-Spin is one tool among many. Here are other common approaches:
-
-**Manual Docker Compose** - Use Docker Compose directly. More hands-on but gives you complete control. Great for learning.
-
-**Kubernetes** - Use tools like Helm or Kustomize to deploy to Kubernetes clusters. More complex but extremely powerful for large-scale applications.
-
-**PaaS Providers** - Services like Laravel Forge, Ploi, or platform providers can deploy containers for you. More expensive but less hands-on management.
-
-**CI/CD Tools** - GitLab CI, GitHub Actions, or Jenkins can orchestrate your deployments without additional tools.
-
-Choose whatever works best for your team's skills, budget, and requirements. The important part is that you're using containers, which means you maintain flexibility to change strategies later.
-
-## Key principles for success
-Regardless of which tools you choose, follow these principles:
-
-1. **Keep development and production similar** - Use the same base images and configurations in both environments
-2. **Store infrastructure in Git** - Version control your Docker configurations alongside your code
-3. **Automate everything** - Manual deployments lead to mistakes and inconsistency
-4. **Use health checks** - Let your orchestrator verify deployments succeed before switching traffic
-5. **Practice deployments** - Deploy to staging frequently to catch issues before production
-6. **Monitor your containers** - Use logging and monitoring to understand what's happening in production
-
-## What's next?
-Now that you understand the journey from development to production, you're ready to learn how to properly package your application for deployment.
-
-:u-button{to="/docs/deployment-and-production/packaging-your-app-for-deployment" label="Learn how to package your app for deployment" aria-label="Learn how to package your app for deployment" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
\ No newline at end of file
diff --git a/docs/content/docs/4.deployment-and-production/3.health-checks.md b/docs/content/docs/4.deployment-and-production/3.health-checks.md
new file mode 100644
index 000000000..0a37cd000
--- /dev/null
+++ b/docs/content/docs/4.deployment-and-production/3.health-checks.md
@@ -0,0 +1,68 @@
+---
+head.title: 'Health checks - Docker PHP - Server Side Up'
+title: Health checks
+description: 'How the built-in Docker health check works in each variation, and how to point it at a route in your app.'
+layout: docs
+---
+
+::lead-p
+Every image except `cli` ships a Docker `HEALTHCHECK`. It works with no configuration. Set `HEALTHCHECK_PATH` to check a route in your app instead of the web server alone.
+::
+
+## What each variation checks
+
+| Variation | Check | Passes when |
+|---|---|---|
+| `fpm-nginx` | `curl` to `http://localhost:${NGINX_HTTP_PORT}${HEALTHCHECK_PATH}` | NGINX and PHP-FPM both answer |
+| `fpm-apache` | `curl` to `http://localhost:${APACHE_HTTP_PORT}${HEALTHCHECK_PATH}` | Apache and PHP-FPM both answer |
+| `frankenphp` | `curl` to `http://localhost:${CADDY_HTTP_PORT}${HEALTHCHECK_PATH}` | Caddy answers |
+| `fpm` | [`php-fpm-healthcheck`](https://github.com/renatomefi/php-fpm-healthcheck){target="_blank"} | PHP-FPM's status page answers |
+| `cli` | None | |
+
+`HEALTHCHECK_PATH` defaults to `/healthcheck`. In `fpm-nginx` and `fpm-apache`, the web server passes `/healthcheck` to PHP-FPM's ping page, which returns `OK`. In `frankenphp`, Caddy returns `OK` itself. Neither one reaches your app.
+
+The check follows the port variable when you change it. `curl` follows redirects and skips certificate checks, so the check still passes with `SSL_MODE=full`, where the HTTP port redirects to HTTPS. Requests to `HEALTHCHECK_PATH` stay out of the access log.
+
+## Check your app instead
+
+Set `HEALTHCHECK_PATH` to a route in your app that returns `200`, such as Laravel's `/up`:
+
+```yaml [compose.yml]
+services:
+ app:
+ image: my-org/my-app:1.0.0
+ environment:
+ HEALTHCHECK_PATH: "/up"
+```
+
+The check now fails when your app can't boot, not only when the web server is down. Pick a route that answers quickly.
+
+In `fpm-nginx` and `fpm-apache`, S6 Overlay requests the same path at startup. It marks the web server ready once the path returns `200`.
+
+[Laravel health checks](/docs/framework-guides/laravel/health-checks) covers `/up` and the checks for queue, Horizon, and scheduler containers.
+
+## Timing
+
+All four checks use the same settings:
+
+| Setting | Value |
+|---|---|
+| `start_period` | `60s` |
+| `start_interval` | `3s` |
+| `interval` | `10s` |
+| `timeout` | `3s` |
+| `retries` | `3` |
+
+During the first 60 seconds, Docker checks every 3 seconds (Docker Engine 25 and newer), and failures don't count. After that, three failures in a row mark the container unhealthy. Override any of these with `healthcheck:` in your Compose file.
+
+## What happens when a check fails
+
+Docker and Docker Compose only mark a container unhealthy. They don't restart it. Compose uses health only for `depends_on` with `condition: service_healthy`.
+
+Docker Swarm replaces unhealthy containers. During a rolling update, it waits for each new container to pass before it moves on.
+
+Kubernetes ignores Docker's `HEALTHCHECK`. Point a readiness or liveness probe at the same path instead.
+
+## Containers that run another command
+
+A container that runs `php artisan queue:work` or another command in place of the web server still inherits the image's health check. That check fails, because no web server is running. Give each of these containers its own `healthcheck:`. The [Laravel health check scripts](/docs/framework-guides/laravel/health-checks#every-worker-gets-a-check) cover the common Laravel workers.
diff --git a/docs/content/docs/4.deployment-and-production/3.packaging-your-app-for-deployment.md b/docs/content/docs/4.deployment-and-production/3.packaging-your-app-for-deployment.md
deleted file mode 100644
index 59c9eea8a..000000000
--- a/docs/content/docs/4.deployment-and-production/3.packaging-your-app-for-deployment.md
+++ /dev/null
@@ -1,167 +0,0 @@
----
-title: Packaging Your App for Deployment
-description: 'Learn how to properly package your PHP application into production-ready Docker images.'
-layout: docs
----
-
-::lead-p
-In development, you mount your code as a volume for instant changes. In production, you build your code directly into the image for security, reliability, and portability. This guide shows you how to make that transition.
-::
-
-## Development vs production images
-Let's revisit what you've been doing in development and why it changes for production.
-
-### What you've been doing (development)
-In the [installation guide](/docs/getting-started/installation), your `compose.yml` looked like this:
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - 80:8080
- volumes:
- - ./:/var/www/html # Your code mounted as a volume
-```
-
-This works great for development because:
-- You edit files on your computer and see changes instantly
-- No rebuild needed between changes
-- Fast iteration and debugging
-
-### What you need for production
-For production, you'll create a **custom image** that includes your application code:
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx
-
-# Copy your application code into the image
-COPY --chown=www-data:www-data . /var/www/html
-```
-
-This is better for production because:
-- Your code can't be accidentally modified or deleted
-- The image is completely portable and self-contained
-- You can version and tag each release
-- Deployments are atomic — the new version either works or it doesn't
-
-## Creating your first production Dockerfile
-Let's create a proper production Dockerfile step by step. Start by creating a file called `Dockerfile` in your project root.
-
-::tip
-The `Dockerfile` should live at the root of your project, in the same directory as your `compose.yml`.
-::
-
-### Basic PHP application
-For a simple PHP application, your Dockerfile might look like this:
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx
-
-# Switch to root to install dependencies and copy files
-USER root
-
-# Install PHP dependencies
-RUN install-php-extensions intl bcmath
-
-# Switch back to non-root user for security
-USER www-data
-
-# Copy application files with correct ownership
-COPY --chown=www-data:www-data . /var/www/html
-```
-
-That's it! This takes your application code and bakes it into the image.
-
-::note
-Notice we use `--chown=www-data:www-data` when copying files. This ensures the web server can read your files. Learn more in our [file permissions guide](/docs/guide/understanding-file-permissions).
-::
-
-## Building your image
-Once you have your Dockerfile, build your image with:
-
-```bash [Terminal]
-docker build -t my-app:latest .
-```
-
-This creates an image tagged as `my-app:latest` that contains your application code.
-
-### Testing your production image
-Before deploying, test your production image locally:
-
-```yml [compose.prod.yml]
-services:
- php:
- # Use your custom image instead of the base image
- image: my-app:latest
- ports:
- - 80:8080
-```
-
-Then run:
-
-```bash [Terminal]
-docker compose -f compose.prod.yml up
-```
-
-This lets you verify your production image works correctly before deploying to real servers.
-
-## Best practices for production images
-
-### 1. Use specific image tags
-Don't use `latest` tags in production. Use specific versions:
-
-```dockerfile
-# Bad - version can change unexpectedly
-FROM serversideup/php:latest
-
-# Good - explicitly versioned
-FROM serversideup/php:8.5-fpm-nginx
-```
-
-### 2. Run as non-root user
-Always run your application as a non-root user for security:
-
-```dockerfile
-# Our images default to www-data, but explicitly switch back if you use root
-USER www-data
-```
-
-All `serversideup/php` images default to running as the `www-data` user for security.
-
-### 3. Keep secrets out of images
-Never bake secrets into your images:
-
-```dockerfile
-# ❌ Never do this
-ENV APP_KEY=base64:your-secret-key
-
-# ✅ Provide secrets at runtime via environment variables
-```
-
-Secrets should be provided when the container starts, not built into the image.
-
-## Image versioning strategy
-When building images for deployment, tag them with meaningful versions:
-
-```bash [Terminal]
-# Tag with git commit SHA
-docker build -t my-app:$(git rev-parse --short HEAD) .
-
-# Tag with semantic version
-docker build -t my-app:1.2.3 .
-
-# Tag with date and build number
-docker build -t my-app:2024-10-31-build-42 .
-```
-
-Good versioning lets you:
-- Track which code is running in each environment
-- Roll back to previous versions quickly
-- Debug issues by knowing exactly what's deployed
-
-## What's next?
-Now that you know how to package your application, you're ready to learn about SSL configuration and choosing the right hosting provider for your containerized application.
-
-:u-button{to="/docs/deployment-and-production/configuring-ssl" label="Configure SSL for your application" aria-label="Configure SSL for your application" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
diff --git a/docs/content/docs/4.deployment-and-production/4.configuring-ssl.md b/docs/content/docs/4.deployment-and-production/4.configuring-ssl.md
deleted file mode 100644
index f84bbe846..000000000
--- a/docs/content/docs/4.deployment-and-production/4.configuring-ssl.md
+++ /dev/null
@@ -1,274 +0,0 @@
----
-head.title: 'Configuring SSL - Docker PHP - Server Side Up'
-description: 'Learn how to use SSL with the serversideup/php images.'
-layout: docs
-title: Configuring SSL
----
-
-::lead-p
-SSL encryption is natively supported in our images. With FrankenPHP, a trusted certificate can automatically be generated by Let's Encrypt. You can also bring your own certificates or have a self-signed certificate generated for you.
-::
-
-## Supported Variations
-SSL is natively supported in the following variations:
-
-| Variation | SSL Support | Automated, Signed Certificate Support (via Let's Encrypt) |
-|-----------|---------------------|---------------------|
-| `cli` | ❌ No | - |
-| `fpm` | ❌ No | - |
-| `fpm-nginx` | ✅ Yes | ❌ No (requires a reverse proxy in front of the container) |
-| `fpm-apache` | ✅ Yes | ❌ No (requires a reverse proxy in front of the container) |
-| `frankenphp` | ✅ Yes | ✅ Yes |
-
-## SSL Modes
-You can control SSL behavior with the `SSL_MODE` environment variable:
-
-| SSL Mode | Description |
-|----------|-------------|
-| `off` (default) | HTTP only. |
-| `mixed` | HTTP and HTTPS. |
-| `full` | HTTPS only. HTTP requests will be redirected to HTTPS. |
-
-## Choose How to Run SSL in Production
-
-You have a few options for running SSL in production:
-
-| Approach | Certificate Type | Management Type | Zero-Downtime Deployments | Minimal Number of Containers |
-|----------|----------------|-----------------|-----------------|--------------------------------|
-| Reverse Proxy (like Traefik or Caddy) | ✅ Trusted Certificate (via Let's Encrypt) | ✅ Automatic | ✅ Yes | ⚠️ 2 |
-| FrankenPHP's built-in automatic HTTPS | ✅ Trusted Certificate (via Let's Encrypt) | ✅ Automatic | ❌ No | ✅ 1 |
-| Bring Your Own Certificate | ✅ Trusted Certificate (through any vendor) | ❌ Manual | ❌ No | ✅ 1 |
-| Self-signed | ❌ Self-signed Certificate | ✅ Automatic | ❌ No | ✅ 1 |
-
-### Reverse Proxy (recommended)
-::tip
-Reverse proxies don't just terminate SSL—they also give you zero-downtime with rolling updates.
-::
-{:zoom=false}
-Our recommended approach is to use a reverse proxy like [Traefik](https://traefik.io/traefik/){target="_blank"} or [Caddy](https://caddyserver.com/){target="_blank"} that listens on ports 80 (HTTP) and 443 (HTTPS). The reverse proxy will forward traffic to your container on the non-privileged ports of 8080 (HTTP) or 8443 (HTTPS).
-
-Using a reverse proxy unlocks two major benefits:
-1. Automatic SSL certificate management (via Let's Encrypt)
-2. Zero-downtime deployments
-
-When you're running updates on containers, the reverse proxy stays online while updates are deployed to your containers in the background. Configuring a reverse proxy is outside the scope of this documentation, but you can reference the links below to learn more:
-
-- [Traefik](https://traefik.io/traefik/){target="_blank"}
-- [Caddy](https://caddyserver.com/){target="_blank"}
-
-#### Use a Reverse Proxy When You Want...
-- Zero-downtime deployments
-- Automatic SSL certificate management (via Let's Encrypt)
-- Load balancing
-
-If you want a simple way to run your own reverse proxy with zero-downtime deployments, consider using [Spin](https://serversideup.net/open-source/spin/){target="_blank"}.
-
-:u-button{to="https://serversideup.net/open-source/spin/" label="Learn more about Spin" aria-label="Learn more about Spin" target="_blank" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### FrankenPHP's Built-in Automatic HTTPS
-::warning
-Zero-downtime deployments are not possible with FrankenPHP's built-in automatic HTTPS.
-::
-
-FrankenPHP provides automated HTTPS through Caddy. To directly expose FrankenPHP to the internet, you'll need to configure the following:
-1. Environment variables (for Caddy configuration)
-2. Ports (for direct exposure of ports 80 and 443)
-3. Volumes (for certificate files)
-
-#### Environment Variables
-Configure the following environment variables:
-
-| Variable | Expected Value | Description |
-|----------|----------------|----------------|
-| `CADDY_AUTO_HTTPS` *Default: "off"* | `on` | Turn on Caddy's [`auto_https`](https://caddyserver.com/docs/caddyfile/options#auto-https){target="_blank"} global directive.|
-| `CADDY_HTTPS_SERVER_ADDRESS` *Default: "https://"* | `example.com` or `https://example.com` | Set the [server address](https://caddyserver.com/docs/caddyfile/concepts#addresses){target="_blank"} for HTTPS. Pro tip: You can use `$APP_URL` from your `.env` file to set this value. |
-| `SSL_MODE` *Default: "off"* | `full` or `mixed` | Configure how Caddy handles HTTP and HTTPS requests.|
-
-#### Ports
-Configure the following ports:
-
-| Ports to Publish | Description |
-|----------------|----------------|
-| `80` → `8080` | HTTP traffic will be proxied to the container on port 8080. |
-| `443` → `8443` | HTTPS traffic will be proxied to the container on port 8443. |
-
-::note
-Our port mapping remains `80:8080` and `443:8443` because our containers are **unprivileged** by default, meaning we cannot bind to ports less than 1024 (without additional modification).
-::
-
-#### Volumes
-Configure the following volumes:
-
-| Container Directory to Mount | Description |
-|--------------------------------|-------------|
-| `/config` | Directory for Caddy's configuration files (such as Caddyfile or JSON) that must persist for settings to be retained. |
-| `/data` | Directory where Caddy stores SSL/TLS certificates and CA information, required for automatic HTTPS to consistently function. |
-
-::note
-The `config` and `data` volumes must have read/write permissions for the `www-data` user. Caddy will store its configuration and certificates in these volumes (and you want those to persist).
-::
-
-#### Example
-Here's an example of directly exposing FrankenPHP to the internet with automatic HTTPS via Let's Encrypt:
-::code-tree{defaultValue="compose.yml"}
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-frankenphp
- ports:
- - 80:8080
- - 443:8443
- environment:
- CADDY_AUTO_HTTPS: "on"
- CADDY_HTTPS_SERVER_ADDRESS: "https://example.com"
- SSL_MODE: "full"
- # Mount the current directory to /var/www/html
- volumes:
- - .:/var/www/html
- - config:/config
- - data:/data
-
- volumes:
- config:
- data:
-```
-
-```php [public/index.php]
-
-```
-::
-
-#### Use FrankenPHP's Built-in Automatic HTTPS When You Want...
-- To run your application and handle SSL termination all in one container
-- A simple setup without needing zero-downtime deployments
-
-::note
-You can achieve zero-downtime deployments with FrankenPHP by placing a reverse proxy in front of the container.
-::
-
-#### Short-lived & IP-address certificates
-Let's Encrypt offers a [`shortlived` certificate profile](https://letsencrypt.org/docs/profiles/){target="_blank"} that issues ~6-day certificates. Because they expire so quickly, they don't rely on revocation (OCSP/CRL). This same profile is also **required** for [IP-address certificates](https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability){target="_blank"}.
-
-Enable it with the `CADDY_ACME_PROFILE` environment variable:
-
-| Variable | Expected Value | Description |
-|----------|----------------|----------------|
-| `CADDY_ACME_PROFILE` *Default: "off"* | `shortlived` | Select a Let's Encrypt certificate profile. Also accepts `tlsserver` and `classic`. |
-
-::warning
-Enabling a profile configures Caddy's `cert_issuer`, which pins issuance to **Let's Encrypt only** (the default ZeroSSL fallback is dropped). Short-lived certificates also renew roughly every 2 days, so the container needs reliable egress to the ACME CA. Leave it `off` unless you specifically want this behavior.
-::
-
-**Raw-IP / SNI-less access:** clients connecting by IP address send no SNI, so Caddy needs a [`default_sni`](https://caddyserver.com/docs/caddyfile/options#default-sni){target="_blank"} to know which certificate to serve. There's no dedicated variable for this because the value is your own domain/IP — set it through `CADDY_GLOBAL_OPTIONS`:
-
-```yml
-environment:
- CADDY_AUTO_HTTPS: "on"
- CADDY_ACME_PROFILE: "shortlived"
- # Fallback identity for connections that send no SNI (e.g. by IP).
- # Reference your own variable if you like: "default_sni {$APP_DOMAIN}"
- CADDY_GLOBAL_OPTIONS: "default_sni example.com"
-```
-
-### Bringing Your Own Certificate
-If automatic HTTPS isn't an option, you can provide your own certificate from a vendor like [ssls.com](https://www.ssls.com/){target="_blank"}. Ensure your certificate issuer provides certificates compatible with your web server in [PEM format](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail){target="_blank"}.
-
-To add your own certificate, mount the certificate files to the container:
-
-::tip
-Set your private key file permissions to `600` (read/write for owner only). Incorrect permissions will cause errors when loading the private key.
-::
-
-::code-tree{defaultValue="compose.yml"}
-
-```yml [compose.yml]{8-10,13}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - 80:8080
- - 443:8443
- environment:
- SSL_MODE: "mixed"
- SSL_PRIVATE_KEY_FILE: "/etc/ssl/custom/test-key.pem"
- SSL_CERTIFICATE_FILE: "/etc/ssl/custom/test.pem"
- volumes:
- - .:/var/www/html/
- - ./certs/:/etc/ssl/custom/
-```
-
-```php [public/index.php]
-
-```
-
-```pem [certs/test-key.pem]
------BEGIN PRIVATE KEY-----
-EXAMPLE_PRIVATE_KEY_DO_NOT_USE
-MIIEvQIBADANBgkqhkiG9w0BAQEFASCBKwggSjAgEAAoIBAQDExampleKeyData
-ThisIsNotARealPrivateKeyAndShouldNotBeUsedInProduction123456789
-ReplaceThisWithYourActualPrivateKeyFile
------END PRIVATE KEY-----
-```
-
-```pem [certs/test.pem]
------BEGIN CERTIFICATE-----
-EXAMPLE_CERTIFICATE_DO_NOT_USE
-MIIEIDCCAwigAwIBAgIQCqH+3yBp80lQ9OVmbNmbRzANBgkqhkiG9w0BAQsFADBh
-MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
-d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
-QTAeFw0yMTA0MjkwMDAwMDBaFw0zMjA0MjgyMzU5NTlaMFsxCzAJBgNVBAYTAlVT
-MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
-b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
-9w0BAQEFAAOCAQ8AMIIBCgKCAQEAumQB+ILtbVLaKTeQeGviJLbfBxMIRZACMpbs
-QFmylhSTSSpLc1bNPrRVWWVmv+Lt8i3HuLjPQF+3M2NzBWVYB7Gixgd13KZBquor
-2W4Sj5SfR2onVzULfBy6SrwxfSTnnykA1NAzGLbGSukNkY4fO7N4V3C1mLGvL8H
------END CERTIFICATE-----
-```
-::
-
-#### Use Your Own Certificates When You...
-- Cannot use Let's Encrypt (corporate policy, network restrictions, etc.)
-- Have a specific certificate vendor requirement
-- Don't need zero-downtime deployments
-
-::note
-You can also bring your own certificate and configure it with a reverse proxy to get zero-downtime deployments.
-::
-
-### Self-Signed Certificate
-::warning
-Self-signed certificates will display warnings in the browser.
-::
-
-While browsers will show warnings, self-signed certificates are useful for specific use cases, such as encrypting traffic between containers in a cluster. If you set `SSL_MODE` to `mixed` or `full` without providing a certificate at `$SSL_CERTIFICATE_FILE` and `$SSL_PRIVATE_KEY_FILE`, a self-signed certificate will be automatically generated.
-
-```yml [compose.yml]{7-9}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - 80:8080
- - 443:8443
- environment:
- # Set SSL mode to "mixed" (HTTP + HTTPS)
- SSL_MODE: "mixed"
- volumes:
- - .:/var/www/html
-```
-
-The above will generate a self-signed certificate and configure the server to listen on both HTTP (port 80) and HTTPS (port 443).
-
-#### Use a Self-Signed Certificate When You...
-- Have a reverse proxy in front of the container handling SSL termination
-- Need all traffic to be encrypted (even on the internal network between containers)
-
-::note
-If you have a reverse proxy in front of the container handling SSL termination, you don't need to use `SSL_MODE` at all. You can configure your reverse proxy to communicate with your PHP container via HTTP (port 8080), eliminating the need to configure SSL within the container.
-::
\ No newline at end of file
diff --git a/docs/content/docs/4.deployment-and-production/4.configuring-trusted-proxies.md b/docs/content/docs/4.deployment-and-production/4.configuring-trusted-proxies.md
new file mode 100644
index 000000000..3146eced1
--- /dev/null
+++ b/docs/content/docs/4.deployment-and-production/4.configuring-trusted-proxies.md
@@ -0,0 +1,150 @@
+---
+head.title: 'Configuring trusted proxies - Docker PHP - Server Side Up'
+title: Configuring trusted proxies
+description: 'Get the visitor''s real IP address when your app runs behind a CDN, load balancer, or reverse proxy.'
+layout: docs
+---
+
+::lead-p
+Behind a CDN, load balancer, or reverse proxy, the web server sees the proxy's IP address instead of the visitor's. `TRUSTED_PROXY` tells NGINX, Apache, or FrankenPHP which proxies to trust and which header holds the real client IP, so your logs, rate limits, and IP rules use the right address.
+::
+
+## Choose a `TRUSTED_PROXY` value
+Set `TRUSTED_PROXY` on the `fpm-nginx`, `fpm-apache`, or `frankenphp` image:
+
+```yaml [compose.yml]{8}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - 80:8080
+ environment:
+ # cloudflare (default), sucuri, local, or off
+ TRUSTED_PROXY: "local"
+```
+
+| Value | Trusts | Reads the client IP from |
+|-------|--------|--------------------------|
+| `cloudflare` (default) | [Cloudflare's IP ranges](https://www.cloudflare.com/ips/){target="_blank"} and private networks | `CF-Connecting-IP` (FrankenPHP falls back to `X-Forwarded-For`) |
+| `sucuri` | [Sucuri's IP ranges](https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/){target="_blank"} and private networks | `X-Forwarded-For` |
+| `local` | Private networks only | `X-Forwarded-For` |
+| `off` | Nothing. The web server logs the address that connected to it. | |
+
+"Private networks" means `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, and `fd00::/8`, which covers Docker networks. Every option except `off` includes them. If you run Traefik or Caddy in Docker behind Cloudflare, `cloudflare` already covers both.
+
+Use `local` when your own reverse proxy is the only thing in front of the container. `cloudflare` won't work there. NGINX and Apache only read `CF-Connecting-IP`, which doesn't exist without Cloudflare. They keep the proxy's IP.
+
+The `cloudflare` option reads `CF-Connecting-IP` because Cloudflare sets it on every request and proxies like Traefik pass it through unchanged. Many proxies rewrite `X-Forwarded-For` unless you configure them to trust Cloudflare's ranges.
+
+## What Laravel still needs
+The web server passes the visitor's IP to PHP as `REMOTE_ADDR`. That makes `request()->ip()` correct with no Laravel configuration.
+
+When your proxy terminates HTTPS, Laravel also needs to know the original scheme so it generates `https://` URLs. Trust only the `X-Forwarded-Proto` header in Laravel's [trusted proxy configuration](https://laravel.com/docs/requests#configuring-trusted-proxies){target="_blank"}:
+
+```php [bootstrap/app.php]
+use Illuminate\Http\Request;
+
+->withMiddleware(function (Middleware $middleware) {
+ $middleware->trustProxies(
+ at: '*',
+ headers: Request::HEADER_X_FORWARDED_PROTO,
+ );
+})
+```
+
+::caution
+`at: '*'` trusts whatever address connected to PHP, which is the visitor once the web server has replaced `REMOTE_ADDR`. Use it only when visitors can't reach the container directly and every request passes through your proxy. Don't add `HEADER_X_FORWARDED_FOR`. Cloudflare appends to `X-Forwarded-For` instead of replacing it. Laravel would then return an address the visitor wrote into that header.
+::
+
+## Keep the proxy chain closed
+A trusted proxy configuration only protects you if traffic can't skip the proxy:
+
+- **Accept traffic only from your CDN.** A client that connects straight to your reverse proxy can send a forged `CF-Connecting-IP` header, and the proxy passes it along. Allow only your CDN's IP ranges at the edge, for example with a firewall rule or Traefik's `ipAllowList` middleware.
+- **Match the option to your setup.** Don't use `cloudflare` or `sucuri` unless traffic comes through that service.
+- **Expect FrankenPHP to resolve differently.** FrankenPHP uses Caddy's `trusted_proxies_strict` mode, which reads `X-Forwarded-For` from right to left and stops at the first untrusted address. When every address in the chain is trusted (a client on a private network behind a private proxy), FrankenPHP reports the proxy's address, while NGINX and Apache report the leftmost address.
+
+CDN IP ranges change. The ranges are part of the image, so pull new images regularly. If you depend on them, compare them with your provider's published list.
+
+## Add your own provider
+For a provider that isn't built in, such as AWS ALB or Fastly, add a file to the image and set `TRUSTED_PROXY` to its name without the extension:
+
+| Variation | File |
+|-----------|------|
+| `fpm-nginx` | `/etc/nginx/trusted-proxy/.conf` |
+| `fpm-apache` | `/etc/apache2/trusted-proxy/.conf` |
+| `frankenphp` | `/etc/frankenphp/trusted-proxy/.caddyfile` |
+
+Include the private network ranges so container-to-container traffic still resolves. This example uses `3.0.0.0/8` as a stand-in; replace it with your provider's published ranges.
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+COPY --chmod=644 docker/trusted-proxy/nginx/aws-alb.conf /etc/nginx/trusted-proxy/aws-alb.conf
+```
+
+```yaml [compose.yml]
+services:
+ php:
+ build: .
+ ports:
+ - 80:8080
+ environment:
+ TRUSTED_PROXY: "aws-alb"
+```
+
+```nginx [docker/trusted-proxy/nginx/aws-alb.conf]
+# Private networks and loopback
+set_real_ip_from 10.0.0.0/8;
+set_real_ip_from 172.16.0.0/12;
+set_real_ip_from 192.168.0.0/16;
+set_real_ip_from 127.0.0.0/8;
+set_real_ip_from ::1;
+set_real_ip_from fd00::/8;
+
+# Your provider's ranges
+set_real_ip_from 3.0.0.0/8;
+
+real_ip_header X-Forwarded-For;
+real_ip_recursive on;
+```
+
+```apache [docker/trusted-proxy/apache/aws-alb.conf]
+RemoteIPHeader X-Forwarded-For
+
+# Private ranges use InternalProxy so mod_remoteip resolves through Docker network hops
+RemoteIPInternalProxy 10.0.0.0/8
+RemoteIPInternalProxy 172.16.0.0/12
+RemoteIPInternalProxy 192.168.0.0/16
+RemoteIPInternalProxy 127.0.0.0/8
+RemoteIPInternalProxy ::1
+RemoteIPInternalProxy fd00::/8
+
+# Your provider's ranges
+RemoteIPTrustedProxy 3.0.0.0/8
+```
+
+```caddyfile [docker/trusted-proxy/aws-alb.caddyfile]
+servers {
+ trusted_proxies static \
+10.0.0.0/8 \
+172.16.0.0/12 \
+192.168.0.0/16 \
+127.0.0.0/8 \
+::1 \
+fd00::/8 \
+3.0.0.0/8
+
+ trusted_proxies_strict
+ client_ip_headers X-Forwarded-For
+}
+```
+::
+
+For Apache or FrankenPHP, copy the matching file to the path in the table above. To try a file without rebuilding, mount it read-only at the same path instead of using `COPY`.
+
+## Still seeing the proxy's IP
+1. If the container's access log shows your proxy's address, that address isn't in the ranges for the option you chose. Pick another option, or add a file for your provider as shown [above](#add-your-own-provider).
+2. Check the header. Cloudflare sends `CF-Connecting-IP`; most other proxies send `X-Forwarded-For`.
+3. If the access log is right but `request()->ip()` is wrong, check that Laravel isn't trusting `X-Forwarded-For` (see [what Laravel still needs](#what-laravel-still-needs)).
diff --git a/docs/content/docs/4.deployment-and-production/5.choosing-a-host.md b/docs/content/docs/4.deployment-and-production/5.choosing-a-host.md
deleted file mode 100644
index b0ac136d2..000000000
--- a/docs/content/docs/4.deployment-and-production/5.choosing-a-host.md
+++ /dev/null
@@ -1,158 +0,0 @@
----
-title: Choosing a Host
-description: 'Learn how to choose the right hosting provider for your containerized PHP application.'
-layout: docs
----
-
-::lead-p
-One of the biggest advantages of containerizing your application is the ability to run your application anywhere. This gives you the freedom to choose the best host for your needs. Here's a guide to help you choose the right host for your needs.
-::
-
-## The beauty of portability
-Because your application runs in a container, you're no longer tied to a specific hosting provider. The same container that runs on your laptop will run on:
-
-- Any VPS provider (DigitalOcean, Hetzner, Vultr, Linode, etc.)
-- Cloud platforms (AWS, Google Cloud, Azure)
-- Managed Kubernetes services
-- Your own hardware in a data center
-- Even a Raspberry Pi in your closet
-
-This portability means:
-- **No vendor lock-in** - Switch hosts without rebuilding your application
-- **Price shopping** - Move to cheaper providers if prices increase
-- **Multi-cloud strategies** - Run on multiple providers for redundancy
-- **Freedom to experiment** - Try different hosts without risk
-
-## Hosting options
-
-Choose your hosting based on your team's experience, budget, and application scale. Here's a quick comparison:
-
-| Hosting Type | Best For | Monthly Cost | Management Level | When to Choose |
-|-------------|----------|--------------|------------------|----------------|
-| **VPS** | Most PHP apps | $5-20 | Self-managed | You want control and best value |
-| **Managed Kubernetes** | Enterprise/high-traffic | $70-100+ | Moderate | You need advanced orchestration |
-| **Cloud Platforms** | Variable workloads | $20-50+ | Low | You want integrated cloud services |
-| **PaaS** | Quick deployment | $25-100+ | Minimal | You want zero infrastructure hassle |
-
-::tip
-**Our recommendation:** Start with a VPS unless you have specific needs. A $10/month VPS can handle what costs $50-100/month on PaaS, and you'll have full control over your infrastructure.
-::
-
-## Recommended Hosts
-Below are our recommended hosts from our experience. Some links may contain referral codes, but these codes give you free credits to help you get started. These referral programs do not influence our recommendations.
-
-### VPS Hosts
-::card-group
-
-::card
----
-title: Hetzner
-icon: i-hosts-hetzner-square
-to: https://hetzner.cloud/?ref=lhLUIrkdUPhl
-target: _blank
----
-Hetzner is infamously known for its low prices and high performance. They are a great choice for small to medium-sized applications.
-::
-
-::card
----
-title: Vultr
-icon: i-hosts-vultr-square
-to: https://www.vultr.com/?ref=7093917
-target: _blank
----
-Vultr has great performance and and over 32 global data centers.
-::
-
-::card
----
-title: DigitalOcean
-icon: i-hosts-digitalocean-square
-to: https://m.do.co/c/f3bad4b927ca
-target: _blank
----
-DigitalOcean is most known for it's simple user experience and vast support of many developer tools.
-::
-
-::
-
-### PaaS Hosts
-::card-group
-
-::card
----
-title: Sevalla
-icon: i-hosts-sevalla-square
-to: https://sevalla.com/
-target: _blank
----
-Sevalla is a modern PaaS with transparent usage-based pricing and no artificial limits. **Full disclosure:** Sevalla sponsors this project, but we genuinely recommend them for their refreshingly honest approach to PaaS pricing.
-::
-
-::
-
-## Simplifying VPS setup with Spin
-::tip{to="https://serversideup.net/open-source/spin/" target="_blank"}
-[Spin](https://serversideup.net/open-source/spin/) is a free and open source tool that simplifies Docker workflows from development to production.
-::
-
-
-Spin can automatically provision and configure servers on Hetzner, DigitalOcean, and Vultr, setting up Docker Swarm for zero-downtime deployments. It handles the infrastructure complexity while you maintain full control of your server. Here's a quick example with Laravel:
-
-:iframe{src="https://www.youtube-nocookie.com/embed/5z2JoEt5XIk?si=u5v-bDN-cMv0OE-C" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen style="aspect-ratio: 16/9; width: 100%;"}
-
-Spin is *not required* to run these Docker images, but it can help simplify the setup process. Spin was designed to have native integration with these images, but it can be used with any Docker setup.
-
-:u-button{to="https://serversideup.net/open-source/spin/" label="Learn more about Spin" aria-label="Learn more about Spin" target="_blank" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## What to consider when choosing a host
-
-- **Budget** - VPS offers best value ($10-20/mo), PaaS most expensive ($25-100+/mo)
-- **Technical expertise** - VPS requires Linux knowledge, PaaS abstracts complexity
-- **Scale** - Small/medium apps thrive on VPS, very large apps benefit from Kubernetes
-- **Time investment** - PaaS saves time but costs more, VPS costs less but needs more hands-on
-- **Data sovereignty** - Choose providers with data centers in your required regions
-- **Growth trajectory** - VPS and Kubernetes scale cost-effectively, PaaS gets expensive
-
-#### Specifically for PaaS providers
-- **Pricing** - Is the pricing transparent and upfront? Are there alerts if you're approaching your limits?
-- **Debugging** - Are you able to access logs easily and debug issues?
-- **Vendor lock-in** - Are you building around a proprietary platform, or can you easily migrate to another provider if needed?
-
-## Our recommendation for most projects
-
-If you're running a typical PHP application (Laravel, WordPress, custom app), here's our suggested progression:
-
-::steps{level="4"}
-
-#### Starting out: VPS provider like Hetzner or DigitalOcean ($5-10/month)
-- Use Docker Swarm for single-server deployment
-- Simple, affordable, and plenty of room to grow
-
-#### Growing: Performance tuning and hardware upgrades ($20-40/month)
-- Focus on fine tuning your application for performance
-- Upgrade your server hardware if needed (simple as a few clicks)
-
-#### At scale: Managed Kubernetes or multiple VPS servers (>$100/month)
-- Move to Kubernetes when you need sophisticated orchestration
-- Only consider if you *must* exceed [99.999% of uptime](https://uptime.is/99.999){target="_blank"}
-
-::
-
-Most PHP applications never need to move beyond a well-configured VPS. A $20/month server can serve thousands of requests per second when properly optimized.
-
-::note
-Don't let fancy infrastructure distract you from building your application. Pick what works for you. Start simple, prove your concept, then scale up as needed.
-::
-
-## Key requirements for any host
-Regardless of which provider you choose, ensure they support:
-
-- **Docker installation** - Ability to install and run Docker (most Linux VPS hosts support this)
-- **Adequate resources** - At minimum 1GB RAM, 1 CPU core (2GB+ recommended for most applications)
-- **Security** - Strong security policies and practices to protect your application and data
-- **Performance** - Good performance and quality hardware to handle your application's traffic
-- **Network reliability** - Good uptime SLA and network performance
-- **Responsive support team** - Quality and responsive support team to help you out when you need it
-- **Data center location** - Close to your users for better performance
-- **Backup options** - Either provided by host or easily implemented yourself (and we strongly recommend the [3-2-1 backup rule](https://en.wikipedia.org/wiki/Backup){target="_blank"})
diff --git a/docs/content/docs/4.deployment-and-production/5.production-performance-tuning.md b/docs/content/docs/4.deployment-and-production/5.production-performance-tuning.md
new file mode 100644
index 000000000..35c5f4b97
--- /dev/null
+++ b/docs/content/docs/4.deployment-and-production/5.production-performance-tuning.md
@@ -0,0 +1,173 @@
+---
+head.title: 'Production performance tuning - Docker PHP - Server Side Up'
+title: Production performance tuning
+description: 'Turn on OPcache, size it for your app, and tune PHP-FPM workers for production.'
+layout: docs
+---
+
+::lead-p
+This page covers the settings that change PHP performance in production: OPcache, the realpath cache, and PHP-FPM workers. It explains where the defaults come from and how to measure your app before you change them.
+::
+
+## PHP OPcache
+OPcache stores compiled PHP bytecode in shared memory. Requests skip reading and parsing your PHP files ([PHP manual](https://www.php.net/manual/en/book.opcache.php){target="_blank"}). It also means code changes don't show up until the cache is cleared, which is why it is off by default.
+
+### Turn it on in production
+
+| Setting | What happens |
+|---------|--------------|
+| `PHP_OPCACHE_ENABLE=0` (default) | OPcache is off. Every request reads your files, so edits to mounted code show up right away. |
+| `PHP_OPCACHE_ENABLE=1` | OPcache is on with the defaults below. Files are compiled once and cached until the container restarts. |
+
+Turn it on anywhere you aren't editing code in place, such as production and staging:
+
+```yaml [compose.yml]{5}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ environment:
+ PHP_OPCACHE_ENABLE: "1"
+```
+
+::note
+`PHP_OPCACHE_ENABLE_CLI=1` is the default, so CLI commands like `php artisan` also use OPcache. Each CLI process gets its own cache that ends when the process exits. Set `PHP_OPCACHE_ENABLE_CLI=0` to use OPcache for the web server only.
+::
+
+### Defaults
+These apply once `PHP_OPCACHE_ENABLE=1`. Override any of them with the same variable. The [environment variable specification](/docs/reference/environment-variable-specification) lists the rest.
+
+| Variable | Default | What it controls |
+|----------|---------|------------------|
+| `PHP_OPCACHE_VALIDATE_TIMESTAMPS` | `0` | Whether OPcache checks files for changes. `0` caches files until the container restarts. |
+| `PHP_OPCACHE_REVALIDATE_FREQ` | `2` | Seconds between checks. Used only when `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`. |
+| `PHP_OPCACHE_MEMORY_CONSUMPTION` | `256` | Size of the shared memory segment, in megabytes. |
+| `PHP_OPCACHE_INTERNED_STRINGS_BUFFER` | `32` | Megabytes inside that segment for interned strings. |
+| `PHP_OPCACHE_MAX_ACCELERATED_FILES` | `32531` | Maximum number of cached files. |
+| `PHP_OPCACHE_JIT` | `off` | The JIT compiler. See [JIT](#jit). |
+| `PHP_OPCACHE_JIT_BUFFER_SIZE` | `0` | Memory for JIT code. |
+| `PHP_OPCACHE_PRELOAD` | `""` | Path to a preload script. See [Preloading](#preloading). |
+| `PHP_OPCACHE_PRELOAD_USER` | `""` | User that runs the preload script. Needed only when running as root. |
+
+The memory size, file count, interned strings buffer, and `validate_timestamps=0` come from [Symfony's performance guide](https://symfony.com/doc/current/performance.html#configure-opcache-for-maximum-performance){target="_blank"}, which [FrankenPHP's performance guide](https://frankenphp.dev/docs/performance/){target="_blank"} recommends for any framework. [Laravel Forge's OPcache option](https://laravel.com/forge/docs/servers/php#opcache){target="_blank"} makes the same tradeoff: reload PHP after every deployment.
+
+`PHP_OPCACHE_VALIDATE_TIMESTAMPS=0` matters most. When your code is built into the image, files never change between deployments. Checking them is wasted work.
+
+### Memory
+OPcache reserves one shared memory segment of `PHP_OPCACHE_MEMORY_CONSUMPTION` megabytes when PHP starts. The interned strings buffer lives inside it. With the defaults, 32 MB of the 256 MB holds interned strings and 224 MB holds compiled code. The JIT buffer is added on top of the segment ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.memory-consumption){target="_blank"}).
+
+Reserved memory isn't used memory. The container only pays for pages that are written. Every PHP-FPM worker shares the same segment.
+
+### Measure the cache
+Put this file in your public directory and request it through the web server. The CLI has its own cache. Running the file with `php` tells you nothing about the web server.
+
+```php [public/opcache-status.php]
+ round($bytes / 1048576) . ' MB';
+$files = "{$stats['num_cached_keys']} of {$stats['max_cached_keys']}";
+
+$report = [
+ 'Cache full' => $status['cache_full'] ? 'yes' : 'no',
+ 'Cached files' => $files,
+ 'Memory used' => $toMegabytes($memory['used_memory']),
+ 'Memory free' => $toMegabytes($memory['free_memory']),
+ 'Interned strings free' => $toMegabytes($strings['free_memory']),
+ 'Out of memory restarts' => $stats['oom_restarts'],
+ 'Hash restarts' => $stats['hash_restarts'],
+];
+
+foreach ($report as $label => $value) {
+ echo "$label: $value\n";
+}
+```
+
+::caution
+This file exposes cache and memory statistics. Remove or protect it before you deploy.
+::
+
+For an upper bound on the file count, count the PHP files in your image with `find /var/www/html -name '*.php' | wc -l`. Fill the cache with your smoke tests or a [preload script](#preloading) before you read the memory numbers. Then:
+
+- **Cache full** is `yes` or **out of memory restarts** keeps rising: raise `PHP_OPCACHE_MEMORY_CONSUMPTION`.
+- **Cached files** is close to the maximum or **hash restarts** keeps rising: raise `PHP_OPCACHE_MAX_ACCELERATED_FILES`.
+- **Interned strings free** is near zero: raise `PHP_OPCACHE_INTERNED_STRINGS_BUFFER`.
+
+Restarts are what you want to avoid. Each one clears the whole cache, and requests are slower until it fills again.
+
+### Start a new container for every deployment
+With timestamps off, PHP only sees new files after `opcache_reset()`, `opcache_invalidate()`, or a restart ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.validate-timestamps){target="_blank"}). Deploy a new container instead of changing files in a running one.
+
+- **Running `php artisan optimize` in a live container** writes new cache files, but the web workers keep serving the old compiled copies. Restart the container instead.
+- **`opcache_reset()` from the CLI doesn't clear the web server's cache.** The CLI has its own cache in its own process.
+- **Mounting code with OPcache on** means your edits won't show up. Leave OPcache off in development, or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1` to check for changes every `PHP_OPCACHE_REVALIDATE_FREQ` seconds, as Version 4 did.
+
+### Laravel
+With [`AUTORUN_ENABLED=true`](/docs/framework-guides/laravel/automations), the container runs `php artisan optimize` before the web server starts. That caches config, routes, events, and views, including the compiled Blade views ([Laravel docs](https://laravel.com/docs/13.x/deployment#optimizing-view-loading){target="_blank"}), so they are on disk before OPcache reads them. Automations are off by default; without them, run `php artisan optimize` yourself when the container starts.
+
+Queue workers, Horizon, the scheduler, Reverb, and Octane on Swoole or RoadRunner are CLI processes, so `PHP_OPCACHE_ENABLE_CLI` applies to them. Laravel says to restart long-running processes after a deploy ([Laravel docs](https://laravel.com/docs/13.x/deployment#reloading-services){target="_blank"}). A new container does that.
+
+### WordPress
+WordPress clears OPcache for the files it writes during core, plugin, and theme updates from the admin, using [`wp_opcache_invalidate()`](https://developer.wordpress.org/reference/functions/wp_opcache_invalidate/){target="_blank"}. Files changed any other way (`git pull`, WP-CLI, SFTP, or plugins that write PHP files) aren't picked up until the container restarts. If you use the [volume-based approach](/docs/framework-guides/wordpress/using-wordpress-with-docker), restart after those changes or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`.
+
+### Preloading
+Preloading compiles a set of files into OPcache when PHP starts and keeps them there until PHP stops ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.preload){target="_blank"}). Requests skip the autoloader for those classes. [Symfony generates a preload file](https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading){target="_blank"} at `config/preload.php`. Laravel doesn't ship one.
+
+```yaml [compose.yml]{6}
+services:
+ php:
+ image: my-app:1.0.0
+ environment:
+ PHP_OPCACHE_ENABLE: "1"
+ PHP_OPCACHE_PRELOAD: "/var/www/html/config/preload.php"
+```
+
+- **The file must exist.** PHP won't start if the preload script is missing or throws.
+- **The CLI runs it too.** With `PHP_OPCACHE_ENABLE_CLI=1`, every `php` command preloads first, so the script must work outside a web request. Set `PHP_OPCACHE_PRELOAD` on the running service, not as `ENV` in your Dockerfile, so build steps like `RUN composer install` don't depend on it.
+- **Running as root needs `PHP_OPCACHE_PRELOAD_USER`.** The images run as `www-data`, so this only applies if you change the user. The CLI is exempt on PHP 8.3 and newer ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.preload-user){target="_blank"}).
+- **Preloaded files never change.** They stay until restart, even with `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`.
+
+### JIT
+The JIT compiler is off by default, as it is in PHP. The [PHP JIT RFC](https://wiki.php.net/rfc/jit){target="_blank"} found little gain for web apps like WordPress and large gains for CPU-heavy, non-web work. Xdebug disables the JIT when it is loaded ([Xdebug docs](https://xdebug.org/docs/compat){target="_blank"}).
+
+To enable it, set both variables. The buffer is added on top of `PHP_OPCACHE_MEMORY_CONSUMPTION`. With the defaults, `64M` makes a 320 MB segment:
+
+```yaml [compose.yml]{6-7}
+services:
+ php:
+ image: my-app:1.0.0
+ environment:
+ PHP_OPCACHE_ENABLE: "1"
+ PHP_OPCACHE_JIT: "tracing"
+ PHP_OPCACHE_JIT_BUFFER_SIZE: "64M"
+```
+
+Measure response times before and after. If they don't change, leave it off.
+
+### Settings without a variable
+Set any other OPcache option in a [custom ini file](/docs/customizing-the-image/changing-common-php-settings). Two you may want:
+
+- `opcache.huge_code_pages=1` copies compiled code into huge pages. It needs OS support ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.huge_code_pages){target="_blank"}).
+- `opcache.file_cache=/path` adds a cache on disk that survives a PHP restart ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.file-cache){target="_blank"}).
+
+## PHP realpath cache
+PHP caches the resolved path of every file it opens. [Symfony recommends](https://symfony.com/doc/current/performance.html#configure-the-php-realpath-cache){target="_blank"} at least `realpath_cache_size=4096K` and `realpath_cache_ttl=600` for apps that open many files. Those are the defaults for `PHP_REALPATH_CACHE_SIZE` and `PHP_REALPATH_CACHE_TTL`. PHP disables this cache when `open_basedir` is set. Leave `PHP_OPEN_BASEDIR` empty unless you need it.
+
+## PHP-FPM workers
+The `fpm`, `fpm-nginx`, and `fpm-apache` images run PHP-FPM, which handles each request in a worker process. The defaults suit a small container:
+
+| Variable | Default | What it controls |
+|----------|---------|------------------|
+| `PHP_FPM_PM_CONTROL` | `ondemand`, or `dynamic` in `fpm` | How workers start: `ondemand` (only when requests arrive), `dynamic`, or `static` |
+| `PHP_FPM_PM_MAX_CHILDREN` | `20` | Most workers running at once, which is the most requests handled at once |
+| `PHP_FPM_PM_START_SERVERS` | `2` | Workers started with `dynamic` |
+| `PHP_FPM_PM_MIN_SPARE_SERVERS` | `1` | Fewest idle workers kept with `dynamic` |
+| `PHP_FPM_PM_MAX_SPARE_SERVERS` | `3` | Most idle workers kept with `dynamic` |
+| `PHP_FPM_PM_MAX_REQUESTS` | `0` | Requests before a worker restarts. `0` never restarts it. |
+
+Set `PHP_FPM_PM_MAX_CHILDREN` from memory: divide the memory you can give PHP by the memory one worker uses under load. Each worker can grow to `PHP_MEMORY_LIMIT` (`256M` by default). Twenty workers can use far more memory than a small server has. Watch per-process memory with `docker stats` or `ps` while you run a load test, then size the pool so it fits.
+
+If a request queue builds up while CPU is idle, raise `PHP_FPM_PM_MAX_CHILDREN`. If the container runs out of memory, lower it. Set `PHP_FPM_PM_MAX_REQUESTS` to a value such as `500` if worker memory grows over time from a leak.
diff --git a/docs/content/docs/4.deployment-and-production/6.choosing-a-host.md b/docs/content/docs/4.deployment-and-production/6.choosing-a-host.md
new file mode 100644
index 000000000..cd550532f
--- /dev/null
+++ b/docs/content/docs/4.deployment-and-production/6.choosing-a-host.md
@@ -0,0 +1,87 @@
+---
+head.title: 'Choosing a host - Docker PHP - Server Side Up'
+title: Choosing a host
+description: 'What to look for in a host for your PHP containers, and the hosts we use.'
+layout: docs
+---
+
+::lead-p
+Your image runs on any host that runs Docker, so you can choose on price, location, and how much of the server you want to manage, and move later without rebuilding your app.
+::
+
+## Types of hosting
+
+| Type | You manage | Good fit |
+|------|------------|----------|
+| VPS | The server, Docker, updates, and backups | Most PHP apps, on one server or a few |
+| PaaS | Only your app | Teams that don't want to manage servers |
+| Managed Kubernetes | Your cluster configuration | Apps that need many servers and automatic failover |
+
+A VPS costs the least per CPU and gigabyte of memory, but you are responsible for the server. A PaaS costs more and runs your containers for you. Start with the simplest option that fits: one VPS, then Docker Swarm across a few servers as you grow, and Kubernetes only when you need many servers. Your image runs the same on all three.
+
+## What to look for
+- **Docker support.** Any Linux VPS can run Docker. On a PaaS, check that it runs your own image and lets you set environment variables and health checks.
+- **Resources.** Plan on at least 1 GB of memory and one CPU, and 2 GB or more for most production apps. Leave room for PHP-FPM workers: each one can use up to `PHP_MEMORY_LIMIT` (`256M` by default). See [PHP-FPM workers](/docs/deployment-and-production/production-performance-tuning#php-fpm-workers).
+- **Data center location.** Close to your users, and in the regions your data must stay in.
+- **Backups.** Provided by the host or easy to run yourself. Keep at least one copy off the host.
+- **Pricing you can predict.** Usage-based billing should come with alerts before you hit a limit.
+- **Logs and access.** You should be able to read container logs and get a shell when something breaks.
+- **No lock-in.** Prefer hosts that run standard images, so you can move.
+
+## Hosts we use
+Some links include a referral code that gives you starting credit.
+
+### VPS
+::card-group
+
+::card
+---
+title: Hetzner
+icon: i-hosts-hetzner-square
+to: https://hetzner.cloud/?ref=lhLUIrkdUPhl
+target: _blank
+---
+Low-cost VPS servers in Europe, the US, and Singapore.
+::
+
+::card
+---
+title: Vultr
+icon: i-hosts-vultr-square
+to: https://www.vultr.com/?ref=7093917
+target: _blank
+---
+VPS servers in data centers around the world.
+::
+
+::card
+---
+title: DigitalOcean
+icon: i-hosts-digitalocean-square
+to: https://m.do.co/c/f3bad4b927ca
+target: _blank
+---
+VPS servers with managed databases, load balancers, and object storage.
+::
+
+::
+
+### PaaS
+::card-group
+
+::card
+---
+title: Sevalla
+icon: i-hosts-sevalla-square
+to: https://sevalla.com/
+target: _blank
+---
+A PaaS with usage-based pricing. Sevalla sponsors this project.
+::
+
+::
+
+## Set up a VPS with Spin
+[Spin](https://serversideup.net/open-source/spin/){target="_blank"} is a free, open source tool from the same maintainers. It provisions servers on Hetzner, DigitalOcean, and Vultr and sets up Docker Swarm for zero-downtime deployments with these images. You don't need Spin to use the images.
+
+:youtube-video{id="5z2JoEt5XIk" title="Deploy Laravel Like a Pro (Without Being a DevOps Expert)"}
diff --git a/docs/content/docs/4.deployment-and-production/98.orchestrators/.navigation.yml b/docs/content/docs/4.deployment-and-production/98.orchestrators/.navigation.yml
deleted file mode 100644
index 13126c183..000000000
--- a/docs/content/docs/4.deployment-and-production/98.orchestrators/.navigation.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-title: Orchestrators
-icon: false
-defaultOpen: false
\ No newline at end of file
diff --git a/docs/content/docs/6.customizing-the-image/.navigation.yml b/docs/content/docs/5.customizing-the-image/.navigation.yml
similarity index 100%
rename from docs/content/docs/6.customizing-the-image/.navigation.yml
rename to docs/content/docs/5.customizing-the-image/.navigation.yml
diff --git a/docs/content/docs/5.customizing-the-image/1.changing-common-php-settings.md b/docs/content/docs/5.customizing-the-image/1.changing-common-php-settings.md
new file mode 100644
index 000000000..b1d6f4ff8
--- /dev/null
+++ b/docs/content/docs/5.customizing-the-image/1.changing-common-php-settings.md
@@ -0,0 +1,85 @@
+---
+head.title: 'Changing common PHP settings - Docker PHP - Server Side Up'
+title: Changing php.ini settings
+description: 'Change PHP settings with environment variables, or with your own ini file for settings that have no variable.'
+layout: docs
+---
+
+::lead-p
+Common PHP settings such as the memory limit, upload size, and timezone each have an environment variable. For anything else, add your own ini file to the image.
+::
+
+## Set a variable
+Set the variable on your service. The new value applies when the container starts, with no rebuild:
+
+::code-group
+
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - 80:8080
+ environment:
+ PHP_MEMORY_LIMIT: "512M"
+ PHP_POST_MAX_SIZE: "500M"
+ PHP_UPLOAD_MAX_FILE_SIZE: "500M"
+ PHP_DATE_TIMEZONE: "America/New_York"
+ volumes:
+ - .:/var/www/html
+```
+
+```bash [docker run]
+docker run --rm \
+ -p 80:8080 \
+ -v "$(pwd):/var/www/html" \
+ -e PHP_MEMORY_LIMIT=512M \
+ -e PHP_DATE_TIMEZONE=America/New_York \
+ serversideup/php:8.5-fpm-nginx
+```
+
+::
+
+Every variable and its default is in the [environment variable specification](/docs/reference/environment-variable-specification).
+
+## Add your own ini file
+PHP reads every `.ini` file in `/usr/local/etc/php/conf.d/` in alphabetical order, and a later file overrides an earlier one. The image's settings are in `serversideup-docker-php.ini`, so name your file so it sorts after that, for example `zzz-custom-php.ini`:
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+COPY zzz-custom-php.ini /usr/local/etc/php/conf.d/
+```
+
+```ini [zzz-custom-php.ini]
+mysqli.max_persistent = 300
+opcache.max_file_size = 10M
+opcache.log_verbosity_level = 3
+```
+
+::
+
+A setting in your file wins over the matching environment variable. If you set `memory_limit` in your file, `PHP_MEMORY_LIMIT` no longer changes it.
+
+To replace the image's settings entirely, remove the default file as `root`. The `PHP_*` variables for php.ini settings, such as `PHP_MEMORY_LIMIT` and `PHP_DATE_TIMEZONE`, stop working once it is gone, because that file is where they are read. The `PHP_FPM_*` variables are read from the PHP-FPM pool configuration and keep working.
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+USER root
+RUN rm /usr/local/etc/php/conf.d/serversideup-docker-php.ini
+USER www-data
+
+COPY zzz-custom-php.ini /usr/local/etc/php/conf.d/
+```
+
+## Check the result
+Print the value PHP is using:
+
+```bash [Terminal]
+docker compose exec php php -i | grep memory_limit
+```
+
+`php -i` shows the CLI's settings, which come from the same files. To see what the web server uses, open a page that calls [`phpinfo()`](https://www.php.net/manual/en/function.phpinfo.php){target="_blank"}.
diff --git a/docs/content/docs/5.customizing-the-image/2.installing-additional-php-extensions.md b/docs/content/docs/5.customizing-the-image/2.installing-additional-php-extensions.md
new file mode 100644
index 000000000..d09d96382
--- /dev/null
+++ b/docs/content/docs/5.customizing-the-image/2.installing-additional-php-extensions.md
@@ -0,0 +1,56 @@
+---
+head.title: 'Installing additional PHP extensions - Docker PHP - Server Side Up'
+title: Installing PHP extensions
+description: 'Add PHP extensions to your image with install-php-extensions.'
+layout: docs
+---
+
+::lead-p
+Every image includes [`install-php-extensions`](https://github.com/mlocati/docker-php-extension-installer){target="_blank"}, which installs an extension and the system packages it needs on both Debian and Alpine. Run it in your `Dockerfile` as `root`.
+::
+
+## Add extensions in your Dockerfile
+The images run as the unprivileged `www-data` user, so switch to `root` to install and switch back afterward:
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+USER root
+RUN install-php-extensions intl bcmath
+USER www-data
+```
+
+```yaml [compose.yml]
+services:
+ php:
+ build: .
+ ports:
+ - 80:8080
+ volumes:
+ - .:/var/www/html
+```
+
+::
+
+Build and start the container. `--build` rebuilds the image when the `Dockerfile` changes:
+
+```bash [Terminal]
+docker compose up --build
+```
+
+Check that the extensions loaded:
+
+```bash [Terminal]
+docker compose exec php php -m | grep -E 'intl|bcmath'
+```
+
+To build your code into the same image for production, see [packaging your app for deployment](/docs/deployment-and-production/packaging-your-app-for-deployment).
+
+## Which extensions you can install
+`install-php-extensions` supports most PECL and bundled extensions across PHP versions. See its [list of supported extensions](https://github.com/mlocati/docker-php-extension-installer#supported-php-extensions){target="_blank"}. The extensions already in each image are on the [default configurations](/docs/getting-started/default-configurations) page.
+
+`intl` is a common addition. It isn't included by default because of its size, but Laravel's `email` validation rule needs it for the [`dns` and `spoof` checks](https://laravel.com/docs/validation#rule-email){target="_blank"}.
+
+Can't find the extension you need? [Ask in GitHub Discussions](https://github.com/serversideup/docker-php/discussions/new?category=q-a){target="_blank"}.
diff --git a/docs/content/docs/5.customizing-the-image/3.adding-your-own-start-up-scripts.md b/docs/content/docs/5.customizing-the-image/3.adding-your-own-start-up-scripts.md
new file mode 100644
index 000000000..0284022f4
--- /dev/null
+++ b/docs/content/docs/5.customizing-the-image/3.adding-your-own-start-up-scripts.md
@@ -0,0 +1,88 @@
+---
+head.title: 'Adding your own start up scripts - Docker PHP - Server Side Up'
+title: Adding start up scripts
+description: 'Run your own shell scripts when the container starts, in a set order with the default scripts.'
+layout: docs
+---
+
+::lead-p
+Every `.sh` file in `/etc/entrypoint.d/` runs when the container starts, before PHP or the web server. Add your own script there to run a command at startup, such as an Artisan command after migrations.
+::
+
+## Add a script
+Write the script and copy it into the image:
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+COPY entrypoint.d/ /etc/entrypoint.d/
+```
+
+```sh [entrypoint.d/60-my-command.sh]
+#!/bin/sh
+if [ ! -f "$APP_BASE_DIR/artisan" ]; then
+ echo "❌ artisan not found in $APP_BASE_DIR"
+ exit 1
+fi
+
+php "$APP_BASE_DIR/artisan" my:custom-command
+```
+
+```yaml [compose.yml]
+services:
+ php:
+ build: .
+ ports:
+ - 80:8080
+ volumes:
+ - .:/var/www/html
+```
+
+::
+
+`APP_BASE_DIR` is `/var/www/html` by default. The `60-` prefix runs this script after Laravel automations, which are `50-`.
+
+## How scripts run
+The entrypoint, `docker-php-serversideup-entrypoint`, sources each script with `.` under `/bin/sh`. That has a few consequences:
+
+- **No execute bit needed.** The file only needs to be readable and end in `.sh`.
+- **The entrypoint ignores the shebang line.** A script that starts with `#!/bin/bash` still runs under `sh`. Start every script with `#!/bin/sh` anyway, because S6 needs it (see below).
+- **Write POSIX `sh`, not Bash.** Bash syntax such as `[[ ]]` or arrays fails on Debian too, not only on Alpine.
+- **`exit 0` ends only your script.** The next script runs.
+- **Any other exit code stops the container** before PHP or the web server starts.
+- **Any failing command stops the container.** Scripts inherit `set -e` from the entrypoint, so a command that returns non-zero, such as a `grep` with no match, ends the script with an error. Add `|| true` to a command that is allowed to fail.
+- **Variables don't carry over.** Each script runs in its own subshell, so a variable you set or export isn't visible to later scripts or to PHP. Set it with `ENV` or in your Compose file instead.
+- **Scripts run for every command,** including one-off commands like `docker compose run php php artisan tinker`. If your script should skip those, check `$SERVERSIDEUP_DEFAULT_COMMAND`. It is `true` only when the command is `/init`, `php-fpm`, or `frankenphp`, the default commands of the server images.
+
+If you convert the scripts to S6 services with `docker-php-serversideup-s6-init`, S6 runs them instead, in the same order. S6 executes each script rather than sourcing it, so the shebang matters: `#!/bin/sh` is rewritten to load the container's environment, and a script without one fails to start. See [using S6 Overlay](/docs/customizing-the-image/using-s6-overlay).
+
+Scripts should finish quickly. For a process that keeps running, such as a queue worker, run another container or [run it as an S6 service](/docs/customizing-the-image/using-s6-overlay).
+
+## Choose a number
+Scripts run in version-sort order (`sort -V`) of their file names, so `10-` comes after `9-`. Scripts with the same number sort by the rest of the name: `1-my-script.sh` runs after `1-log-output-level.sh`, not before it. Pick a number between the default scripts you want to run between.
+
+| Script | Images | What it does |
+|--------|--------|--------------|
+| `0-container-info.sh` | All | Prints the startup banner. Off with `SHOW_WELCOME_MESSAGE=false`. |
+| `1-log-output-level.sh` | All | Applies `LOG_OUTPUT_LEVEL=debug` to PHP and PHP-FPM. |
+| `5-fpm-pool-user.sh` | `fpm`, `fpm-nginx`, `fpm-apache` | When running as `root`, sets the user PHP-FPM workers run as. |
+| `5-generate-ssl.sh` | `fpm-nginx`, `fpm-apache`, `frankenphp` | Creates a self-signed certificate when `SSL_MODE` isn't `off`. See [configuring SSL](/docs/deployment-and-production/configuring-ssl). |
+| `10-init-webserver-config.sh` | `fpm-nginx`, `fpm-apache` | Writes the NGINX or Apache configuration from environment variables. |
+| `50-laravel-automations.sh` | All | Runs [Laravel automations](/docs/framework-guides/laravel/automations) when `AUTORUN_ENABLED=true`. |
+
+For example, `20-` runs after the web server configuration and before Laravel automations, and `60-` runs after everything.
+
+`DISABLE_DEFAULT_CONFIG=true` makes every default script exit without doing anything. Your own scripts still run.
+
+To replace a default script, copy a file with the same name into `/etc/entrypoint.d/`.
+
+## Check that it ran
+Start the container and look for your script's output in the logs:
+
+```bash [Terminal]
+docker compose up --build
+```
+
+Set `LOG_OUTPUT_LEVEL=debug` to log each script's name and exit code as it runs.
diff --git a/docs/content/docs/5.customizing-the-image/4.using-s6-overlay.md b/docs/content/docs/5.customizing-the-image/4.using-s6-overlay.md
new file mode 100644
index 000000000..566261b58
--- /dev/null
+++ b/docs/content/docs/5.customizing-the-image/4.using-s6-overlay.md
@@ -0,0 +1,119 @@
+---
+head.title: 'Using S6 Overlay - Docker PHP - Server Side Up'
+title: Using S6 Overlay
+description: 'How the fpm-nginx and fpm-apache images use S6 Overlay, and how to add your own long-running service.'
+layout: docs
+---
+
+::lead-p
+The `fpm-nginx` and `fpm-apache` images use [S6 Overlay](https://github.com/just-containers/s6-overlay){target="_blank"} to run PHP-FPM and the web server in one container. You can add your own long-running process, such as a worker, as another S6 service.
+::
+
+## Why these images run two processes
+NGINX and Apache serve static files and pass PHP requests to PHP-FPM. A PHP request needs both processes running. S6 Overlay is a process supervisor built for containers. It starts PHP-FPM and waits until it is ready before it starts the web server. It restarts either one if it crashes. If a service fails to start, S6 stops the container. The image's health check requests `/healthcheck` through the web server to PHP-FPM. When either process is down, the container reports unhealthy.
+
+The other images don't use S6. `cli` runs your command, `fpm` runs `php-fpm` alone, and `frankenphp` serves PHP and static files from one process.
+
+::note
+Some PaaS providers run containers in a way S6 Overlay doesn't support. See [this comment from the S6 Overlay author](https://github.com/just-containers/s6-overlay/issues/535#issuecomment-1597680218){target="_blank"}.
+::
+
+### S6 Overlay compared to Supervisor
+
+
+Supervisor also restarts child processes. It runs as PID 1 and wasn't built for containers. While a child process is failing, Supervisor keeps running, so the orchestrator still sees a healthy container.
+
+
+
+With S6 Overlay, a service that fails to start stops the container. A process that is down fails the health check.
+
+## How the container starts
+1. `docker-php-serversideup-entrypoint` runs every script in `/etc/entrypoint.d/` in order. See [adding start up scripts](/docs/customizing-the-image/adding-your-own-start-up-scripts) for the list.
+2. It starts S6 Overlay with `/init`.
+3. S6 starts the `php-fpm` service, then `nginx` or `apache2`, which depend on `php-fpm`.
+
+Every service starts after all the entrypoint scripts have finished.
+
+## Add a long-running service
+An S6 service is a directory in `/etc/s6-overlay/s6-rc.d/` with a `type` file, a `run` script, and a `dependencies.d` directory. This one runs a PHP script that never exits:
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+USER root
+
+COPY --chmod=755 ./my-worker/ /etc/s6-overlay/s6-rc.d/my-worker/
+RUN touch /etc/s6-overlay/user-bundles.d/user/contents.d/my-worker
+
+USER www-data
+```
+
+```text [my-worker/type]
+longrun
+```
+
+```sh [my-worker/run]
+#!/command/with-contenv sh
+exec php /var/www/html/worker.php
+```
+
+```text [my-worker/dependencies.d/base]
+```
+
+::
+
+- `type` is `longrun` for a process S6 keeps running and restarts.
+- `run` starts the process. `with-contenv` passes the container's environment variables to it, and `exec` replaces the shell so S6 supervises PHP directly.
+- The empty `dependencies.d/base` file makes the service wait for S6's own setup, which [S6 Overlay recommends](https://github.com/just-containers/s6-overlay#writing-a-service-script){target="_blank"} for every service.
+- The empty file in `/etc/s6-overlay/user-bundles.d/user/contents.d/` registers the service. S6 only starts services listed there.
+
+::warning
+Use `user-bundles.d/user/contents.d/`, not the older `s6-rc.d/user/contents.d/`. Since S6 Overlay 3.2.3.2, the old location makes startup try to write to `/etc/s6-overlay`, which fails as `www-data`, so the container doesn't boot. See [upgrading to v5](/docs/upgrading/upgrade-to-v5).
+::
+
+The service runs as `www-data`, like the rest of the container.
+
+## Start a service between the startup scripts
+Because the entrypoint scripts finish before S6 starts, a service can't run in the middle of them. If it needs to, for example a database proxy that must be up before `50-laravel-automations.sh` runs migrations, convert the scripts to S6 services with `docker-php-serversideup-s6-init`:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+USER root
+
+# Your own startup scripts, if you have any
+COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
+RUN docker-php-serversideup-s6-init
+
+COPY --chmod=755 ./my-worker/ /etc/s6-overlay/s6-rc.d/my-worker/
+RUN touch /etc/s6-overlay/user-bundles.d/user/contents.d/my-worker
+
+USER www-data
+```
+
+`docker-php-serversideup-s6-init` must run as `root`. It:
+
+- Moves every script from `/etc/entrypoint.d/` to `/etc/s6-overlay/scripts/` and makes each one an S6 oneshot service named after the file without `.sh`. For example, `10-init-webserver-config.sh` becomes `10-init-webserver-config`.
+- Chains the oneshots so they run in the same order the entrypoint would have used.
+- Makes `php-fpm`, `nginx`, and `apache2` wait for the last oneshot.
+- Can run again in an image built from yours. New scripts join the existing order, and a script with the same name replaces the earlier one.
+
+Copy your startup scripts into `/etc/entrypoint.d/` before you run it. Scripts added afterward run from the entrypoint before S6 starts.
+
+Your service can now depend on any script by name. Add an empty file for each dependency:
+
+```bash [Terminal]
+mkdir -p my-worker/dependencies.d
+touch my-worker/dependencies.d/base
+touch my-worker/dependencies.d/10-init-webserver-config
+```
+
+To make a script wait for your service instead, add a file named after your service to that script's `dependencies.d` in a `RUN` step after `docker-php-serversideup-s6-init`:
+
+```dockerfile [Dockerfile]
+RUN touch /etc/s6-overlay/s6-rc.d/50-laravel-automations/dependencies.d/my-worker
+```
+
+S6 counts a `longrun` service as up as soon as its `run` script starts. Your process may still be starting when the script runs.
diff --git a/docs/content/docs/5.customizing-the-image/5.understanding-file-permissions.md b/docs/content/docs/5.customizing-the-image/5.understanding-file-permissions.md
new file mode 100644
index 000000000..2c502ce8b
--- /dev/null
+++ b/docs/content/docs/5.customizing-the-image/5.understanding-file-permissions.md
@@ -0,0 +1,73 @@
+---
+head.title: 'Understanding file permissions - Docker PHP - Server Side Up'
+title: Understanding file permissions
+description: 'Match the container user to your host user in development while keeping production images unprivileged.'
+layout: docs
+---
+
+::lead-p
+The images run as the unprivileged `www-data` user. When you mount your code from the host, files the container writes belong to `www-data`'s user ID. Files it needs to change must be writable by that ID. This page shows how to make the container's user match yours in development.
+::
+
+## Why permissions break with mounted code
+`www-data` is UID and GID `33` on Debian and `82` on Alpine. Your host user is usually something else, such as `1000`, and every developer on a team may have a different ID.
+
+With your code mounted as a volume:
+
+- Files the container creates, such as logs and cache files, belong to `33:33` on your host, so you need `sudo` to edit or delete them.
+- Directories owned by your host user may not be writable by `33`, so PHP fails with `Permission denied` when it writes to `storage/` or `bootstrap/cache/`.
+
+## Why the images don't change the user at startup
+Some images accept `PUID` and `PGID` variables and change the user when the container starts. That needs the container to start as `root`. Files created before the switch, such as early log files, can end up owned by `root` and no longer writable by `www-data`. These images stay unprivileged instead. You change the user ID when you build the image.
+
+## Match your user ID in development
+Two helper scripts do the work at build time. Both must run as `root`:
+
+- `docker-php-serversideup-set-id www-data :` changes `www-data`'s UID and GID.
+- `docker-php-serversideup-set-file-permissions --owner :` gives the image's own files back to that user: `/var/www`, `/composer`, and the web server's configuration, log, and certificate directories. It detects the variation, or you can pass `--service`.
+
+Use a multi-stage `Dockerfile` so only the development target changes the user. Production keeps the default:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx AS base
+
+FROM base AS development
+ARG USER_ID
+ARG GROUP_ID
+
+USER root
+RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
+ docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID
+USER www-data
+
+FROM base AS production
+COPY --chown=www-data:www-data . /var/www/html
+```
+
+Build the `development` target with your IDs in Compose:
+
+```yaml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ target: development
+ args:
+ USER_ID: ${UID}
+ GROUP_ID: ${GID}
+ ports:
+ - 80:8080
+ volumes:
+ - .:/var/www/html
+```
+
+Compose reads these from your environment. Bash doesn't export `UID` and doesn't set `GID` at all. Export both before you run Compose. To make it permanent, add the line to your shell profile:
+
+```bash [Terminal]
+export UID GID=$(id -g)
+docker compose up --build
+```
+
+`www-data` in the container now has your UID and GID. You can edit and delete the files it creates without `sudo`. Build the `production` target for deployment; it uses the default `33:33` (or `82:82` on Alpine).
+
+[Spin](https://serversideup.net/open-source/spin/){target="_blank"} sets the user and group IDs for you if you'd rather not manage this yourself.
diff --git a/docs/content/docs/5.guide/.navigation.yml b/docs/content/docs/5.guide/.navigation.yml
deleted file mode 100644
index a5d442aa1..000000000
--- a/docs/content/docs/5.guide/.navigation.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-title: Advanced Guides
-icon: false
-defaultOpen: false
\ No newline at end of file
diff --git a/docs/content/docs/5.guide/1.migrating-from-official-php-images.md b/docs/content/docs/5.guide/1.migrating-from-official-php-images.md
deleted file mode 100644
index 3d458e54b..000000000
--- a/docs/content/docs/5.guide/1.migrating-from-official-php-images.md
+++ /dev/null
@@ -1,97 +0,0 @@
----
-head.title: 'Migrating from official PHP Docker images - Docker PHP - Server Side Up'
-description: 'Learn how easy it is to move from the official PHP docker images to serversideup/php.'
-layout: docs
-title: Migrating from official PHP images
----
-
-::lead-p
-Migrating from the official PHP images to serversideup/php is easy because our images are based on the official PHP images. We just give you a "batteries included" experience that's ready for production.
-::
-
-## Key differences
-::warning
-Because our images run as `www-data` by default, you may need to update file permissions for mounted volumes.
-::
-
-| | **Official PHP Images** |**serversideup/php** |
-|-------------------------|-------------------------|---------------------|
-| Base Operating System | Debian, Alpine | Debian, Alpine |
-| PHP Compilation | PHP Source Code | PHP Source Code (based on official PHP images) |
-| Run PHP, pinned to the minor version | ✅ | ✅ |
-| Multi-arch support | ✅ | ✅ |
-| Init System | Docker CMD | Docker CMD or [S6-Overlay](https://github.com/just-containers/s6-overlay) |
-| Published Registry| DockerHub | [DockerHub](https://hub.docker.com/r/serversideup/php), [GitHub Packages](https://github.com/serversideup/docker-php/pkgs/container/php) |
-| Unprivileged by default | ❌ | ✅ |
-| Variable-first configuration | ❌ | ✅ |
-| Includes `composer` | ❌ | ✅ |
-| Includes [`install-php-extensions`](https://github.com/mlocati/docker-php-extension-installer) | ❌ | ✅ |
-| Production-Ready by default| ❌ | ✅ |
-| Built-in security optimizations | ❌ | ✅ |
-| Optimized for Laravel & WordPress| ❌ | ✅ |
-| NGINX + FPM variation| ❌ | ✅ |
-| FrankenPHP variation| ❌ | ✅ |
-| Native health checks | ❌ | ✅ |
-
-:u-button{to="/docs/getting-started/choosing-an-image" label="Learn more about choosing an image" aria-label="Learn more about choosing an image" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Making the change
-Making the change will literally take you two seconds.
-
-::steps{level="4"}
-
-#### Figure out which image you'd like to use
-Review our [choosing an image](/docs/getting-started/choosing-an-image) guide to help you decide which image you'd like to use. Also, make sure our [default configurations](/docs/getting-started/default-configurations) satisfy your requirements.
-
-#### Update your `Dockerfile` or `compose.yml` file
-:::tip
-We simply change `php:8.5-apache` to `serversideup/php:8.5-fpm-apache`
-:::
-**Dockerfile**
-:::code-group
-```dockerfile [ORIGINAL: Dockerfile]{1}
-FROM php:8.5-apache
-
-# Rest of your Dockerfile...
-```
-```dockerfile [UPDATED: Dockerfile]{1}
-FROM serversideup/php:8.5-fpm-apache
-
-# Rest of your Dockerfile...
-```
-:::
-
-**compose.yml**
-:::code-group
-```yml [ORIGINAL: compose.yml]{3,5-6}
-services:
- php:
- image: php:8.5-apache
- ports:
- - 80:80
- - 443:443
-```
-```yml [UPDATED: compose.yml]{3,5-6}
-services:
- php:
- image: serversideup/php:8.5-fpm-apache
- ports:
- - 80:8080
- - 443:8443
-```
-:::
-
-#### Test your application
-Make sure to test your application to ensure it's working as expected.
-
-#### Deploy and enjoy!
-Making the change is that simple.
-
-::
-
-## Related resources
-If you need to customize the base image, review our guides below:
-
-- [Changing common PHP settings](/docs/customizing-the-image/changing-common-php-settings)
-- [Installing additional PHP extensions](/docs/customizing-the-image/installing-additional-php-extensions)
-- [Startup scripts](/docs/customizing-the-image/adding-your-own-start-up-scripts)
\ No newline at end of file
diff --git a/docs/content/docs/5.guide/2.using-healthchecks-with-laravel.md b/docs/content/docs/5.guide/2.using-healthchecks-with-laravel.md
deleted file mode 100644
index 245b50dfc..000000000
--- a/docs/content/docs/5.guide/2.using-healthchecks-with-laravel.md
+++ /dev/null
@@ -1,100 +0,0 @@
----
-head.title: 'Native Docker Health Checks for Laravel - Docker PHP - Server Side Up'
-description: 'Stop guessing if Laravel is up and running. Our health checks have you covered to ensure Laravel is running properly. It supports HTTP checks, Laravel Horizon, Reverb, Scheduler, and Queue.'
-layout: docs
----
-
-## Native Docker Health Checks for Laravel
-::lead-p
-Dialing in health checks are very important for ensuring your application is running smoothly and that you're able to deploy updates without any downtime. This guide will explain how our health checks work and how you can use them to your advantage.
-::
-
-:iframe{src="https://www.youtube-nocookie.com/embed/cuYIB5VrH1Q?si=75VBCKAe5x2Hmckd" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen style="aspect-ratio: 16/9;"}
-
-## What are Health Checks?
-
-Health checks are a way to check the status of your application. Whenever a container is started, a health check is performed. If the health check fails, the container will be restarted or marked as unhealthy. Health checks are very important for rolling updates and ensuring your application can start up in order if you have services that depend on each other.
-
-## Our Health Checks
-We offer a number of health check commands, specifically for Laravel. You can find these commands are prefixed with `healthcheck-` and are located in [`/usr/local/bin`](https://github.com/serversideup/docker-php/tree/main/src/common/usr/local/bin){target="_blank"}. The examples below show how to use these health checks in your `compose.yml` file, but you can also use them in other environments.
-
-## Default Health Check Settings
-By default, our Dockerfiles ship with the following health check commands:
-
-| Variation | Health Check Command |
-| --------- | -------------------- |
-| `cli` | (none) |
-| `fpm` | [`php-fpm-healthcheck`](https://github.com/renatomefi/php-fpm-healthcheck){target="_blank"} |
-| `fpm-apache` | `curl --insecure --silent --location --show-error --fail http://localhost:8080$HEALTHCHECK_PATH` |
-| `fpm-nginx` | `curl --insecure --silent --location --show-error --fail http://localhost:8080$HEALTHCHECK_PATH` |
-| `frankenphp` | `curl --insecure --silent --location --show-error --fail http://localhost:8080$HEALTHCHECK_PATH` |
-
-::note
-Notice how `fpm-apache`, `fpm-nginx`, and `frankenphp` have a `$HEALTHCHECK_PATH` variable? This is because you can specify a path for the health check to validate.
-::
-
-## Changing the Health Check Path
-By default, you can see our [Environment Variable Specification](/docs/reference/environment-variable-specification) shows the `HEALTHCHECK_PATH` variable is set to `/healthcheck`.
-
-::tip
-This only validates that FPM-NGINX or FPM-APACHE are running and ready to accept connections. It does not validate that Laravel is running or healthy.
-::
-
-If you are using Laravel, [modern versions of Laravel will ship with a `/up` route](https://laravel.com/docs/12.x/deployment#the-health-route){target="_blank"} that you can use to validate that Laravel is running and healthy.
-
-You can even create your own custom path in your application if you want. As long as the path returns a 200 status code, the health check will be successful.
-
-## Advanced Laravel Services
-Since it is good practice to use the same Docker image for all our services, we also make additional health checks for Laravel's advanced services. If you're looking for rolling updates with zero downtime, it's very important to use these health checks.
-
-### Laravel Horizon
-We utilize the `artisan horizon:status` command to check the status of Laravel Horizon. This is a command native to Laravel Horizon and is used to determine if the Horizon process is running.
-
-To run this command automatically, you can call our health check command in your `compose.yml` file.
-
-```yml [compose.yml]
-## Rest of your service configuration...
-healthcheck:
- test: ["CMD", "healthcheck-horizon"]
-```
-
-[See a full example of configuring Laravel Horizon →](/docs/framework-guides/laravel/horizon)
-
-### Laravel Reverb
-We use `pgrep` to check if the `reverb:start` command is running. This ensures the Reverb process is running and ready to accept connections.
-
-To run this command automatically, you can call our health check command in your `compose.yml` file.
-
-```yml [compose.yml]
-## Rest of your service configuration...
-healthcheck:
- test: ["CMD", "healthcheck-reverb"]
-```
-
-[See a full example of configuring Laravel Reverb →](/docs/framework-guides/laravel/reverb)
-
-### Laravel Scheduler
-We use `pgrep` to check if the `schedule:work` command is running. This ensures the scheduler process is running.
-
-To run this command automatically, you can call our health check command in your `compose.yml` file.
-
-```yml [compose.yml]
-## Rest of your service configuration...
-healthcheck:
- test: ["CMD", "healthcheck-schedule"]
-```
-
-[See a full example of configuring Laravel Scheduler →](/docs/framework-guides/laravel/task-scheduler)
-
-### Laravel Queue
-We use `pgrep` to check if the `queue:work` command is running. This ensures the queue process is running.
-
-To run this command automatically, you can call our health check command in your `compose.yml` file.
-
-```yml [compose.yml]
-## Rest of your service configuration...
-healthcheck:
- test: ["CMD", "healthcheck-queue"]
-```
-
-[See a full example of configuring Laravel Queue →](/docs/framework-guides/laravel/queue)
diff --git a/docs/content/docs/5.guide/2.using-s6-overlay.md b/docs/content/docs/5.guide/2.using-s6-overlay.md
deleted file mode 100644
index cabdda1a0..000000000
--- a/docs/content/docs/5.guide/2.using-s6-overlay.md
+++ /dev/null
@@ -1,96 +0,0 @@
----
-head.title: 'Using S6 Overlay - Docker PHP - Server Side Up'
-description: 'Learn about S6 Overlay and how it is used in this project.'
-layout: docs
----
-
-## What's S6 Overlay?
-[S6 Overlay](https://github.com/just-containers/s6-overlay) is a process supervisor designed for containerization from the ground up. It's a modern alternative to [Supervisor (aka Supervisord)](https://supervisord.org/).
-
-S6 Overlay is a perfect match for running PHP because it usually requires running multiple processes together.
-
-## What images do you use S6 Overlay with?
-We only use S6 Overlay for images that need two processes to run a service. This includes:
-- `serversideup/php:*-fpm-apache`
-- `serversideup/php:*-fpm-nginx`
-
-## Why does PHP need multiple processes?
-Serving a PHP application can be broken down into two different components:
-1. The PHP Application Itself
-2. The static files that go with it (JavaScript, images, etc)
-
-There are ways to have these components served together with Apache modules like "mod_php". As time moved on, we found this to be very resource inefficient.
-
-Running "mod_php" with Apache meant that even when a JavaScript file needs to be loaded, Apache would load PHP to serve that file. This caused a lot of unnecessary memory and CPU usage for files that didn't need PHP to be served.
-
-### PHP-FPM
-PHP-FPM was the answer to make this serving a PHP application more efficient. This allowed PHP apps to be served with a lot less memory and CPU overhead.
-
-Although this method was more efficient, it meant we still need something to serve our static content. This is where we turned to "reverse proxies".
-
-### Reverse proxy
-Reverse proxies are servers that route traffic based on the request. To make things more confusing, you can have a web server be a reverse proxy and a web server at the same time (this is how we run NGINX). You can even configure Apache to run as a web server and a reverse proxy too (we run our `php:*-fpm-apache` images like this).
-
-
-
-In the example above, you can see the web request coming in from the top. NGINX is our first stop where it inspects the request.
-
-#### Static Files
-If the request is for a static file (jpeg, js, png, etc), NGINX will also serve that file for us without loading PHP-FPM. This makes the request very fast and efficient.
-
-#### PHP Files
-If the request ends in `.php`, then it will send the request over to PHP-FPM. PHP-FPM will then execute the PHP file and return a response to the original client, all passed through NGINX.
-
-## Shouldn't containers only have one process?
-In a perfect world, that would be ideal -- but this isn't always realistic. You can see in the example above we need two things running:
-1. NGINX: To serve static files
-1. PHP-FPM: To serve PHP application
-
-If you want to replicate your application without the added complexity of multiple physical servers, etc -- you need something like S6 Overlay to properly bring up the processes and ensure your application service health is accurately reported.
-
-[S6 Overlay's philosophy](https://github.com/just-containers/s6-overlay#the-docker-way){target="_blank"} is a perfect match when it comes to running PHP:
-
-- A container should do ***one thing*** (which may contain multiple processes). When that one thing stops, the container should also stop.
-
-## The advantages of S6 Overlay
-When we configure PHP to run with the S6 Overlay system, we get a number of advantages:
-
-- ✅ S6 Overlay was designed from the ground up to run within containers
-- ✅ We get explicit control to run small scripts or configurations before/after the main processes start
-- ✅ We get a better confidence on answering "Is my container actually healthy?"
-
-## This disadvantage of S6 Overlay
-
-- ❌ S6 Overlay may not be compatible with all PaaS providers, depending on how they run their containers ([see this comment from the S6 Overlay creator](https://github.com/just-containers/s6-overlay/issues/535#issuecomment-1597680218){target="_blank"})
-
-## S6 Overlay vs. Supervisor
-Many people flock to Supervisor, which was a very popular option before containerization. Here's some examples why you may want to trade Supervisor for S6 Overlay:
-
-### How Supervisor reports container health
-
-
-When you bring up Supervisord within a container, it will be assigned `PID 1`. Then Supervisor will bring up child processes with it.
-
-During a failure, Supervisor can be configured to restart the child process to attempt recovery, but the container orchestrator thinks the container is still healthy because `supervisord` is occupying `PID 1` which is still healthy.
-
-**👉 This design can lead to inaccurate container health statuses during a failure.**
-
-### How S6 Overlay reports container health
-
-S6 Overlay was designed to be run in containers from the ground up. S6 Overlay can also attempt recovery, but it is more accurate on determining container health compared to Supervisor.
-
-**👍 By design, S6 Overlay can accurately detect a failure and exit (which is what we want when our app fails).**
-
-## Customizing the initialization process
-
-Since S6 Overlay was designed around the idea of containerization, there are also a number of other advantages to properly time your customizations during container startup.
-
-S6 Overlay has a number of options to [write our own service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} and properly time everything.
-
-In the example above, you can see we have a `runas-user` script which helps us customize and set custom UIDs and GIDs for our file permissions. At the same time, `laravel-automations` executes to see if there are any automated migrations to run.
-
-Both scripts must finish successfully before S6 Overlay starts our main `php-fpm` process, which has both scripts listed as a dependency.
-
-As you can see this structure can be very powerful in making your own customizations. This is great for giving you full control of how you'd like your application to behave.
-
-:u-button{to="/docs/customizing-the-image/changing-common-php-settings" label="Changing Common PHP Settings" aria-label="Changing Common PHP Settings" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
\ No newline at end of file
diff --git a/docs/content/docs/5.guide/3.understanding-file-permissions.md b/docs/content/docs/5.guide/3.understanding-file-permissions.md
deleted file mode 100644
index 30abe6585..000000000
--- a/docs/content/docs/5.guide/3.understanding-file-permissions.md
+++ /dev/null
@@ -1,105 +0,0 @@
----
-head.title: 'Understanding file permissions - Docker PHP - Server Side Up'
-description: 'Eliminate the headache of file permissions when working with PHP + Docker.'
-layout: docs
----
-::lead-p
-Working with file permissions is one of the biggest headaches when working with PHP + Docker. This generally is because the PHP server also requires a web server to serve static files. By default, this means multiple users are created in the container, and permissions can get out of hand quickly.
-::
-
-{:zoom=false}
-
-## Even more frustrating: Development Environments
-Even if someone configured a single user in the container to run both the PHP server and the web server, things get even more complicated in development environments. For example, if you have Alice running her Windows Machine with WSL2, she might have a user ID of `1001`. Then you have Bob running his Ubuntu workstation with a user ID of `1002`. Meanwhile, Charlie is running his Docker on his macOS machine (that runs a tiny VM) that has a totally different file permission experience compared to Windows and Linux because of the file system differences.
-
-If a volume is mounted from the container to the host, the container will write files to the host as `33:33`, which will require sudo/root permissions to edit and delete files.
-
-## Our industry attempted workarounds
-We've seen experiences that allow users to provide an environment variable of `PUID` and `PGID`. Although this is a great user experience, it requires the container user to be privileged, which is a major "no-no" in the security world. It also had downstream file permission errors if the container failed on initialization where logs would be created by the root user and no longer writable by the `www-data` user.
-
-## Our solution
-We focus on providing the tools to give sysadmins the ability to:
-- Keep their containers unprivileged by default
-- Allow the dynamic reconfiguration of the container user and group ID (at build time only)
-
-It's a bummer that we can only set the user and group ID at build time, but it's a small price to pay for the security benefits of running unprivileged containers.
-
-#### How it works
-- By default, all our images run `www-data` as the user (`33:33` for Debian and `82:82` for Alpine)
-- We provide a script that can be called at build time to change the UID and GID of `www-data` (called `docker-php-serversideup-set-id`)
-- If you need to update permissions of service files (example: NGINX, Apache, FrankenPHP, etc), you can run the `docker-php-serversideup-set-file-permissions` at build. This will automatically detect the service and update the file permissions accordingly.
-- We will use a multi-stage build to ensure that the `docker-php-serversideup-set-id` script is not executed in the construction of the final image
-
-## Example
-Here's an example of ensuring our UID/GID of `www-data` will match the development UID/GID of the host machine, while preserving the default UID/GID of `33:33` for the final image:
-
-
-```dockerfile [Dockerfile]
-############################################
-# Base Image
-############################################
-FROM serversideup/php:8.5-fpm-nginx-bookworm AS base
-
-############################################
-# Development Image
-############################################
-FROM base AS development
-
-# Switch to root so we can do root things
-USER root
-
-# Save the build arguments as a variable
-ARG USER_ID
-ARG GROUP_ID
-
-# Use the build arguments to change the UID
-# and GID of www-data while also changing
-# the file permissions for NGINX
-RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
- \
- # Update the file permissions to match the new UID/GID
- docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID
-
-# Drop back to our unprivileged user
-USER www-data
-
-############################################
-# Production Image
-############################################
-
-# Since we're calling "base", production isn't
-# calling any of that permission stuff
-FROM base AS production
-
-# Copy our app files as www-data (33:33)
-COPY --chown=www-data:www-data . /var/www/html
-```
-
-To show a simple Docker Compose file example for development, we could use:
-
-```yml [compose.yml]
-services:
- php:
- build:
- context: .
- target: development
- args:
- # UID and GID must be set as environment variables on the host machine
- USER_ID: $UID
- GROUP_ID: $GID
- ports:
- - 80:8080
- volumes:
- - .:/var/www/html
-```
-
-When we run `docker compose up`, our compose file directs us to build the `development` target. This target will run the `docker-php-serversideup-set-id` script to change the UID and GID of `www-data` to match the host machine (assuming `$UID` and `$GID` are set in a zsh/bash profile or something similar). This will allow us to run the container as an unprivileged user while still having the correct permissions to read and write files.
-
-The best thing is the user can delete files off of their machine without being prompted for sudo permissions. This is because we're aligning the UID/GID of the container with the host machine.
-
-When it comes to building our image for production, we just use the `production` target, which will copy the files as `www-data` with the default UID/GID of `33:33`.
-
-## An optimized experience from development to production
-If you like the concepts above and you're looking for an optimized experience for developers (especially when it comes simplifying the setting of UID/GID), we recommend checking out our other open source project Spin. Spin is a lightweight wrapper for Docker Compose that allows you to manage your environment from development to production.
-
-:u-button{to="https://serversideup.net/open-source/spin/" label="Learn more about Spin" aria-label="Learn more about Spin" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
\ No newline at end of file
diff --git a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
deleted file mode 100644
index 62075d36f..000000000
--- a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
+++ /dev/null
@@ -1,358 +0,0 @@
----
-head.title: 'Configuring Trusted Proxies - Docker PHP - Server Side Up'
-description: 'Learn how to configure trusted proxies to get accurate client IP addresses when running behind load balancers, CDNs, or reverse proxies.'
-layout: docs
----
-
-::lead-p
-When your application runs behind a load balancer, CDN, or reverse proxy, the web server sees the proxy's IP address instead of the actual visitor's IP. Trusted proxy configuration tells your web server which proxies to trust for forwarding the real client IP.
-::
-
-## Why Trusted Proxies Matter
-
-Without proper trusted proxy configuration, your application will:
-- Log the proxy's IP address instead of the visitor's real IP
-- Have incorrect geolocation data
-- Potentially break rate limiting or IP-based security rules
-- Show incorrect information in Laravel's `request()->ip()` or similar methods
-
-::warning
-Never trust all IP addresses as proxies. Only trust specific proxy IPs that you control or know are legitimate (like your CDN provider).
-::
-
-## Available Options
-
-Set the `TRUSTED_PROXY` environment variable to one of the following values:
-
-| Value | Description |
-|-------|-------------|
-| `cloudflare` (default) | Trusts [Cloudflare's IP ranges](https://www.cloudflare.com/ips/){target="_blank"} **+ local Docker networks** using the `CF-Connecting-IP` header |
-| `sucuri` | Trusts [Sucuri's IP ranges](https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/){target="_blank"} **+ local Docker networks** using the `X-Forwarded-For` header |
-| `local` | Trusts only private/local network ranges (Docker networks, localhost) using the `X-Forwarded-For` header |
-| `off` | Disables trusted proxy configuration entirely |
-
-::tip
-**You don't need to choose between your CDN and local proxies.** All options (except `off`) automatically include Docker's internal network ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). This means if you're behind Cloudflare *and* running a reverse proxy like Traefik or Caddy in Docker, just use `cloudflare` — it already trusts both.
-::
-
-## Quick Start
-
-### Using Cloudflare (Default)
-
-If you're using Cloudflare as your CDN/proxy, you don't need to change anything. The default configuration already trusts Cloudflare's IP ranges:
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- # TRUSTED_PROXY defaults to "cloudflare"
-```
-
-### Using Sucuri
-
-If you're using Sucuri as your web application firewall:
-
-```yml [compose.yml]{8}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- environment:
- TRUSTED_PROXY: "sucuri"
-```
-
-### Local/Docker Networks Only
-
-If you're running behind your own reverse proxy (like Traefik or Caddy) on the same Docker network:
-
-```yml [compose.yml]{8}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- environment:
- TRUSTED_PROXY: "local"
-```
-
-### Disabling Trusted Proxies
-
-If you want to handle proxy configuration yourself or your application is directly exposed to the internet:
-
-```yml [compose.yml]{8}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- environment:
- TRUSTED_PROXY: "off"
-```
-
-## Laravel Configuration
-
-While the Docker images handle the web server's trusted proxy configuration, Laravel also needs to know about trusted proxies at the application level.
-
-Laravel includes a `TrustProxies` middleware that you should configure in your application. For most setups using our images, you can trust all proxies since the web server has already validated them:
-
-```php [bootstrap/app.php]
-->withMiddleware(function (Middleware $middleware) {
- $middleware->trustProxies(at: '*');
-})
-```
-
-Or if you prefer more control, configure specific headers:
-
-```php [bootstrap/app.php]
-->withMiddleware(function (Middleware $middleware) {
- $middleware->trustProxies(
- at: '*',
- headers: Request::HEADER_X_FORWARDED_FOR |
- Request::HEADER_X_FORWARDED_HOST |
- Request::HEADER_X_FORWARDED_PORT |
- Request::HEADER_X_FORWARDED_PROTO
- );
-})
-```
-
-::tip
-For Cloudflare specifically, Laravel can use the `CF-Connecting-IP` header. The `HEADER_X_FORWARDED_FOR` setting works because Cloudflare also sets this header.
-::
-
-:u-button{to="https://laravel.com/docs/requests#trusting-proxies" target="_blank" label="Learn more about Laravel's TrustProxies middleware" aria-label="Learn more about Laravel's TrustProxies middleware" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## How It Works
-
-### Cloudflare
-When `TRUSTED_PROXY=cloudflare`:
-- Trusts Cloudflare's published IPv4 and IPv6 ranges
-- Uses the `CF-Connecting-IP` header to get the real client IP
-- Includes Docker network ranges for container-to-container communication
-
-Cloudflare sets `CF-Connecting-IP` on every request, and reverse proxies like Traefik pass it through unchanged. Many proxies rewrite `X-Forwarded-For` unless you explicitly configure them to trust Cloudflare's ranges, which is why this option uses Cloudflare's own header. If your traffic does not come through Cloudflare, Apache and nginx return the proxy's IP because no `CF-Connecting-IP` header exists. Use `local` in that case. FrankenPHP is the only server that can check multiple headers, so it falls back to `X-Forwarded-For` when `CF-Connecting-IP` is missing.
-
-### Sucuri
-When `TRUSTED_PROXY=sucuri`:
-- Trusts Sucuri's WAF IP ranges
-- Uses the `X-Forwarded-For` header to get the real client IP
-- Includes Docker network ranges for container-to-container communication
-
-### Local
-When `TRUSTED_PROXY=local`:
-- Trusts only private network ranges (RFC 1918)
-- Trusts localhost and IPv6 loopback addresses
-- Uses the `X-Forwarded-For` header
-- Perfect for setups with your own reverse proxy on the same network
-
-## Security Considerations
-
-::caution
-Incorrectly configuring trusted proxies can allow attackers to spoof their IP address by sending fake headers. Only trust IP ranges that you know are legitimate proxies.
-::
-
-- **Don't trust all IPs**: Never set your web server to trust `X-Forwarded-For` from any IP address
-- **Keep configurations updated**: CDN IP ranges can change over time. Our images are updated regularly, but if security is critical, verify the ranges match your provider's published list
-- **Use the right option**: If you're not using Cloudflare or Sucuri, don't use those options. Use `local` if you have your own reverse proxy, or `off` if you don't need proxy trust
-- **Restrict who can reach your origin**: A client that bypasses your CDN and connects directly to your reverse proxy can send a forged `CF-Connecting-IP` header, and your proxy will pass it along. Only accept traffic from your CDN's IP ranges at the edge (for example, a firewall rule or Traefik's `ipAllowList` middleware)
-- **FrankenPHP resolves clients strictly**: FrankenPHP uses Caddy's `trusted_proxies_strict` mode, which walks `X-Forwarded-For` from right to left and stops at the first untrusted address. If every address in the chain is trusted (a client on a private network behind a private proxy), FrankenPHP reports the proxy's address, while Apache and nginx report the leftmost address
-
-## Troubleshooting
-
-### Still seeing proxy IP instead of real IP
-
-1. **Check your CDN/proxy is in the trusted list**: Verify your proxy's IP is within the trusted ranges for your chosen option
-2. **Check the header being used**: Different proxies use different headers. Cloudflare uses `CF-Connecting-IP`, while most others use `X-Forwarded-For`
-3. **Check Laravel configuration**: Remember to also configure Laravel's `TrustProxies` middleware
-
-### Application works but logs show wrong IP
-
-Your web server might be configured correctly, but your application framework might need separate configuration. See the [Laravel Configuration](#laravel-configuration) section above.
-
-### Need to trust a different proxy provider
-
-If your proxy provider isn't listed, you can create a custom configuration. See [Custom Trusted Proxy Configuration](#custom-trusted-proxy-configuration) below.
-
-## Custom Trusted Proxy Configuration
-
-If you're using a proxy provider that isn't included (like AWS ALB, Fastly, or a custom load balancer), you can create your own trusted proxy configuration.
-
-### Two Ways to Add Custom Configs
-
-| Method | Best For | How It Works |
-|--------|----------|--------------|
-| **Dockerfile (recommended)** | Production deployments | Bakes the config into your image |
-| **Volume mount** | Local development | Mount the file at runtime |
-
-The examples below use the Dockerfile approach. To use a volume mount instead, simply replace the `COPY` instruction with a volume mount in your `compose.yml`:
-
-```yml [compose.yml]
-volumes:
- # Mount syntax: ./local/path:/container/path:ro
- - ./docker/trusted-proxy/aws-alb.conf:/etc/nginx/trusted-proxy/aws-alb.conf:ro
-```
-
-| Variation | Config Path |
-|-----------|-------------|
-| `fpm-nginx` | `/etc/nginx/trusted-proxy/{name}.conf` |
-| `fpm-apache` | `/etc/apache2/trusted-proxy/{name}.conf` |
-| `frankenphp` | `/etc/frankenphp/trusted-proxy/{name}.caddyfile` |
-
-::tip
-Always include Docker's internal network ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) so container-to-container communication works correctly.
-::
-
-::warning
-Keep your custom IP ranges up to date. Cloud providers periodically update their IP ranges.
-::
-
-### FPM-NGINX Custom Configuration
-
-::code-tree{defaultValue="Dockerfile"}
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx
-
-# Copy our custom trusted proxy configuration
-COPY --chmod=644 docker/trusted-proxy/aws-alb.conf /etc/nginx/trusted-proxy/aws-alb.conf
-```
-
-```yml [compose.yml]
-services:
- php:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- environment:
- TRUSTED_PROXY: "aws-alb"
-```
-
-```conf [docker/trusted-proxy/aws-alb.conf]
-##
-# AWS ALB - Trusted Proxy (example)
-##
-
-# Configure docker networks and loopback addresses
-set_real_ip_from 10.0.0.0/8;
-set_real_ip_from 172.16.0.0/12;
-set_real_ip_from 192.168.0.0/16;
-set_real_ip_from 127.0.0.0/8;
-set_real_ip_from ::1;
-set_real_ip_from fd00::/8;
-
-# Add your proxy provider's IP ranges
-# Example: AWS us-east-1 ranges (check AWS docs for current IPs)
-set_real_ip_from 3.0.0.0/8;
-
-# Set the header your proxy uses
-real_ip_header X-Forwarded-For;
-real_ip_recursive on;
-```
-::
-
-### FPM-Apache Custom Configuration
-
-::code-tree{defaultValue="Dockerfile"}
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-apache
-
-# Copy our custom trusted proxy configuration
-COPY --chmod=644 docker/trusted-proxy/aws-alb.conf /etc/apache2/trusted-proxy/aws-alb.conf
-```
-
-```yml [compose.yml]
-services:
- php:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- environment:
- TRUSTED_PROXY: "aws-alb"
-```
-
-```conf [docker/trusted-proxy/aws-alb.conf]
-##
-# AWS ALB - Trusted Proxy (example)
-##
-
-# Set the header your proxy uses
-RemoteIPHeader X-Forwarded-For
-
-# Configure docker networks
-# Use InternalProxy for private ranges so mod_remoteip resolves the client IP
-# through Docker network hops
-RemoteIPInternalProxy 10.0.0.0/8
-RemoteIPInternalProxy 172.16.0.0/12
-RemoteIPInternalProxy 192.168.0.0/16
-RemoteIPInternalProxy 127.0.0.0/8
-RemoteIPInternalProxy ::1
-RemoteIPInternalProxy fd00::/8
-
-# Add your proxy provider's IP ranges
-# Example: AWS us-east-1 ranges (check AWS docs for current IPs)
-RemoteIPTrustedProxy 3.0.0.0/8
-```
-::
-
-### FrankenPHP Custom Configuration
-
-::code-tree{defaultValue="Dockerfile"}
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-frankenphp
-
-# Copy our custom trusted proxy configuration
-COPY --chmod=644 docker/trusted-proxy/aws-alb.caddyfile /etc/frankenphp/trusted-proxy/aws-alb.caddyfile
-```
-
-```yml [compose.yml]
-services:
- php:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - "80:8080"
- volumes:
- - .:/var/www/html
- environment:
- TRUSTED_PROXY: "aws-alb"
-```
-
-```caddyfile [docker/trusted-proxy/aws-alb.caddyfile]
-servers {
- # Trust Docker/private networks + loopback
- trusted_proxies static \
-10.0.0.0/8 \
-172.16.0.0/12 \
-192.168.0.0/16 \
-127.0.0.0/8 \
-::1 \
-fd00::/8 \
-3.0.0.0/8
-
- # Set the header your proxy uses
- trusted_proxies_strict
- client_ip_headers X-Forwarded-For
-}
-```
-::
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
deleted file mode 100644
index 633c857c5..000000000
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ /dev/null
@@ -1,431 +0,0 @@
----
-head.title: 'Major version migrations - Docker PHP - Server Side Up'
-description: 'Step-by-step guides for migrating between major versions of the serversideup/php Docker images.'
-layout: docs
-title: Major version migrations
----
-
-::lead-p
-When we ship a new major version of `serversideup/php`, we collect the breaking changes, new features, and migration checklists in this guide. Use it whenever you're crossing a major version boundary — for example, V3 → V4 or V2 → V3. For day-to-day patches and security updates, see the [Upgrade Guide](/docs/getting-started/upgrade-guide) instead.
-::
-
-## Dropped PHP versions
-
-We keep building an end-of-life PHP version while its official base image exists and the operating system underneath still receives security updates. Once the operating system is end of life too, a rebuild cannot deliver any patches, so we stop. The tags stay pullable, but they freeze at their last successful build and receive no further security updates. The full rule is in our [security policy](https://github.com/serversideup/docker-php/blob/main/SECURITY.md#component-lifecycle-references).
-
-| Version | Last built | Why |
-| --- | --- | --- |
-| PHP 8.0 | 2026-09-03 | Only ever shipped on Debian Bullseye and Alpine 3.16, both now end of life. Upstream stopped building it in November 2023. |
-| PHP 7.4 | 2026-09-03 | Same bases as 8.0. Upstream stopped building it in November 2022. |
-
-Debian Bullseye and Alpine 3.16 were dropped at the same time. Debian 11 LTS ended on 2026-08-31, and the following week Debian removed the Bullseye packages from its mirrors, so `apt-get install` no longer succeeds inside a build. Alpine 3.16 reached end of life on 2024-05-23.
-
-PHP 8.1 is end of life as well, but we still build it on `bookworm`, `trixie`, and `alpine3.22` because those bases still receive security updates. It will stop when they do.
-
-If you are on a frozen version, move to a supported PHP version and base from [Choosing an image](/docs/getting-started/choosing-an-image). See [EOL versions and the legacy-modernization path](https://github.com/serversideup/docker-php/blob/main/SECURITY.md#eol-versions-and-the-legacy-modernization-path).
-
-### Installing packages on a frozen Bullseye image
-
-::warning{title="Unsupported, and it does not make the image safer"}
-The `7.4` and `8.0` tags, and any `-bullseye` tag, are frozen. Debian no longer publishes security updates for Bullseye, so nothing below gets you a patched package. It only lets `apt-get install` work again so you can add a dependency while you finish migrating.
-::
-
-Debian moved the Bullseye packages off its regular mirrors after the LTS period ended. The `bullseye` and `bullseye-updates` suites now live on `archive.debian.org`. The `bullseye-security` suite is not on the archive yet, so the last copy of it is only available from [snapshot.debian.org](https://snapshot.debian.org/){target="_blank"}, Debian's archive of every past mirror state. Its release file has expired, which is why apt must be told to skip the expiry check. This is the same approach [docker-php-extension-installer uses](https://github.com/mlocati/docker-php-extension-installer/pull/1320){target="_blank"}.
-
-The frozen images ship an `install-php-extensions` from before that fix, so download the current release before installing extensions.
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:7.4-fpm-nginx
-
-USER root
-
-RUN printf '%s\n' \
- 'deb http://archive.debian.org/debian bullseye main' \
- 'deb http://archive.debian.org/debian bullseye-updates main' \
- 'deb http://snapshot.debian.org/archive/debian-security/20260903T220410Z bullseye-security main' \
- > /etc/apt/sources.list \
- && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99no-check-valid-until \
- && docker-php-serversideup-install-php-ext-installer 2.11.15
-
-RUN docker-php-serversideup-dep-install-debian "mariadb-client" \
- && install-php-extensions intl
-
-USER www-data
-```
-
-Once Debian publishes `bullseye-security` on `archive.debian.org`, replace the `snapshot.debian.org` line with `deb http://archive.debian.org/debian-security bullseye-security main`.
-
-The Alpine 3.16 tags need no changes. Alpine keeps every past release on its mirrors, so `apk add` still works. The packages are just as unpatched.
-
-## Version 4 → Version 5 Migration
-Version 5 is about production polish. Setting `PHP_OPCACHE_ENABLE=1` now gives you tuned defaults instead of PHP's stock values, Laravel Octane runs with our production Caddyfile, `TRUSTED_PROXY` works the same way on every web server, and FrankenPHP logs follow Caddy's defaults for stream and format. PHP 7.4 and 8.0 are no longer built. See [Dropped PHP versions](#dropped-php-versions).
-
-The breaking changes below are in the order most people will notice them. The first two are OPcache and FrankenPHP logging. The rest only matter if you call `session_start()` yourself, customize S6 Overlay in your Dockerfile, or run Laravel Octane with a custom `FRANKENPHP_CONFIG`.
-
-If you want to stay on Version 4 while you review the changes, pin your image tag to the last v4 release. Version-pinned tags are never rebuilt, so you will not receive security updates until you move to v5. See [how our releases work](/docs/getting-started/upgrade-guide#how-our-releases-work).
-
-```yml [compose.yml] {3}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx-v4.5.1
-```
-
-### Why we changed OPcache
-Most people start with these images in development, so OPcache stays off by default to keep your edits showing up instantly. But when you flip it on for production, the settings behind it should be the ones you would have picked yourself after reading the docs. They were not. Version 4 checked every cached file for changes every two seconds, shipped PHP's stock memory sizes, and documented an environment variable that did nothing. Version 5 fixes all of that with the values from [Symfony's performance guide](https://symfony.com/doc/current/performance.html#configure-opcache-for-maximum-performance){target="_blank"}, which says "The default OPcache configuration is not suited for Symfony applications." [FrankenPHP's performance guide](https://frankenphp.dev/docs/performance/){target="_blank"} points to the same page "even if you don't use Symfony." [Read the production performance tuning guide →](/docs/guide/production-performance-tuning#php-opcache)
-
-### Why we changed FrankenPHP logging
-Version 4 sent FrankenPHP's logs to `stdout` to mirror the access log convention of our NGINX and Apache variations. Caddy's access log is a named logger that shares the same [structured format](https://caddyserver.com/docs/logging){target="_blank"} and default output as its runtime log, though, with a level on every entry. Caddy, the official FrankenPHP image, and Laravel Octane all expect that output on `stderr`. Version 4 also forced the human-readable `console` format, which Caddy [only picks by itself when `stderr` is a terminal](https://caddyserver.com/docs/caddyfile/directives/log#format){target="_blank"}. A container has no terminal, so Caddy, the official FrankenPHP image, and Octane all write JSON there, and forcing `console` put ANSI color codes into every log pipeline. Version 5 follows both upstream defaults, which means Octane works without any special handling. [Read how we approach logging →](/docs/getting-started/default-configurations#logging)
-
-### Breaking changes in Version 5
-::caution
-Each of these changes behavior for a configuration that works on Version 4. Read them before you update your image tag.
-::
-
-#### `PHP_OPCACHE_VALIDATE_TIMESTAMPS` now defaults to `0`
-With OPcache enabled, PHP files are now cached until the container restarts. PHP no longer checks the filesystem for changes on every request. This is the correct setting for code that is built into the image, which is how we recommend deploying.
-
-You are affected if you set `PHP_OPCACHE_ENABLE=1` **and** any of these apply:
-
-- Your code is mounted as a volume and you edit it in place
-- You follow the [volume-based WordPress approach](/docs/framework-guides/wordpress/using-wordpress-with-docker#volume-based-approach-traditional) and update with `git pull`, WP-CLI, or SFTP
-- You run commands like `docker exec php artisan optimize` against a live container and expect the web workers to pick up the new files
-
-The fix is one of two things: restart the container after code changes (recommended), or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1` to restore the Version 4 behavior.
-
-#### `CADDY_LOG_OUTPUT` now defaults to `stderr` and `CADDY_LOG_FORMAT` to `auto`
-FrankenPHP logs, including request logs, now go to `stderr` instead of `stdout`, and inside a container they are JSON instead of `console` lines. `docker logs`, Docker Compose, and Kubernetes show both streams, so most setups will only notice the format.
-
-You are affected if:
-
-- You separate the streams yourself, for example with `2>/dev/null` or a log driver rule that only captures `stdout`
-- You parse the `console` lines, for example with a regular expression in your log pipeline
-- You read FrankenPHP logs by eye with `docker compose logs` or `docker service logs` and prefer the colored lines
-
-The fix is one of two things: keep the defaults and let your log pipeline read the `level` of each JSON entry (recommended), or restore the Version 4 look with `CADDY_LOG_FORMAT=console` and `CADDY_LOG_OUTPUT=stdout`. Do not use either override with Laravel Octane, which only relays `stderr` and only parses JSON.
-
-#### `PHP_SESSION_COOKIE_HTTPONLY` now defaults to `On`
-PHP [recommends `session.cookie_httponly=On`](https://www.php.net/manual/en/session.security.ini.php){target="_blank"} for production, so the images now ship it that way. The flag stops browser scripts from reading PHP's native session cookie. Laravel, Symfony, and WordPress manage their own session cookies and are not affected.
-
-You are affected if your app calls `session_start()` directly **and** reads the session cookie from JavaScript. Set `PHP_SESSION_COOKIE_HTTPONLY=Off` to keep the Version 4 behavior.
-
-#### S6 Overlay dependencies moved to `dependencies.d/`
-The `fpm-nginx` and `fpm-apache` variations now declare service dependencies with the `dependencies.d/` directory that [S6 Overlay documents](https://github.com/just-containers/s6-overlay#writing-a-service-script){target="_blank"}, instead of the deprecated flat `dependencies` file. This is what fixed a race where `php-fpm` could start before its pool user was written when the container runs as root.
-
-You are affected if your Dockerfile appends lines to `/etc/s6-overlay/s6-rc.d//dependencies` for `php-fpm`, `nginx`, or `apache2`. S6 ignores that file once `dependencies.d/` exists. Create an empty file in `dependencies.d/` instead:
-
-```dockerfile [Dockerfile]
-RUN touch /etc/s6-overlay/s6-rc.d/nginx/dependencies.d/my-service
-```
-
-Stock images and rootless containers are not affected. [Learn more about start-up script dependencies →](/docs/customizing-the-image/adding-your-own-start-up-scripts)
-
-#### S6 Overlay `user` bundle moved to `user-bundles.d/`
-Version 5 ships [S6 Overlay v3.2.3.2](https://github.com/just-containers/s6-overlay/blob/v3.2.3.2/CHANGELOG.md){target="_blank"}, which defines the `user` and `user2` bundles in `/etc/s6-overlay/user-bundles.d/` instead of `/etc/s6-overlay/s6-rc.d/`. The images follow that layout, so `/etc/s6-overlay/s6-rc.d/user` no longer exists. `docker-php-serversideup-s6-init` already writes to the new location.
-
-You are affected if your Dockerfile creates `/etc/s6-overlay/s6-rc.d/user/contents.d/` to start your own service. Create the empty file in the new directory instead:
-
-```dockerfile [Dockerfile]
-RUN touch /etc/s6-overlay/user-bundles.d/user/contents.d/my-service
-```
-
-::warning
-Do not recreate the old `/etc/s6-overlay/s6-rc.d/user` directory. When it exists, S6 Overlay ignores `user-bundles.d/` entirely, so `php-fpm` and the web server never start. As the default `www-data` user, the container does not boot at all because S6 Overlay tries to write to `/etc/s6-overlay` during start up.
-::
-
-#### Laravel Octane: the `FRANKENPHP_CONFIG` worker block and `CADDY_GLOBAL_OPTIONS` no longer apply
-Before Version 5, running Octane with our Caddyfile meant adding a `worker { }` block to `FRANKENPHP_CONFIG` and routing directives to `CADDY_PHP_SERVER_OPTIONS`. The Caddyfile now does both when Octane starts FrankenPHP, so keeping the block fails with "global workers must not have duplicate filenames". Remove it.
-
-Octane also needs the Caddy admin API and sets `CADDY_GLOBAL_OPTIONS` for itself, so `CADDY_ADMIN` and `CADDY_GLOBAL_OPTIONS` are ignored while Octane runs. Put global options in `/etc/frankenphp/caddyfile-global.d/` instead. Nothing changes in classic mode. [Read the Octane guide →](/docs/framework-guides/laravel/octane#octane-options)
-
-#### FrankenPHP: `REMOTE_ADDR` is now the client IP resolved from trusted proxies
-Version 4 set `$_SERVER['REMOTE_ADDR']` to the TCP peer on FrankenPHP, even when Caddy had already worked out the real client IP for the access log. It now matches what Caddy resolved, the same as NGINX and Apache, and Caddy runs in strict mode so a client behind a trusted proxy cannot forge it. You are affected if anything in your app compares `REMOTE_ADDR` to a proxy's address. [Read the trusted proxies guide →](/docs/guide/configuring-trusted-proxies)
-
-#### FrankenPHP: Mercure 1.0 rejects `publisher_jwt` and `subscriber_jwt`
-Version 5 ships FrankenPHP 1.13, which includes Mercure 1.0. If you enable the Mercure hub with `publisher_jwt` or `subscriber_jwt`, including through the `mercure` array in `config/octane.php`, FrankenPHP now fails to start. Turn the hub on with the new `MERCURE_*` environment variables instead, which work in classic mode and with Octane. If your app still signs 0.x tokens, add `protocol_version_compatibility 8` to `MERCURE_EXTRA_DIRECTIVES` while you migrate. [Read how to set up Mercure →](/docs/image-variations/frankenphp#mercure) The [Mercure 1.0 upgrade guide](https://github.com/dunglas/mercure/blob/v1.0.3/docs/UPGRADE.md){target="_blank"} covers the client changes.
-
-#### FrankenPHP: Caddy limits request headers
-FrankenPHP 1.13 includes [Caddy 2.11.7](https://github.com/caddyserver/caddy/releases/tag/v2.11.7){target="_blank"}. Requests with more than 16 KiB of headers now get a `431 Request Header Fields Too Large` response, where Version 4 allowed 1 MB. Large cookies are the usual cause. Headers with a `.` in their name are now dropped, like headers with a `_`, because PHP reads both as `-` and a client could use them to spoof headers like `X-Forwarded-For`.
-
-#### FrankenPHP: `num_threads` no longer includes worker threads
-If you set `num_threads` in `FRANKENPHP_CONFIG` while running workers, including Laravel Octane, FrankenPHP now starts that many threads on top of the worker threads, and fails to start if `max_threads` is lower than the total. Lower `num_threads` to the number of threads you want for regular requests.
-
-### Fixes
-- `PHP_OPCACHE_FORCE_RESTART_TIMEOUT` existed in Version 4 but never reached `php.ini`. It now works. The default of `180` matches PHP's own default, so nothing changes unless you had set it to something else.
-
-### Changed defaults
-The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is only used as files are cached. `PHP_REALPATH_CACHE_TTL` is not an OPcache setting and applies whether OPcache is on or off. It comes from the same [Symfony recommendation](https://symfony.com/doc/current/performance.html#configure-the-php-realpath-cache){target="_blank"}.
-
-| Variable | Version 4 | Version 5 |
-|----------|-----------|-----------|
-| `PHP_OPCACHE_VALIDATE_TIMESTAMPS` | `1` | `0` |
-| `PHP_OPCACHE_MEMORY_CONSUMPTION` | `128` | `256` |
-| `PHP_OPCACHE_INTERNED_STRINGS_BUFFER` | `8` | `32` |
-| `PHP_OPCACHE_MAX_ACCELERATED_FILES` | `10000` | `32531` |
-| `PHP_REALPATH_CACHE_TTL` | `120` | `600` |
-
-`CADDY_LOG_OUTPUT` and `CADDY_LOG_FORMAT` apply to the FrankenPHP variation only. See [Why we changed FrankenPHP logging](#why-we-changed-frankenphp-logging).
-
-| Variable | Version 4 | Version 5 |
-|----------|-----------|-----------|
-| `CADDY_LOG_OUTPUT` | `stdout` | `stderr` |
-| `CADDY_LOG_FORMAT` | `console` | `auto` |
-
-### New variables
-- `PHP_OPCACHE_ENABLE_CLI` - Whether CLI commands use OPcache when `PHP_OPCACHE_ENABLE=1`. Defaults to `1`, which is what Version 4 did. Set it to `0` to keep OPcache on for the web server only.
-- `PHP_OPCACHE_PRELOAD` - Path to a preload script. Symfony generates one for you and [recommends it](https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading){target="_blank"}.
-- `PHP_OPCACHE_PRELOAD_USER` - The user to preload as when the container runs as root.
-- `PHP_DISABLE_FUNCTIONS` - Comma-separated list of PHP functions to disable. Empty by default because Laravel, Composer, and Symfony Process rely on `proc_open`.
-- `PHP_FILE_UPLOADS` and `PHP_MAX_FILE_UPLOADS` - Turn HTTP file uploads off, or cap how many files one request can carry. Default to `On` and `20`.
-- `PHP_HTML_ERRORS` - Format on-screen errors as HTML when `PHP_DISPLAY_ERRORS` is on. Defaults to `On`.
-- `PHP_REALPATH_CACHE_SIZE` - Size of PHP's realpath cache. Defaults to `4096K`.
-- `PHP_SESSION_COOKIE_HTTPONLY` - Defaults to `On`. See the breaking change above.
-- `TRUSTED_PROXY` - Which proxy IPs to trust for the real client IP: `cloudflare` (default), `sucuri`, `local`, or `off`. Works on `fpm-nginx`, `fpm-apache`, and `frankenphp`.
-- `CADDY_ACME_PROFILE` - Select a Let's Encrypt certificate profile on FrankenPHP: `shortlived` (required for IP-address certificates), `tlsserver`, `classic`, or `off` (default).
-- `LARAVEL_OCTANE` - Set by Octane, not by you. The FrankenPHP Caddyfile uses it to switch into worker mode.
-- `MERCURE_ENABLED`, `MERCURE_TRUSTED_ISSUERS`, `MERCURE_PUBLISHER_JWT_KEY`, `MERCURE_SUBSCRIBER_JWT_KEY`, and `MERCURE_EXTRA_DIRECTIVES` - Run FrankenPHP's Mercure hub. See the breaking change above.
-- `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND` - Lets the container start when Laravel is not in `APP_BASE_DIR` yet, for example before the first `composer install`. Defaults to `false`.
-
-[See the full list of environment variables →](/docs/reference/environment-variable-specification)
-
-### New features in Version 5
-- **Laravel Octane uses our Caddyfile** - Pass `--caddyfile=/etc/frankenphp/Caddyfile` to `octane:start` and the image switches into worker mode with the same trusted proxy support, security headers, asset caching, SSL modes, and health check as classic mode. [Read the Octane guide →](/docs/framework-guides/laravel/octane)
-- **Trusted proxies on every web server** - `TRUSTED_PROXY` gives `fpm-nginx`, `fpm-apache`, and `frankenphp` the same Cloudflare, Sucuri, local, or off behavior, and all three resolve the client IP through more than one Docker hop. [Read the trusted proxies guide →](/docs/guide/configuring-trusted-proxies)
-- **Short-lived and IP-address certificates** - FrankenPHP can request Let's Encrypt's short-lived profile with `CADDY_ACME_PROFILE`. [Read about short-lived certificates →](/docs/deployment-and-production/configuring-ssl#short-lived--ip-address-certificates)
-- **Laravel Nightwatch health check** - `healthcheck-nightwatch` runs `php artisan nightwatch:status` so Docker can watch the agent. [Read the Nightwatch guide →](/docs/framework-guides/laravel/nightwatch)
-- **Mercure from environment variables** - Set `MERCURE_ENABLED=true` and your JWT keys to run FrankenPHP's Mercure hub, in classic mode or with Octane. [Read how to set up Mercure →](/docs/image-variations/frankenphp#mercure)
-- **Caddyfile rules from a folder** - Drop a `.caddyfile` into `/etc/frankenphp/caddyfile-server.d/` to add headers, redirects, or matchers to your app's site, in classic mode or with Octane. [Read how to add your own Caddyfile rules →](/docs/image-variations/frankenphp#adding-your-own-caddyfile-rules)
-- **FrankenPHP redacts the `authorization` query parameter** - Request logs never contain the JWT that Mercure 0.x subscribers pass in the URL, in every log format. [Read about FrankenPHP logging →](/docs/image-variations/frankenphp#logging)
-- **Every image is tested before it is published** - Each image is started on `amd64` and `arm64` and checked before it reaches Docker Hub. If one image fails, nothing from that build is published. [Read what happens when you open a pull request →](/docs/getting-started/contributing#what-happens-when-you-open-a-pull-request)
-
-### V5 Migration Checklist
-
-#### Docker Compose
-- Update the image tag
-- If your code is mounted as a volume with `PHP_OPCACHE_ENABLE=1`, either turn OPcache off for that environment or add `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`
-- If you deploy WordPress on a volume, add `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1` or restart the container after updates made outside the WordPress admin
-- Replace any `docker exec ... artisan optimize` style deployment steps with a container restart
-- If your app calls `session_start()` itself and reads the session cookie from JavaScript, add `PHP_SESSION_COOKIE_HTTPONLY=Off`
-- If you run FrankenPHP and something reads only `stdout`, add `CADDY_LOG_OUTPUT=stdout`. If something parses the `console` lines, or you prefer them when reading logs by eye, add `CADDY_LOG_FORMAT=console`. Skip both if you run Laravel Octane
-- If you run Laravel Octane, add `--caddyfile=/etc/frankenphp/Caddyfile` to your `octane:start` command and remove any `FRANKENPHP_CONFIG` worker block or `CADDY_PHP_SERVER_OPTIONS` you added to make Octane work
-- If you run the Mercure hub on FrankenPHP, set `MERCURE_ENABLED=true` and your keys with the `MERCURE_*` variables, and remove the `mercure` array from `config/octane.php`
-
-#### Dockerfile
-- If you append to `/etc/s6-overlay/s6-rc.d//dependencies` for `php-fpm`, `nginx`, or `apache2`, move each line to an empty file in that service's `dependencies.d/` directory
-- If you create `/etc/s6-overlay/s6-rc.d/user/contents.d/` to start your own S6 service, create it in `/etc/s6-overlay/user-bundles.d/user/contents.d/` instead
-- If you add `PHP_OPCACHE_PRELOAD`, prefer setting it on the running service rather than as an `ENV` in the Dockerfile, so build steps like `RUN composer install` do not depend on the preload script
-
-## Version 3 → Version 4 Migration
-Version 3 to Version 4 is a much easier migration compared to previous versions. There are **no breaking changes**, so you can simply update your image tag to the latest version and take advantage of the new features.
-
-### New Features in Version 4
-This release focused on expanding image variations, improving Laravel automations, and enhancing the developer experience. Here are the key features:
-
-- **FrankenPHP variation** - A new production-ready FrankenPHP variation with intelligent defaults, flexible environment configuration, native health checks, and support for Debian and Alpine operating systems.
-- **Revamped documentation site** - Completely rewritten documentation with improved navigation, better examples, and a modern user experience.
-- **Enhanced Laravel automations** - Refactored to use `php artisan optimize` by default (following Laravel best practices), with support for migration modes (`fresh`, `refresh`), database connection selection, seeding options, and easier debugging with `AUTORUN_DEBUG`.
-- **Expanded environment variables** - 25+ new environment variables for fine-tuning PHP, NGINX, Apache, and FrankenPHP configurations. [See the full list of environment variables →](/docs/reference/environment-variable-specification)
-- **Improved health checks** - Better container startup detection using `start-period` and `start-interval` for more accurate health readings.
-- **IPv6 support for NGINX** - Control IP listening protocols with `NGINX_LISTEN_IP_PROTOCOL` (supports `ipv4`, `ipv6`, or `all`).
-- **Enhanced file permissions script** - `docker-php-serversideup-set-file-permissions` now includes automated service detection and support for multiple directories with the `--dir` flag.
-- **Quieter logs** - Health check requests no longer appear in access logs for `fpm-nginx` and `fpm-apache` variations.
-
-### Quality of Life Improvements
-- **Startup scripts** - Improved handling of `entrypoint.d` scripts with better error handling and a redesigned container startup info display.
-- **FPM process control** - Default changed to `ondemand` for even lower resource usage in `fpm-nginx` and `fpm-apache` variations.
-- **Better Apache logs** - Access logs now include "Referer" and "User Agent" for better debugging.
-- **NGINX improvements** - Added `absolute_redirect off;` for better proxy compatibility, fixed `svgz` handling with Symfony's asset mapper, and allowed `robots.txt` to be dynamically generated by PHP.
-
-### V4 Migration Checklist
-Since there are no breaking changes, the migration is straightforward:
-
-#### Update Your Images
-Simply update your image tags to the latest version. For example:
-
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
-```
-
-No other changes are required unless you want to take advantage of new features.
-
-#### Optional: Leverage New Features
-
-**Consider enabling Laravel optimizations (if using Laravel):**
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- environment:
- AUTORUN_ENABLED: "true"
- AUTORUN_LARAVEL_OPTIMIZE: "true"
-```
-
-**Try the new FrankenPHP variation:**
-```yml [compose.yml]
-services:
- php:
- image: serversideup/php:8.5-frankenphp
- ports:
- - 80:8080
- - 443:8443
-```
-
-That's it! Version 4 is designed to be a smooth, non-breaking upgrade that gives you more flexibility and features when you need them.
-
-## Version 2 → Version 3 Migration
-If you're an existing user of our v2 images, be sure that your current configurations are NOT set to use the latest images. To do this, you can lock your images into the `v2.2.1` tag. This will ensure that you're not automatically upgraded to the v3 images.
-
-For example, if you are using `8.2-fpm-nginx`, you would change your `compose.yml` file to use the [`v2.2.1`](https://hub.docker.com/r/serversideup/php/tags?page=1&name=2.2.1){target="_blank"} tag:
-
-```yml [compose.yml] {3}
-services:
- php:
- image: serversideup/php:8.2-fpm-nginx
- ports:
- - 80:80
- volumes:
- - .:/var/www/html
-```
-
-```yml [compose.yml] {3}
-services:
- php:
- image: serversideup/php:8.2-fpm-nginx-v2.2.1
- ports:
- - 80:80
- volumes:
- - .:/var/www/html
-```
-
-All you need to do is add `-v2.2.1` to the end of the image tag. This will ensure that you're not automatically upgraded to the v3 images.
-
-### New Features in Version 3
-We've been busy overhauling our PHP Docker Images to make them more production-ready and easier to use. Here are some of the new features we've added:
-- **Based on official PHP Images** - We're now building an improved developer experience on top of the official PHP Docker images.
-- **Unprivileged by default** - We're now running our images as an unprivileged user by default. This is a huge step forward in security and compatibility.
-- **PHP 8.4 support** - We're now shipping the latest and greatest.
-- **Pin to the exact minor version** - Pin your app to the exact minor version of PHP that you want to use. This means you can pin to `8.2.12` instead of `8.2`.
-- **Easier start up script customization** - We now have a folder called `/etc/entrypoint.d` that allows you to easily customize your container with scripts. Just put them in numerical order and we'll execute any shell script you want. No S6 Overlay knowledge required.
-- **Expanded Laravel Automations** - We added automations to run `config:cache`, `route:cache`, `view:cache`, `event:cache`, `migrate --force --isolated`, and `storage:link`
-- **NGINX Unit Support** - We're offering NGINX Unit as a variation as an alternative to PHP-FPM. This allows you to run PHP applications without the need for a webserver like NGINX or Apache to run with PHP-FPM.
-- **Available on GitHub Packages** - We're now publishing our images to GitHub Packages. This means you can use our images without needing to authenticate with Docker Hub.
-
-### Breaking changes in Version 3
-::caution
-The following changes are considered to be "breaking changes" and will require you to make changes to your application.
-::
-
-#### Ubuntu is no longer used as a base image
-We now use Debian or Alpine as our base OS (because we're using the official PHP images as a base). This is a huge change, but we're confident this will be the best direction moving forward.
-
-#### `ppa:ondrej/php` is no longer used
-Since we're using PHP.net as the "official source of truth" for getting our PHP versions, this means we're also dropping support for the `ppa:ondrej/php` repository. If you're using things like `apt-get install php-redis` you will need to change your method of installing PHP extensions.
-
-[Learn how to install your own PHP extension →](/docs/customizing-the-image/installing-additional-php-extensions)
-
-#### `webuser` is no longer being used
-We used to add a user called `webuser` with the UID of `9999` with shell permissions. To increase security, we're now using the `www-data` user and group that is built into the official PHP images. If you have mounted volumes, you will need to `chown` the files to match the ID of the `www-data` user and groups. For Debian, this is `33:33` and for Alpine, this is `82:82`.
-
-#### NGINX and Apache listen on 8080 (HTTP) and 8443 (HTTPS) by default
-Our images are now unprivileged by default. This is a major step forward in security and compatibility. Since we are unprivileged by default, we lose the ability to mount on ports less than 1024. If you're using NGINX or Apache, you will need to update your port mappings to use `8080` and `8443` instead of `80` and `443`.
-
-[Learn more about this change →](/docs/getting-started/default-configurations#unprivileged-by-default)
-
-#### S6 Overlay is only used in `*-fpm-apache` and `*-fpm-nginx` images
-Due to compatibility issues, we only use S6 Overlay in our `*-fpm-apache` and `*-fpm-nginx` images. If you were using S6 Overlay for our other variations (cli, fpm, etc), you will need to migrate your scripts to use the new `/etc/entrypoint.d` folder.
-
-#### `SSL_MODE` is now set to `off` by default (HTTP only)
-Running end-to-end SSL by default created more problems than good. By default, we're now shipping HTTP-only by default with the option for people to turn this on.
-
-#### `AUTORUN_ENABLED` is now set to `false` by default.
-Having this set to "true" by default also created more problems than good. If you want to use any of the Laravel Automation Scripts, be sure to set this to `true`.
-
-#### MSMTP is no longer included in the images
-For security and image size reasons, we removed MSMTP from the images. If you need to send emails, use an external SMTP service like Postmark/Sendgrid/Mailgun. You can also extend the image yourself to include MSMTP specifically for your use case.
-
-#### Variable deprecations
-- `WEB_APP_DIRECTORY` has now been renamed to `APP_BASE_DIR`
-- `DEBUG_OUTPUT` has been removed for in favor of `LOG_OUTPUT_LEVEL=debug`
-- `PUID` & `PGID` are no longer used because it requires root privileges. See the [new way to set the UID and GID →](/docs/guide/understanding-file-permissions)
-- `MSMTP_RELAY_SERVER_HOSTNAME` & `MSMTP_RELAY_SERVER_PORT` are no longer used because MSMTP is no longer included in the images.
-- `PHP_POOL_NAME` has been renamed to `PHP_FPM_POOL_NAME`
-
-### V3 Migration Checklist
-Here is a good list to perform the V3 migration.
-
-#### Repository
-- Ensure you're committing to a test environment
-
-#### Docker Compose
-- Update the image name (if applicable)
-- Check each environment variable exists and is set to a proper value [See the full list of environment variables →](/docs/reference/environment-variable-specification)
-- Ensure you updated the ports to `8080` and `8443` for NGINX, Apache, and Unit
-- Consider adding `PHP_OPCACHE_ENABLE=1` to your production environment for increased performance
-
-#### Dockerfile
-- Update the base image name (if applicable)
-- Remove any `ppa:ondrej/php` references
-- Remove any Ubuntu specific commands
-- Ensure all extensions are installed with the `install-php-extensions` command [Learn how to install your own PHP extension →](/docs/customizing-the-image/installing-additional-php-extensions)
-- Ensure your `COPY` commands are copying with the correct permissions (i.e. `--chown=www-data:www-data`)
-
-#### CI/CD
-If you're running `fpm-nginx` (or similar) on a runner that's running as your builds as `root`, you may need to add `user = www-data` and `group = www-data` to your `php-fpm.conf` file so you can bring FPM up correctly.
-
-If you have to run things as root in CI, you can do this with a multi stage build and set the targets:
-
-
-```dockerfile [Dockerfile]
-############################################
-# Base Image
-############################################
-
-# Learn more about the Server Side Up PHP Docker Images at:
-# https://serversideup.net/open-source/docker-php/
-FROM serversideup/php:8.4-fpm-nginx AS base
-
-## Uncomment if you need to install additional PHP extensions
-# USER root
-# RUN install-php-extensions bcmath gd
-
-############################################
-# Development Image
-############################################
-FROM base AS development
-
-# We can pass USER_ID and GROUP_ID as build arguments
-# to ensure the www-data user has the same UID and GID
-# as the user running Docker.
-ARG USER_ID
-ARG GROUP_ID
-
-# Switch to root so we can set the user ID and group ID
-USER root
-RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
- docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID
-USER www-data
-
-############################################
-# CI image
-############################################
-FROM base AS ci
-
-# Sometimes CI images need to run as root
-USER root
-
-############################################
-# Production Image
-############################################
-FROM base AS deploy
-COPY --chown=www-data:www-data . /var/www/html
-USER www-data
-```
-
-#### Production/Staging Servers
-- Update all host volume file permissions to match the `www-data` UID/GID (`33:33` for Debian, `82:82` for Alpine) [Learn how to manage file permissions](/docs/guide/understanding-file-permissions)
-- If you're running Docker Swarm with host volume mounts, we created a script that could potentially help ([change-volume-permissions.sh](https://github.com/serversideup/docker-volume-change-permission-script))
-
-#### Deployment
-- CI/CD with valid tests is always encouraged
-- After completing all steps above, you're now ready to deploy the new images
diff --git a/docs/content/docs/5.guide/6.production-performance-tuning.md b/docs/content/docs/5.guide/6.production-performance-tuning.md
deleted file mode 100644
index 1aae13fea..000000000
--- a/docs/content/docs/5.guide/6.production-performance-tuning.md
+++ /dev/null
@@ -1,189 +0,0 @@
----
-head.title: 'Production Performance Tuning - Docker PHP - Server Side Up'
-description: 'The settings that matter for PHP performance in production, where our defaults come from, and how to measure and tune them for your application.'
-layout: docs
-title: Production performance tuning
----
-
-::lead-p
-This guide covers the settings that matter for performance once your application is in production, where our defaults come from, and how to tune them when your app needs more.
-::
-
-## PHP OPcache
-
-OPcache "improves PHP performance by storing precompiled script bytecode in shared memory, thereby removing the need for PHP to load and parse scripts on each request" ([PHP manual](https://www.php.net/manual/en/book.opcache.php){target="_blank"}). This means that every request skips the work of reading and parsing your PHP files, so it is faster. It also means that changes to your code are not seen until the cache is cleared, which is why we keep it off by default.
-
-### Turning it on
-
-OPcache is controlled by one switch, [`PHP_OPCACHE_ENABLE`](/docs/reference/environment-variable-specification).
-
-| Setting | What happens |
-|---------|--------------|
-| `PHP_OPCACHE_ENABLE=0` (default) | OPcache is off. Every request reads your files fresh, so edits show up right away when your code is mounted as a volume. |
-| `PHP_OPCACHE_ENABLE=1` | OPcache is on with the tuned defaults below. Files are compiled once and cached until the container restarts. |
-
-We keep OPcache off by default so nobody loses an afternoon wondering why a change is not showing up. Turn it on anywhere you are not editing code in place, such as production or staging:
-
-```yml [compose.yml] {5}
-services:
- php:
- image: serversideup/php:8.5-fpm-nginx
- environment:
- PHP_OPCACHE_ENABLE: "1"
-```
-
-::note
-CLI commands like `php artisan` also use OPcache when `PHP_OPCACHE_ENABLE_CLI=1`, which is the default. A CLI process gets its own cache that ends when the process exits, so the gain there is optimized code rather than caching. Set `PHP_OPCACHE_ENABLE_CLI=0` to keep OPcache on for the web server only.
-::
-
-### Defaults
-
-When `PHP_OPCACHE_ENABLE=1`, these are the defaults you get. Override any of them with the same variable:
-
-| Variable | Default | What it controls |
-|----------|---------|------------------|
-| `PHP_OPCACHE_VALIDATE_TIMESTAMPS` | `0` | Whether OPcache checks if a file changed. `0` caches files until the container restarts, so `PHP_OPCACHE_REVALIDATE_FREQ` is never used. |
-| `PHP_OPCACHE_MEMORY_CONSUMPTION` | `256` | Size of the shared memory segment in megabytes. |
-| `PHP_OPCACHE_INTERNED_STRINGS_BUFFER` | `32` | Megabytes inside that segment for interned strings. |
-| `PHP_OPCACHE_MAX_ACCELERATED_FILES` | `32531` | Maximum number of files in the cache. |
-| `PHP_OPCACHE_FORCE_RESTART_TIMEOUT` | `180` | Seconds to wait for a scheduled restart before OPcache forces it. |
-| `PHP_OPCACHE_SAVE_COMMENTS` | `1` | Keep doc comments in the cache. Disabling it "may break applications and frameworks that rely on comment parsing for annotations, including Doctrine, Zend Framework 2 and PHPUnit." |
-| `PHP_OPCACHE_ENABLE_FILE_OVERRIDE` | `0` | Let OPcache answer `file_exists()` from its cache. Left off because it "risks returning stale data if opcache.validate_timestamps is disabled." |
-| `PHP_OPCACHE_JIT` | `off` | The JIT compiler. See [JIT](#jit). |
-| `PHP_OPCACHE_JIT_BUFFER_SIZE` | `0` | Memory for JIT code. |
-| `PHP_OPCACHE_PRELOAD` | `""` | Path to a preload script. See [Preloading](#preloading). |
-| `PHP_OPCACHE_PRELOAD_USER` | `""` | User to run the preload script as. Only needed when running as root. |
-
-These are not our numbers. The memory size, file count, interned strings buffer, and `validate_timestamps=0` are the values in [Symfony's performance guide](https://symfony.com/doc/current/performance.html#configure-opcache-for-maximum-performance){target="_blank"}, which [FrankenPHP's performance guide](https://frankenphp.dev/docs/performance/){target="_blank"} recommends "even if you don't use Symfony." Laravel does not publish OPcache values, but [Laravel Forge's OPcache option](https://laravel.com/forge/docs/servers/php#opcache){target="_blank"} asks the same thing of you that this configuration does: reload PHP after every deployment. Every variable links to the PHP manual in the [environment variable specification](/docs/reference/environment-variable-specification).
-
-The most important one is `PHP_OPCACHE_VALIDATE_TIMESTAMPS=0`. Symfony's guide puts it plainly: "In production servers, PHP files should never change, unless a new application version is deployed." When your code is built into the image, checking for changes is wasted work.
-
-### Memory
-
-OPcache reserves one shared memory segment of `PHP_OPCACHE_MEMORY_CONSUMPTION` megabytes when PHP starts. The interned strings buffer lives inside that segment. With the defaults, 256 MB is reserved, 32 MB of it holds interned strings, and 224 MB holds compiled code. The JIT buffer is different: when the JIT is on, PHP adds `PHP_OPCACHE_JIT_BUFFER_SIZE` on top, so "its total size is this value plus opcache.jit_buffer_size" ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.memory-consumption){target="_blank"}).
-
-Reserving memory is not the same as using it. The container only pays for pages that are written, and every PHP-FPM worker shares the same segment.
-
-### Measuring
-
-PHP reports how full the cache is. Put this file in your public directory and open it in a browser or with `curl`. It has to go through the web server, since the CLI has its own cache:
-
-```php [public/opcache-status.php]
- round($bytes / 1048576) . ' MB';
-$files = "{$stats['num_cached_keys']} of {$stats['max_cached_keys']}";
-
-$report = [
- 'Cache full' => $status['cache_full'] ? 'yes' : 'no',
- 'Cached files' => $files,
- 'Memory used' => $toMegabytes($memory['used_memory']),
- 'Memory free' => $toMegabytes($memory['free_memory']),
- 'Interned strings free' => $toMegabytes($strings['free_memory']),
- 'Out of memory restarts' => $stats['oom_restarts'],
- 'Hash restarts' => $stats['hash_restarts'],
-];
-
-foreach ($report as $label => $value) {
- echo "$label: $value
-";
-}
-```
-
-::caution
-This file exposes cache and memory statistics. Remove it before deploying, or protect it.
-::
-
-Two of these numbers are available before you serve real traffic. Count the PHP files in your image with `find /var/www/html -name '*.php' | wc -l` for an upper bound on the file limit, and run your smoke tests or a [preload script](#preloading) to fill the cache before you read the memory numbers. Then check:
-
-- **Cache full is `yes`** or **out of memory restarts** is climbing: raise `PHP_OPCACHE_MEMORY_CONSUMPTION`.
-- **Cached files** is close to the maximum or **hash restarts** is climbing: raise `PHP_OPCACHE_MAX_ACCELERATED_FILES`.
-- **Interned strings free** is near zero: raise `PHP_OPCACHE_INTERNED_STRINGS_BUFFER`.
-
-Restarts are what you want to avoid. When OPcache runs out of room it restarts and clears the whole cache, so requests are slower until it fills back up.
-
-### Deploying
-
-With OPcache enabled, files are cached until the container restarts, so every deployment should start a new container. The PHP manual is direct about this: with timestamps off, "you must reset OPcache manually via opcache_reset(), opcache_invalidate() or by restarting the Web server for changes to the filesystem to take effect" ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.validate-timestamps){target="_blank"}).
-
-A few things that catch people out:
-
-- **Running cache commands in a live container** like `docker exec php artisan optimize` writes new files, but the running web workers keep serving the old cached copies. Restart the container instead.
-- **`opcache_reset()` from the CLI does nothing for the web server.** The CLI has its own cache in its own process.
-- **Mounting code as a volume with OPcache enabled** means your edits will not show up. Leave OPcache off in development, or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`.
-
-::note
-If you need change detection with OPcache enabled, `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1` restores the Version 4 behavior with a check every `PHP_OPCACHE_REVALIDATE_FREQ` seconds.
-::
-
-### Framework notes
-
-#### Laravel
-
-Our [Laravel automations](/docs/framework-guides/laravel/automations) run `php artisan optimize` in the entrypoint before the web server starts. That includes `view:cache`, which "precompiles all your Blade views so they are not compiled on demand" ([Laravel docs](https://laravel.com/docs/12.x/deployment#optimizing-view-loading){target="_blank"}), so the cached config, routes, events, and views are on disk before OPcache sees them.
-
-Queue workers, Horizon, the scheduler, Reverb, and Octane on Swoole or RoadRunner are CLI processes, so they follow `PHP_OPCACHE_ENABLE_CLI`. Laravel's own advice applies to all of them: after a deploy, "any long-running services such as queue workers, Laravel Reverb, or Laravel Octane should be reloaded / restarted to use the new code" ([Laravel docs](https://laravel.com/docs/12.x/deployment#reloading-services){target="_blank"}). A container restart does that.
-
-#### WordPress
-
-WordPress clears OPcache for the files it writes during core, plugin, and theme updates through the admin, using [`wp_opcache_invalidate()`](https://developer.wordpress.org/reference/functions/wp_opcache_invalidate/){target="_blank"}, so those updates keep working. Files changed outside of WordPress (`git pull`, WP-CLI, SFTP, or plugins that write PHP files directly) are not picked up until the container restarts. If you use the [volume-based approach](/docs/framework-guides/wordpress/using-wordpress-with-docker#volume-based-approach-traditional), restart after those changes or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`.
-
-### Preloading
-
-Preloading compiles a set of files into OPcache when PHP starts and keeps them there "until the server is shut down" ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.preload){target="_blank"}). Requests skip the autoloader for those classes. [Symfony generates a preload file for you](https://symfony.com/doc/current/performance.html#use-the-opcache-class-preloading){target="_blank"} at `config/preload.php`. Laravel does not ship one.
-
-Point `PHP_OPCACHE_PRELOAD` at your script:
-
-```yml [compose.yml] {6}
-services:
- php:
- image: my-app:latest
- environment:
- PHP_OPCACHE_ENABLE: "1"
- PHP_OPCACHE_PRELOAD: "/var/www/html/config/preload.php"
-```
-
-Things to know before you turn it on:
-
-- **The file must exist.** PHP refuses to start if the preload script is missing or throws.
-- **Preloading as root needs `PHP_OPCACHE_PRELOAD_USER`.** Our images run as an unprivileged user, so this only matters if you run the container as root. The CLI is exempt on PHP 8.3 and newer ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.preload-user){target="_blank"}).
-- **The CLI runs it too.** With `PHP_OPCACHE_ENABLE_CLI=1`, every `php` command preloads before it runs, so the script must work outside of a web request. Set `PHP_OPCACHE_ENABLE_CLI=0` if you do not want that. Set `PHP_OPCACHE_PRELOAD` on your running service rather than as an `ENV` in your Dockerfile, so build steps like `RUN composer install` do not depend on it.
-- **Preloaded files never change.** Even with `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`, preloaded code stays until restart.
-
-### JIT
-
-The JIT compiler is off by default, as it is in PHP itself ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.jit){target="_blank"}). The [PHP JIT RFC](https://wiki.php.net/rfc/jit){target="_blank"} found it "doesn't seem to significantly improve real-life apps like WordPress" while the benefit in "non-Web, CPU-intensive scenarios" is "very substantial." Xdebug also turns it off: "When Xdebug is loaded with PHP's JIT on, you will get a warning, and JIT will be disabled" ([Xdebug docs](https://xdebug.org/docs/compat){target="_blank"}).
-
-To enable it, set both variables. The buffer is added on top of `PHP_OPCACHE_MEMORY_CONSUMPTION`, so `64M` with the default makes a 320 MB segment:
-
-```yml [compose.yml] {6-7}
-services:
- php:
- image: my-app:latest
- environment:
- PHP_OPCACHE_ENABLE: "1"
- PHP_OPCACHE_JIT: "tracing"
- PHP_OPCACHE_JIT_BUFFER_SIZE: "64M"
-```
-
-Measure before and after. If your response times do not move, leave it off.
-
-### Advanced settings
-
-If there are options that you don't see in the environment variables, you can set them through a [custom ini file](/docs/customizing-the-image/changing-common-php-settings). For example, in some ocasions you may want to set:
-
-- `opcache.huge_code_pages=1` copies compiled code into huge pages. It "requires appropriate OS configuration" ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.huge_code_pages){target="_blank"}).
-- `opcache.file_cache=/path` adds a second-level cache on disk that helps "at server restart or SHM reset" ([PHP manual](https://www.php.net/manual/en/opcache.configuration.php#ini.opcache.file-cache){target="_blank"}).
-
-## PHP realpath cache
-
-PHP caches the resolved path of every file it opens so it does not repeat the lookup on each request. Symfony's guide pairs this cache with OPcache and says applications that open many PHP files "should use at least" `realpath_cache_size=4096K` and `realpath_cache_ttl=600` ([Symfony docs](https://symfony.com/doc/current/performance.html#configure-the-php-realpath-cache){target="_blank"}). Our images ship both as `PHP_REALPATH_CACHE_SIZE` and `PHP_REALPATH_CACHE_TTL`. PHP disables this cache when `open_basedir` is set, so leave `PHP_OPEN_BASEDIR` empty in production unless you need it.
-
-::tip{to="/docs/reference/environment-variable-specification"}
-The full list of variables and their defaults is in the environment variable specification.
-::
diff --git a/docs/content/docs/6.customizing-the-image/1.changing-common-php-settings.md b/docs/content/docs/6.customizing-the-image/1.changing-common-php-settings.md
deleted file mode 100644
index 08cc6ddc0..000000000
--- a/docs/content/docs/6.customizing-the-image/1.changing-common-php-settings.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-head.title: 'Changing common PHP settings - Docker PHP - Server Side Up'
-description: 'Learn how to change common PHP settings with environment variables or your own php.ini file.'
-layout: docs
-title: Changing php.ini settings
----
-
-::lead-p
-Instead of going through the effort of writing custom scripts or mounting files to change PHP settings, have the power to change common settings with the simplicity of an environment variable.
-::
-
-## Common Examples
-All our environment variables are documented and can be found in our environment variable specification documentation.
-
-:u-button{to="/docs/reference/environment-variable-specification" label="Environment Variable Specification" aria-label="Environment Variable Specification" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-Here are a few examples on how you can change common PHP settings.
-
-```yml [compose.yml] {4-6}
-services:
- php:
- image: serversideup/php:8.2.12-fpm-nginx-bookworm
- environment:
- PHP_POST_MAX_SIZE: "500M"
- PHP_UPLOAD_MAX_FILE_SIZE: "500M"
- SSL_MODE: "mixed"
- ports:
- - 80:8080
- - 443:8443
- volumes:
- - .:/var/www/html/
-```
-
-You can also adjust environment variables using the Docker CLI.
-
-```bash [Terminal]
-docker run -d \
- -p 80:8080 \
- -v $(pwd):/var/www/html \
- -e PHP_DATE_TIMEZONE="America/New_York" \
- serversideup/php:8.2.12-fpm-nginx-bookworm
-```
-
-## Setting your own php.ini
-PHP will read the `php.ini` file from the `/usr/local/etc/php/conf.d/` directory in alphabetical order. This means you can create your own `php.ini` file and mount it to the container to override the default settings.
-
-For example, we can create this file in our project directory:
-```ini [zzz-custom-php.ini]
-mysqli.max_persistent = 300
-opcache.max_file_size = 10M
-opcache.log_verbosity_level = 3
-```
-
-Then in our Dockerfile, we can copy this file to the `/usr/local/etc/php/conf.d/` directory:
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx-bookworm
-
-COPY zzz-custom-php.ini /usr/local/etc/php/conf.d/
-```
-
-If you prefer to remove the default `php.ini` file, you can do so by adding the following line to your Dockerfile:
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx-bookworm
-
-RUN rm /usr/local/etc/php/conf.d/serversideup-docker-php.ini
-COPY zzz-custom-php.ini /usr/local/etc/php/conf.d/
-```
-
-## Validating changes
-It's always best to validate your changes by running `php -i` via the command line or using [`phpinfo()`](https://www.php.net/manual/en/function.phpinfo.php){target="_blank"}.
\ No newline at end of file
diff --git a/docs/content/docs/6.customizing-the-image/2.installing-additional-php-extensions.md b/docs/content/docs/6.customizing-the-image/2.installing-additional-php-extensions.md
deleted file mode 100644
index f89170436..000000000
--- a/docs/content/docs/6.customizing-the-image/2.installing-additional-php-extensions.md
+++ /dev/null
@@ -1,115 +0,0 @@
----
-head.title: 'Installing additional PHP extensions - Docker PHP - Server Side Up'
-description: 'Learn how to add any PHP extension that you need for your application.'
-layout: docs
-title: Installing PHP extensions
----
-
-::lead-p
-serversideup/php includes the [`install-php-extensions`](https://github.com/mlocati/docker-php-extension-installer){target="_blank"} tool by default. This tool allows you to install almost any PHP module that you'll need.
-::
-
-## Default extensions
-By default, we include a number of PHP extensions to get you up and running. You can learn more why we have certain defaults and what's all included on our default configurations page.
-
-:u-button{to="/docs/getting-started/default-configurations#default-php-extensions" label="Learn more about default extensions" aria-label="Learn more about default extensions" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## What extensions are supported?
-Since we're using [`install-php-extensions`](https://github.com/mlocati/docker-php-extension-installer){target="_blank"}, we have a wide support of extensions across many versions of PHP. You can find the full list of supported extensions on the project's README.
-
-:u-button{to="https://github.com/mlocati/docker-php-extension-installer#supported-php-extensions" label="View the supported extensions" aria-label="View the supported extensions" size="md" color="primary" variant="outline" target="_blank" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Installing extensions
-Once you have your extensions ready for installation, you need to use `root` permissions to install them. In most cases, the best experience is to use a `Dockerfile` to do this while you package your application in a container.
-
-If you're not familiar with the concept of packaging your application for deployment, we recommend you to read our guide on how to do it.
-
-:u-button{to="/docs/deployment-and-production/packaging-your-app-for-deployment" label="Learn more about packaging your application for deployment" aria-label="Learn more about packaging your application for deployment" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-### Preparing your Dockerfile
-::warning
-**Our images are unprivileged by default.** This means you'll need to switch to `root` to do "root things", then switch back to the `www-data` user. This ensures your container image is hardened against security vulnerabilities.
-::
-
-```dockerfile [Dockerfile]
-# Choose our base image
-FROM serversideup/php:8.5-fpm-nginx
-
-# Switch to root so we can do root things
-USER root
-
-# Install the intl and bcmath extensions with root permissions
-RUN install-php-extensions intl bcmath
-
-# Drop back to our unprivileged user
-USER www-data
-```
-
-## Building images with Docker Compose
-Here's a simple example with Docker Compose that builds an image with the `intl` and `bcmath` extensions.
-
-::code-tree{defaultValue="Dockerfile"}
-
-```dockerfile [Dockerfile]
-# Choose our base image
-FROM serversideup/php:8.5-fpm-nginx
-
-# Switch to root so we can do root things
-USER root
-
-# Install the intl and bcmath extensions with root permissions
-RUN install-php-extensions intl bcmath
-
-# Drop back to our unprivileged user
-USER www-data
-```
-
-```yml [compose.yml]
-services:
- php:
- # Use "build" instead of "image"
- build:
- # Use the Dockerfile in the current directory
- context: .
- dockerfile: Dockerfile
- # Expose localhost:80 to NGINX's port 8080
- ports:
- - 80:8080
- # Mount current directory to /var/www/html
- volumes:
- - ./:/var/www/html
-```
-
-```php [public/index.php]
-
-```
-::
-
-Once we have our project ready, we can bring our container up with:
-
-::tip
-We use the `--build` flag to tell Docker to rebuild the image from scratch. Good practice in development if you're making changes to your Dockerfile.
-::
-
-```bash [Terminal]
-docker compose up --build
-```
-
-## Real-life example showing development to production
-If you're looking for a more realistic example how this looks from development to production, check out our guide below.
-
-:u-button{to="/docs/deployment-and-production/development-to-production" label="Learn more about development to production" aria-label="Learn more about development to production" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Common PHP extensions that you might need
-We compiled a list of extensions for you to reference.
-
-| Extension | Description | Why it's not included by default |
-|-----------|-------------|--------------------------------
-[intl](https://www.php.net/manual/en/intro.intl.php){target="_blank"} | Internationalization functions, [used by Laravel for validating emails](https://laravel.com/docs/10.x/validation#rule-email){target="_blank"} with "DNS" or "spoof" validation. | Our tests showed this module will add about 40 MB of space to the Docker image, so we decided to not include it by default. |
-
-::tip{to="https://github.com/serversideup/docker-php/discussions/new?category=q-a" target="_blank"}
-Don't see the extension you need? Having trouble? [Open a discussion on GitHub →](https://github.com/serversideup/docker-php/discussions/new?category=q-a)
-::
diff --git a/docs/content/docs/6.customizing-the-image/3.adding-your-own-start-up-scripts.md b/docs/content/docs/6.customizing-the-image/3.adding-your-own-start-up-scripts.md
deleted file mode 100644
index 80dc1c1d4..000000000
--- a/docs/content/docs/6.customizing-the-image/3.adding-your-own-start-up-scripts.md
+++ /dev/null
@@ -1,175 +0,0 @@
----
-head.title: 'Adding your own start up scripts - Docker PHP - Server Side Up'
-description: 'Learn how to use our entrypoint.d directory to customize your container start up experience.'
-layout: docs
-title: Adding Start Up Scripts
----
-::lead-p
-We provide a few [default entrypoint scripts](/docs/getting-started/default-configurations#default-entrypoint-scripts) to get you going, but sometimes you want to just add your own. We've made it easy to do that with our `entrypoint.d` directory.
-::
-
-## Entrypoint Script Requirements
-::note
-Before you write your entry point script, be aware of the following requirements. Your script should:
-- Be executable (755 permissions)
-- Located in the `/etc/entrypoint.d` directory
-- Have the file extension ending in `.sh`
-::
-::tip
-We recommend writing your script in `/bin/sh` for the best compatibility between Alpine and Debian. If you choose to use `/bin/bash`, your script will only be able to run on Debian-based images.
-::
-
-## Choose your execution order
-Since [we provide default entrypoint scripts](/docs/getting-started/default-configurations#default-entrypoint-scripts), you may want to choose the order in which your scripts are executed. We've made it easy to do that by prefixing your script with a number. The lower the number, the earlier it will be executed.
-
-::tip
-If you want to disable our entrypoint scripts, you can set `DISABLE_DEFAULT_CONFIG` to `true` in your environment variables.
-::
-
-## Long running services
-::note
-Don't use entrypoint scripts for long-running services. You want your services to be monitored and restarted if they crash.
-::
-
-Anything in the `/etc/entrypoint.d` directory are scripts that are intended to run quickly and then move on. If you run a service as an entrypoint script, that service may crash and not be restarted.
-
-Instead, learn about [using S6 overlay](/docs/guide/using-s6-overlay) so your services can be properly initialized and monitored. See the [S6 Overylay project](https://github.com/just-containers/s6-overlay) for more details on how to write your own S6 service.
-
-## Example: Create a custom entrypoint script
-In this example, let's create a `99-my-script.sh` so it executes after all the other default scripts.
-
-::code-tree{defaultValue="Dockerfile"}
-
-```bash [entrypoint.d/99-my-script.sh]
-#!/bin/sh
-echo "👋 Hello, world!"
-```
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx
-
-# Copy our scripts as executable
-COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
-```
-
-```yml [compose.yml]
-services:
- php:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - 80:8080
- volumes:
- - .:/var/www/html
-```
-
-```php [public/index.php]
-
-```
-
-::
-
-In the example above, you can see in the `Dockerfile` we are copying our `entrypoint.d` directory to `/etc/entrypoint.d/` in the container. We're also setting the permissions to `755` so our scripts are executable.
-
-## Running our example
-When we run `docker compose up`, we can confirm our script is executing by checking the logs:
-
-::code-panel
----
-label: "Output of \"docker compose up\""
----
-```txt
-php-1 | 👉 [NOTICE]: Improve PHP performance by setting PHP_OPCACHE_ENABLE=1 (recommended for production).
-php-1 | (init-webserver-config): Processing /etc/nginx/nginx.conf.template → /etc/nginx/nginx.conf...
-php-1 | (init-webserver-config): Processing /etc/nginx/site-opts.d/http.conf.template → /etc/nginx/site-opts.d/http.conf...
-php-1 | (init-webserver-config): Processing /etc/nginx/site-opts.d/https.conf.template → /etc/nginx/site-opts.d/https.conf...
-php-1 | ℹ️ NOTICE (init-webserver-config): Enabling NGINX site with SSL "off"...
-php-1 | 👋 Hello, world!
-php-1 | [03-Nov-2025 19:35:29] NOTICE: fpm is running, pid 93
-php-1 | [03-Nov-2025 19:35:29] NOTICE: ready to handle connections
-php-1 | HTTP Status Code: 200
-php-1 | ✅ NGINX + PHP-FPM is running correctly.
-```
-::
-
-You can see our `👋 Hello, world!` is executing *after* the initialization of `10-init-webserver-config.sh`.
-
-## Running Custom Artisan Commands
-If you need to run a custom artisan command at start up, you could update your `entrypoint.d/99-my-script.sh` file to look like this:
-
-```bash [entrypoint.d/99-my-script.sh]
-#!/bin/sh
-# Check if the artisan file exists
-if [ -f "$APP_BASE_DIR/artisan" ]; then
- # Run the custom artisan command
- php "$APP_BASE_DIR/artisan" my:custom-command
-else
- # If the artisan file is not found, stop the container
- echo "❌ Artisan file not found in $APP_BASE_DIR"
- exit 1
-fi
-
-# Exit with a success code
-exit 0
-```
-
-#### Best practices for running custom artisan commands
-- Use `/bin/sh` for the best compatibility between Alpine and Debian. If you choose to use `/bin/bash`, your script will only be able to run on Debian-based images.
-- Check to see if the artisan file exists before running the command
-- Exit with a success code
-- Name your file appropriately, so it executes in logic order with the other scripts (see our [default configurations](/docs/getting-started/default-configurations#default-entrypoint-scripts))
-
-You can change the name of your file to whatever you want, but we recommend using a number prefix to ensure it executes in the correct order. For example, if you want to run a command after the default scripts, you could name your file `99-my-command.sh`. If you want to run a command before the default scripts, you could name your file `1-my-command.sh`.
-
-## Advanced Scenarios: S6 Overlay dependencies
-If you want to customize an image that uses S6 Overlay (`fpm-nginx` or `fpm-apache`), you may have an advanced scenario where you have a custom S6 service that needs to be executed after one of our entrypoint scripts. In order to do this, you'll need to move all our scripts from the `/etc/entrypoint.d` directory to the `/etc/s6-overlay/scripts` directory and declare each one as an S6 service. This would be a very time consuming scenario if you did this manually, but thankfully you can use our `docker-php-serversideup-s6-init` script to do this for you.
-
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx
-
-# Set the user to root for our build steps
-USER root
-
-# If you have your own one-shot scripts, copy them to the entrypoint.d directory
-COPY --chmod=755 ./entrypoint.d/ /etc/entrypoint.d/
-
-# Convert every entrypoint script into an S6 Overlay service
-RUN docker-php-serversideup-s6-init
-
-# If you have your own long running services, copy them to the s6 directory
-COPY --chmod=755 ./my-s6-service/ /etc/s6-overlay/s6-rc.d/my-s6-service/
-
-# Add your service to the "user" bundle so S6 Overlay starts it with the container
-RUN touch /etc/s6-overlay/user-bundles.d/user/contents.d/my-s6-service
-
-# Drop back to the non-root user
-USER www-data
-```
-
-In the above file, we're copying our "one-shot" scripts to the `/etc/entrypoint.d` directory and our long running services to the `/etc/s6-overlay` directory. One-shot scripts are scripts that are intended to run quickly and then move on. Long running services are services that are intended to run for a long time and need to be monitored and restarted if they crash.
-
-::warning
-S6 Overlay only starts the services listed in the `user` bundle. If you skip the empty file in `/etc/s6-overlay/user-bundles.d/user/contents.d/`, your service will never start. S6 Overlay v3.2.3.2 moved this bundle from `/etc/s6-overlay/s6-rc.d/user`, so if your Dockerfile still uses the old path, [see the migration guide](/docs/guide/major-version-migrations).
-::
-
-The magic happens when we run `docker-php-serversideup-s6-init`. This script:
-
-- Moves every script from the `/etc/entrypoint.d` directory to the `/etc/s6-overlay/scripts` directory
-- Declares each one as a one-shot S6 service named after the file without the `.sh` extension (for example, `10-init-webserver-config.sh` becomes the `10-init-webserver-config` service)
-- Runs the services one after another, in the same order the entrypoint would have run the scripts
-- Makes `php-fpm`, `nginx`, and `apache2` wait for the last script to finish, the same way the entrypoint finishes every script before it starts them
-- Can run again in an image built on yours. New scripts are ordered with the ones converted earlier, and a script with the same name replaces the earlier one, just like a `COPY` would in `/etc/entrypoint.d`
-
-You can now reference our script names as dependencies in your own S6 service. Declare each dependency as an empty file in your service's `dependencies.d` directory. S6 Overlay recommends every service also depend on `base` to prevent race conditions during container start up ([see the S6 Overlay docs](https://github.com/just-containers/s6-overlay#writing-a-service-script){target="_blank"}).
-
-```sh
-mkdir -p /etc/s6-overlay/s6-rc.d/my-s6-service/dependencies.d
-touch /etc/s6-overlay/s6-rc.d/my-s6-service/dependencies.d/base
-touch /etc/s6-overlay/s6-rc.d/my-s6-service/dependencies.d/10-init-webserver-config
-```
-
-:u-button{to="https://github.com/just-containers/s6-overlay" label="Learn more about S6 Overlay" aria-label="Learn more about S6 Overlay" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500" target="_blank"}
diff --git a/docs/content/docs/4.deployment-and-production/99.platforms/.navigation.yml b/docs/content/docs/6.upgrading/.navigation.yml
similarity index 63%
rename from docs/content/docs/4.deployment-and-production/99.platforms/.navigation.yml
rename to docs/content/docs/6.upgrading/.navigation.yml
index 85283371c..3b51b58ef 100644
--- a/docs/content/docs/4.deployment-and-production/99.platforms/.navigation.yml
+++ b/docs/content/docs/6.upgrading/.navigation.yml
@@ -1,3 +1,3 @@
-title: Platforms
+title: Upgrading
icon: false
defaultOpen: false
\ No newline at end of file
diff --git a/docs/content/docs/6.upgrading/1.upgrade-guide.md b/docs/content/docs/6.upgrading/1.upgrade-guide.md
new file mode 100644
index 000000000..2523a753f
--- /dev/null
+++ b/docs/content/docs/6.upgrading/1.upgrade-guide.md
@@ -0,0 +1,150 @@
+---
+head.title: 'Upgrade guide - Docker PHP - Server Side Up'
+description: 'How serversideup/php releases and tags work, which tag to use, and how to patch an image yourself.'
+layout: docs
+title: Upgrade guide
+---
+
+::lead-p
+Your image tag decides how updates reach you. Floating tags are rebuilt for you. Version-pinned tags never change.
+::
+
+Watch the [GitHub repository](https://github.com/serversideup/docker-php) for releases (**Watch** → **Custom** → **Releases**) so you hear about changes whichever tag you use. Release notes for every version are on [GitHub releases](https://github.com/serversideup/docker-php/releases).
+
+{.max-w-md.w-full.h-auto.mx-auto}
+
+## When images are rebuilt
+
+Code merges into `main` and ships first as a beta pre-release. After testing, we publish a GitHub release, which builds every image and pushes it to Docker Hub and GitHub Packages. Two other triggers rebuild the floating tags between releases.
+
+| Trigger | When | Floating tags | Version-pinned tags |
+|---|---|---|---|
+| GitHub release | When we publish one | ✅ Updated | ✅ Created |
+| Weekly rebuild | Tuesdays at 08:00 UTC, for OS security updates | ✅ Updated | ❌ |
+| New PHP patch | Hourly check of php.net starts a build; published once it passes tests | ✅ Updated | ❌ |
+
+## Floating and version-pinned tags
+
+A tag that ends in a release version, such as `-v5.0.5`, is version-pinned. Every other tag floats.
+
+| | Floating | Version-pinned |
+|---|---|---|
+| Example | `serversideup/php:8.5-fpm-nginx` | `serversideup/php:8.5-fpm-nginx-v5.0.5` |
+| Rebuilt | On every trigger above | Never. Written once, on release. |
+| Security updates | Run `docker pull` | [Apply them yourself](#apply-your-own-security-updates) |
+| Use it when | You want patches without extra work | You need reproducible builds (compliance, air-gapped hosts) and have your own patch process |
+
+For most projects, use a floating tag on a PHP minor version, such as `8.5-fpm-nginx`. PHP stays on 8.5 while patches and OS updates arrive. Test a pulled image before it reaches production.
+
+How specific the PHP version in a floating tag is changes what you get:
+
+- `8-fpm-nginx` follows the newest PHP 8.x minor.
+- `8.5-fpm-nginx` follows the newest 8.5.x patch.
+- `8.5.12-fpm-nginx` stays on PHP 8.5.12. It gets the weekly OS updates until 8.5.13 ships, then it is no longer rebuilt.
+
+`latest` is the `cli` variation on the newest stable PHP. Don't use it for an app. [Choosing an image](/docs/getting-started/choosing-an-image) lists every variation, operating system, and tag.
+
+## Apply your own security updates
+
+Patch an image yourself when a CVE is disclosed after your version-pinned tag was released. Do the same on a floating tag when you need a fix before the next rebuild. Either way, extend our image in your own Dockerfile and test the result before you deploy it.
+
+### Update operating system packages
+
+The images run as the unprivileged `www-data` user. Switch to `root` for the package manager, then back.
+
+::code-group
+```dockerfile [Debian]
+FROM serversideup/php:8.5-fpm-nginx-v5.0.5
+
+USER root
+RUN apt-get update \
+ && apt-get upgrade -y \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*
+USER www-data
+```
+
+```dockerfile [Alpine]
+FROM serversideup/php:8.5-fpm-nginx-alpine-v5.0.5
+
+USER root
+RUN apk update \
+ && apk upgrade \
+ && rm -rf /var/cache/apk/*
+USER www-data
+```
+::
+
+### Update Composer
+
+Every variation copies Composer from the [official `composer` image](https://hub.docker.com/_/composer). Copy a newer one over it the same way:
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-cli-v5.0.5
+
+COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
+```
+
+Replace `composer:2` with an exact version such as `composer:2.9.8` if you need reproducible builds.
+
+::warning{title="Web servers come from package repositories"}
+- Apache comes from the OS repositories, so `apt-get upgrade` only gets the version your Debian release ships.
+- NGINX comes from the nginx.org repository at the version our build config sets.
+- For a newer web server, pull a newer floating tag or move to a newer base OS, for example from `bookworm` to `trixie`.
+::
+
+## PHP and OS versions we no longer build
+
+We build an end-of-life PHP version while its official docker-library Dockerfile exists and the OS underneath still gets security updates. Once the OS is end of life too, a rebuild can't deliver patches. That's when we stop. The tags stay pullable but freeze at their last build. The full rule is in our [security policy](https://github.com/serversideup/docker-php/blob/main/SECURITY.md#component-lifecycle-references).
+
+| Version | Last built | Why |
+|---|---|---|
+| PHP 8.0 | 2026-09-03 | Only shipped on Debian Bullseye and Alpine 3.16, both end of life. Upstream stopped building it in November 2023. |
+| PHP 7.4 | 2026-09-03 | Same bases as 8.0. Upstream stopped building it in November 2022. |
+| Debian Bullseye | 2026-09-03 | Debian 11 LTS ended on 2026-08-31, and Debian removed its packages from the mirrors the following week. |
+| Alpine 3.16 | 2026-09-03 | End of life on 2024-05-23. Only PHP 7.4 and 8.0 used it. |
+
+PHP 8.1 is end of life but still built on `bookworm`, `trixie`, and `alpine3.22`, because those bases still get security updates. It stops when they do.
+
+If you're on a frozen tag, move to a supported PHP version and base from [Choosing an image](/docs/getting-started/choosing-an-image).
+
+### Installing packages on a frozen Bullseye image
+
+::warning{title="Unsupported, and it doesn't make the image safer"}
+The `7.4`, `8.0`, and `-bullseye` tags are frozen, and Debian no longer publishes security updates for Bullseye. This recipe only makes `apt-get install` work again so you can add a dependency while you finish migrating.
+::
+
+Debian moved `bullseye` and `bullseye-updates` to `archive.debian.org`. The last copy of `bullseye-security` is only on [snapshot.debian.org](https://snapshot.debian.org/){target="_blank"}. Its release file has expired, so apt must skip the expiry check. This is the same approach [docker-php-extension-installer uses](https://github.com/mlocati/docker-php-extension-installer/pull/1320){target="_blank"}. The recipe also installs a newer `install-php-extensions`, because the frozen images ship one from before that fix.
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:7.4-fpm-nginx
+
+USER root
+
+RUN printf '%s\n' \
+ 'deb http://archive.debian.org/debian bullseye main' \
+ 'deb http://archive.debian.org/debian bullseye-updates main' \
+ 'deb http://snapshot.debian.org/archive/debian-security/20260903T220410Z bullseye-security main' \
+ > /etc/apt/sources.list \
+ && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99no-check-valid-until \
+ && docker-php-serversideup-install-php-ext-installer 2.11.15
+
+RUN docker-php-serversideup-dep-install-debian "mariadb-client" \
+ && install-php-extensions intl
+
+USER www-data
+```
+
+Once Debian publishes `bullseye-security` on `archive.debian.org`, replace the `snapshot.debian.org` line with `deb http://archive.debian.org/debian-security bullseye-security main`.
+
+The Alpine 3.16 tags need no changes. Alpine keeps every past release on its mirrors, so `apk add` still works. The packages are just as unpatched.
+
+## Upgrade to a new major version
+
+Floating tags always come from the newest major version. A major version has breaking changes. Read its page before you pull, or pin to the last release of your current major while you review:
+
+- [Upgrade to v5](/docs/upgrading/upgrade-to-v5) from v4
+- [Upgrade to v4](/docs/upgrading/upgrade-to-v4) from v3
+- [Upgrade to v3](/docs/upgrading/upgrade-to-v3) from v2
+
+Upgrade one major version at a time and work through each page in order.
diff --git a/docs/content/docs/6.upgrading/2.upgrade-to-v5.md b/docs/content/docs/6.upgrading/2.upgrade-to-v5.md
new file mode 100644
index 000000000..fe63bd85e
--- /dev/null
+++ b/docs/content/docs/6.upgrading/2.upgrade-to-v5.md
@@ -0,0 +1,173 @@
+---
+head.title: 'Upgrade to v5 - Docker PHP - Server Side Up'
+description: 'The breaking changes, new defaults, and new variables when you move from serversideup/php v4 to v5.'
+layout: docs
+title: Upgrade to v5
+---
+
+::lead-p
+Version 5 tunes OPcache for production and follows Caddy's logging defaults on FrankenPHP. `TRUSTED_PROXY` now works the same on every web server. Most apps only need to check the first two changes below.
+::
+
+PHP 7.4 and 8.0 are no longer built. See [PHP and OS versions we no longer build](/docs/upgrading/upgrade-guide#php-and-os-versions-we-no-longer-build).
+
+## Stay on v4 while you review
+
+Pin your tag to the last v4 release. Version-pinned tags are never rebuilt. You get no security updates until you move to v5.
+
+```yaml [compose.yml] {3}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx-v4.5.3
+```
+
+## Breaking changes
+
+| Change | Likelihood of impact | You're affected if |
+|---|---|---|
+| [OPcache no longer checks for file changes](#opcache-no-longer-checks-for-file-changes) | High | You set `PHP_OPCACHE_ENABLE=1` and change code without restarting the container |
+| [FrankenPHP logs go to stderr as JSON](#frankenphp-logs-go-to-stderr-as-json) | Medium | You run `frankenphp` and read or filter its logs |
+| [Octane uses the image's Caddyfile](#octane-uses-the-images-caddyfile) | Medium | You run Laravel Octane on `frankenphp` |
+| [Mercure 1.0 rejects the old JWT options](#mercure-10-rejects-the-old-jwt-options) | Medium | You run the Mercure hub on `frankenphp` |
+| [Session cookies are HttpOnly](#session-cookies-are-httponly) | Low | Your JavaScript reads PHP's native session cookie |
+| [Session upload progress is off](#session-upload-progress-is-off) | Low | Your app reads upload progress from `$_SESSION` |
+| [S6 dependencies use `dependencies.d/`](#s6-dependencies-use-dependenciesd) | Low | Your Dockerfile appends to an S6 `dependencies` file |
+| [The S6 `user` bundle moved](#the-s6-user-bundle-moved) | Low | Your Dockerfile registers your own S6 service |
+| [FrankenPHP sets `REMOTE_ADDR` to the client IP](#frankenphp-sets-remote_addr-to-the-client-ip) | Low | Your app compares `REMOTE_ADDR` to a proxy address |
+| [Caddy limits request headers](#caddy-limits-request-headers) | Low | Requests to `frankenphp` carry more than 16 KiB of headers |
+| [`num_threads` no longer includes workers](#num_threads-no-longer-includes-workers) | Low | You set `num_threads` in `FRANKENPHP_CONFIG` with workers |
+
+### OPcache no longer checks for file changes
+
+`PHP_OPCACHE_VALIDATE_TIMESTAMPS` now defaults to `0`. With OPcache on, PHP caches each file until the container restarts, which is right for code built into the image. Version 4 checked every file every two seconds and used PHP's stock memory sizes. Version 5 uses the values from [Symfony's performance guide](https://symfony.com/doc/current/performance.html#configure-opcache-for-maximum-performance){target="_blank"} instead (see [Changed defaults](#changed-defaults)).
+
+You're affected if you set `PHP_OPCACHE_ENABLE=1` and any of these apply:
+
+- Your code is mounted as a volume and you edit it in place.
+- You run WordPress from a volume and update it with `git pull`, WP-CLI, or SFTP.
+- You run commands like `docker exec ... php artisan optimize` and expect the web server to pick up the new files.
+
+Restart the container after code changes (recommended). To get the v4 behavior instead, set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`. [Production performance tuning](/docs/deployment-and-production/production-performance-tuning) covers the OPcache settings.
+
+### FrankenPHP logs go to stderr as JSON
+
+`CADDY_LOG_OUTPUT` now defaults to `stderr` and `CADDY_LOG_FORMAT` to `auto`. Caddy, the official FrankenPHP image, and Laravel Octane all write logs to `stderr`. With `auto`, Caddy writes JSON when there is no terminal, which is always the case in a container. Version 4 forced `console` lines on `stdout`, which put ANSI color codes into log pipelines.
+
+`docker logs`, Docker Compose, and Kubernetes show both streams, so most setups only notice the format. You're affected if:
+
+- You only capture `stdout`, for example with `2>/dev/null` or a log driver rule.
+- Your log pipeline parses the `console` lines.
+- You prefer reading colored lines with `docker compose logs`.
+
+Let your pipeline read the `level` field of each JSON entry (recommended). To restore v4, set `CADDY_LOG_FORMAT=console` and `CADDY_LOG_OUTPUT=stdout`. Don't use either override with Octane, which only relays `stderr` and only parses JSON.
+
+### Octane uses the image's Caddyfile
+
+Pass `--caddyfile=/etc/frankenphp/Caddyfile` to `octane:start`. The Caddyfile now adds the worker and routing itself when Octane starts FrankenPHP. Remove any `worker { }` block from `FRANKENPHP_CONFIG` and the routing you added to `CADDY_PHP_SERVER_OPTIONS`. Keeping the worker block fails with "global workers must not have duplicate filenames".
+
+Octane needs the Caddy admin API and sets `CADDY_GLOBAL_OPTIONS` itself. While Octane runs, `CADDY_ADMIN` and `CADDY_GLOBAL_OPTIONS` are ignored. Put global options in a file in `/etc/frankenphp/caddyfile-global.d/` instead. Classic mode doesn't change. See [Laravel Octane](/docs/framework-guides/laravel/octane).
+
+### Mercure 1.0 rejects the old JWT options
+
+Version 5 ships FrankenPHP 1.13, which includes Mercure 1.0. FrankenPHP fails to start if you configure the hub with `publisher_jwt` or `subscriber_jwt`, including through the `mercure` array in `config/octane.php`.
+
+Remove those options and turn the hub on with `MERCURE_ENABLED=true` and the other `MERCURE_*` variables, which work in classic mode and with Octane. If your app still signs 0.x tokens, add `protocol_version_compatibility 8` to `MERCURE_EXTRA_DIRECTIVES` while you migrate. See [FrankenPHP](/docs/image-variations/frankenphp) and the [Mercure 1.0 upgrade guide](https://github.com/dunglas/mercure/blob/v1.0.3/docs/UPGRADE.md){target="_blank"}.
+
+### Session cookies are HttpOnly
+
+`PHP_SESSION_COOKIE_HTTPONLY` now defaults to `On`, as [PHP recommends](https://www.php.net/manual/en/session.security.ini.php){target="_blank"}, so browser scripts can't read PHP's native session cookie. Laravel, Symfony, and WordPress set their own session cookies and aren't affected.
+
+If your app calls `session_start()` and reads the session cookie from JavaScript, set `PHP_SESSION_COOKIE_HTTPONLY=Off`.
+
+### Session upload progress is off
+
+Since v5.0.3, `PHP_SESSION_UPLOAD_PROGRESS_ENABLED` defaults to `0`, following [PHP's October 23, 2026 security advisory](https://www.php.net/archive/2026.php#2026-10-23-1){target="_blank"}. PHP's own default is on.
+
+If your app reads upload progress from `$_SESSION`, set it to `1`, and only if your app never changes `session.serialize_handler` at runtime.
+
+### S6 dependencies use `dependencies.d/`
+
+`fpm-nginx` and `fpm-apache` now declare service dependencies with the `dependencies.d/` directory that [S6 Overlay documents](https://github.com/just-containers/s6-overlay#writing-a-service-script){target="_blank"}, instead of the deprecated `dependencies` file. This fixed a race where `php-fpm` could start before its pool user was written when the container runs as root.
+
+S6 ignores the `dependencies` file once `dependencies.d/` exists. If your Dockerfile appends to `/etc/s6-overlay/s6-rc.d//dependencies` for `php-fpm`, `nginx`, or `apache2`, create an empty file in `dependencies.d/` instead:
+
+```dockerfile [Dockerfile]
+RUN touch /etc/s6-overlay/s6-rc.d/nginx/dependencies.d/my-service
+```
+
+### The S6 `user` bundle moved
+
+Version 5 ships [S6 Overlay v3.2.3.2](https://github.com/just-containers/s6-overlay/blob/v3.2.3.2/CHANGELOG.md){target="_blank"}, which defines the `user` bundle in `/etc/s6-overlay/user-bundles.d/` instead of `/etc/s6-overlay/s6-rc.d/`. `docker-php-serversideup-s6-init` already writes to the new location.
+
+If your Dockerfile creates `/etc/s6-overlay/s6-rc.d/user/contents.d/`, create the empty file in the new directory instead:
+
+```dockerfile [Dockerfile]
+RUN touch /etc/s6-overlay/user-bundles.d/user/contents.d/my-service
+```
+
+::warning
+Don't recreate `/etc/s6-overlay/s6-rc.d/user`. When it exists, S6 Overlay ignores `user-bundles.d/`, so `php-fpm` and the web server never start. As `www-data`, the container doesn't boot at all, because S6 Overlay tries to write to `/etc/s6-overlay` at startup.
+::
+
+See [Using S6 Overlay](/docs/customizing-the-image/using-s6-overlay) for writing your own services.
+
+### FrankenPHP sets `REMOTE_ADDR` to the client IP
+
+Version 4 set `$_SERVER['REMOTE_ADDR']` to the TCP peer on FrankenPHP. It's now the client IP that Caddy resolves from trusted proxies, the same as NGINX and Apache. Caddy runs in strict mode, so a client behind a trusted proxy can't forge it. See [Configuring trusted proxies](/docs/deployment-and-production/configuring-trusted-proxies).
+
+### Caddy limits request headers
+
+FrankenPHP 1.13 includes [Caddy 2.11.7](https://github.com/caddyserver/caddy/releases/tag/v2.11.7){target="_blank"}. Requests with more than 16 KiB of headers now get `431 Request Header Fields Too Large`, where v4 allowed 1 MB. Large cookies are the usual cause. Headers with a `.` in their name are now dropped, like headers with a `_`, because PHP reads both as `-` and a client could use them to spoof headers such as `X-Forwarded-For`.
+
+### `num_threads` no longer includes workers
+
+If you set `num_threads` in `FRANKENPHP_CONFIG` while running workers, including Octane, FrankenPHP now starts that many threads on top of the worker threads. It fails to start if `max_threads` is lower than the total. Lower `num_threads` to the number of threads you want for regular requests.
+
+## Changed defaults
+
+The OPcache values only apply when `PHP_OPCACHE_ENABLE=1`. OPcache only uses the memory as it caches files. `PHP_REALPATH_CACHE_TTL` applies with OPcache on or off and comes from the same [Symfony recommendation](https://symfony.com/doc/current/performance.html#configure-the-php-realpath-cache){target="_blank"}.
+
+| Variable | v4 | v5 |
+|---|---|---|
+| `PHP_OPCACHE_VALIDATE_TIMESTAMPS` | `1` | `0` |
+| `PHP_OPCACHE_MEMORY_CONSUMPTION` | `128` | `256` |
+| `PHP_OPCACHE_INTERNED_STRINGS_BUFFER` | `8` | `32` |
+| `PHP_OPCACHE_MAX_ACCELERATED_FILES` | `10000` | `32531` |
+| `PHP_REALPATH_CACHE_TTL` | `120` | `600` |
+| `PHP_SESSION_COOKIE_HTTPONLY` | PHP default (off) | `On` |
+| `PHP_SESSION_UPLOAD_PROGRESS_ENABLED` | PHP default (on) | `0` (since v5.0.3) |
+| `CADDY_LOG_OUTPUT` (`frankenphp`) | `stdout` | `stderr` |
+| `CADDY_LOG_FORMAT` (`frankenphp`) | `console` | `auto` |
+
+`PHP_OPCACHE_FORCE_RESTART_TIMEOUT` existed in v4 but never reached `php.ini`. It works now. Its default of `180` matches PHP's. Nothing changes unless you set it.
+
+## New variables
+
+| Variable | What it does |
+|---|---|
+| `PHP_OPCACHE_ENABLE_CLI` | Whether CLI commands use OPcache when it's on. Defaults to `1`, as v4 did. |
+| `PHP_OPCACHE_PRELOAD` | Path to a preload script. Set it on the running service, not as an `ENV` in your Dockerfile, so build steps such as `composer install` don't depend on it. |
+| `PHP_OPCACHE_PRELOAD_USER` | The user to preload as when the container runs as root. |
+| `PHP_DISABLE_FUNCTIONS` | Functions to disable. Empty by default. |
+| `PHP_FILE_UPLOADS`, `PHP_MAX_FILE_UPLOADS` | Turn file uploads off, or cap the files per request. |
+| `PHP_HTML_ERRORS` | Format on-screen errors as HTML. |
+| `PHP_REALPATH_CACHE_SIZE` | Size of PHP's realpath cache. |
+| `PHP_SESSION_COOKIE_HTTPONLY`, `PHP_SESSION_UPLOAD_PROGRESS_ENABLED` | See the breaking changes above. |
+| `TRUSTED_PROXY` | Which proxies to trust for the client IP on `fpm-nginx`, `fpm-apache`, and `frankenphp`. |
+| `CADDY_ACME_PROFILE` | Request a Let's Encrypt certificate profile on `frankenphp`, such as `shortlived`. |
+| `MERCURE_*` | Run FrankenPHP's Mercure hub. |
+| `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND` | Start the container when Laravel isn't in `APP_BASE_DIR` yet. |
+| `LARAVEL_OCTANE` | Set by Octane, not by you. |
+
+Defaults and accepted values are in the [environment variable reference](/docs/reference/environment-variable-specification).
+
+## New features
+
+- **Octane with the image's Caddyfile.** Octane gets the same trusted proxies, security headers, asset caching, SSL modes, and health check as classic mode. See [Laravel Octane](/docs/framework-guides/laravel/octane).
+- **Trusted proxies on every web server.** `TRUSTED_PROXY` behaves the same on `fpm-nginx`, `fpm-apache`, and `frankenphp`, through more than one Docker hop. See [Configuring trusted proxies](/docs/deployment-and-production/configuring-trusted-proxies).
+- **Short-lived and IP-address certificates** on FrankenPHP with `CADDY_ACME_PROFILE`. See [Configuring SSL](/docs/deployment-and-production/configuring-ssl).
+- **`healthcheck-nightwatch`** runs `php artisan nightwatch:status`. See [Laravel health checks](/docs/framework-guides/laravel/health-checks).
+- **Mercure from environment variables**, in classic mode or with Octane. See [FrankenPHP](/docs/image-variations/frankenphp).
+- **Caddyfile rules from a folder.** Put a `.caddyfile` in `/etc/frankenphp/caddyfile-server.d/` to add headers, redirects, or matchers to your site.
+- **Redacted Mercure tokens.** FrankenPHP request logs replace the `authorization` query parameter in every log format.
+- **Tested before publishing.** Every image is started and checked on `amd64` and `arm64` before it reaches Docker Hub. If one image fails, nothing from that build is published.
diff --git a/docs/content/docs/6.upgrading/3.upgrade-to-v4.md b/docs/content/docs/6.upgrading/3.upgrade-to-v4.md
new file mode 100644
index 000000000..de58f8c13
--- /dev/null
+++ b/docs/content/docs/6.upgrading/3.upgrade-to-v4.md
@@ -0,0 +1,59 @@
+---
+head.title: 'Upgrade to v4 - Docker PHP - Server Side Up'
+description: 'What changed when serversideup/php moved from v3 to v4, and the defaults to check before you update your tag.'
+layout: docs
+title: Upgrade to v4
+---
+
+::lead-p
+Version 4 adds the FrankenPHP variation and more environment variables. Most apps need no changes, but check the four below before you update your tag.
+::
+
+## Changes to check
+
+| Change | Likelihood of impact | You're affected if |
+|---|---|---|
+| [PHP-FPM uses `ondemand` behind a web server](#php-fpm-uses-ondemand-behind-a-web-server) | Low | You tuned `PHP_FPM_PM_*` for `dynamic` on `fpm-nginx` or `fpm-apache` |
+| [Laravel automations run `php artisan optimize`](#laravel-automations-run-php-artisan-optimize) | Low | You set `AUTORUN_ENABLED=true` and rely on the individual cache commands |
+| [Automations stop the container when Laravel is missing](#automations-stop-the-container-when-laravel-is-missing) | Medium | You set `AUTORUN_ENABLED=true` on a container without `artisan` and `vendor/` |
+| [The `unit` variation was removed in v4.1.0](#the-unit-variation-was-removed-in-v410) | High for Unit users | You use a `-unit` tag |
+
+### PHP-FPM uses `ondemand` behind a web server
+
+`PHP_FPM_PM_CONTROL` now defaults to `ondemand` on `fpm-nginx` and `fpm-apache`, which starts PHP-FPM workers only when requests arrive. The `fpm` variation still defaults to `dynamic`. Set `PHP_FPM_PM_CONTROL=dynamic` to keep the v3 behavior.
+
+### Laravel automations run `php artisan optimize`
+
+With `AUTORUN_ENABLED=true`, the automations now run `php artisan optimize` instead of each cache command on its own. Turn off one cache with its variable, such as `AUTORUN_LARAVEL_ROUTE_CACHE=false`. On Laravel 11.38.0 and later that becomes `optimize --except`. See [Laravel automations](/docs/framework-guides/laravel/automations).
+
+### Automations stop the container when Laravel is missing
+
+In v3, the automations were skipped silently when `artisan` wasn't found. In v4, a container with `AUTORUN_ENABLED=true` exits with an error unless `APP_BASE_DIR` has both `artisan` and a `vendor` directory. It also exits when `AUTORUN_LARAVEL_MIGRATION_ISOLATION=true` and Laravel is older than 9.38.0. Run `composer install` when you build the image, or set `AUTORUN_ENABLED` only on containers that have your app. v5 adds `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND=true` to skip instead.
+
+### The `unit` variation was removed in v4.1.0
+
+NGINX archived the Unit project, so v4.1.0 and later don't build the `unit` variation. Existing `-unit` tags still pull but are no longer updated. See [Unit](/docs/image-variations/unit) for how to move to another variation.
+
+## New features
+
+- **FrankenPHP variation** on Debian and Alpine, with a health check and environment variable configuration. See [FrankenPHP](/docs/image-variations/frankenphp).
+- **More Laravel automation options:** migration modes (`fresh`, `refresh`), a database connection, seeding, and `AUTORUN_DEBUG`.
+- **More environment variables** for PHP, OPcache, NGINX, Apache, and FrankenPHP, such as `NGINX_HTTP_PORT`, `NGINX_CLIENT_MAX_BODY_SIZE`, and `PHP_OPCACHE_VALIDATE_TIMESTAMPS`. See the [environment variable reference](/docs/reference/environment-variable-specification).
+- **IPv6 for NGINX** with `NGINX_LISTEN_IP_PROTOCOL` (`all`, `ipv4`, or `ipv6`).
+- **Faster health checks at startup** with `--start-period` and `--start-interval`.
+- **Health checks stay out of the access logs** on `fpm-nginx` and `fpm-apache`.
+- **`docker-php-serversideup-set-file-permissions`** detects the variation and accepts extra directories with `--dir`.
+- **Apache access logs** include the referrer and user agent.
+- **NGINX** sets `absolute_redirect off;`, handles `svgz` with Symfony's asset mapper, and lets PHP generate `robots.txt`.
+
+## Update your tag
+
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - "80:8080"
+```
+
+This is a floating v5 tag. To stop at v4, use a version-pinned tag such as `8.5-fpm-nginx-v4.5.3`, then read [Upgrade to v5](/docs/upgrading/upgrade-to-v5) before you move on.
diff --git a/docs/content/docs/6.upgrading/4.upgrade-to-v3.md b/docs/content/docs/6.upgrading/4.upgrade-to-v3.md
new file mode 100644
index 000000000..9612536d9
--- /dev/null
+++ b/docs/content/docs/6.upgrading/4.upgrade-to-v3.md
@@ -0,0 +1,101 @@
+---
+head.title: 'Upgrade to v3 - Docker PHP - Server Side Up'
+description: 'The breaking changes and migration steps when you move from serversideup/php v2 to v3.'
+layout: docs
+title: Upgrade to v3
+---
+
+::lead-p
+Version 3 rebuilt the images on the official PHP images and runs them as an unprivileged user. Most v2 setups need changes to ports, file ownership, and how extensions are installed.
+::
+
+## Stay on v2 while you migrate
+
+Add `-v2.2.1` to your tag so you aren't upgraded before you're ready:
+
+```yaml [compose.yml] {3}
+services:
+ php:
+ image: serversideup/php:8.2-fpm-nginx-v2.2.1
+```
+
+## Breaking changes
+
+| Change | Likelihood of impact | You're affected if |
+|---|---|---|
+| [Web servers listen on 8080 and 8443](#web-servers-listen-on-8080-and-8443) | High | You use `fpm-nginx` or `fpm-apache` |
+| [Files belong to `www-data`, not `webuser`](#files-belong-to-www-data-not-webuser) | High | You mount volumes or `COPY` files into the image |
+| [Debian and Alpine replace Ubuntu](#debian-and-alpine-replace-ubuntu) | High | Your Dockerfile runs Ubuntu commands or uses `ppa:ondrej/php` |
+| [Renamed and removed variables](#renamed-and-removed-variables) | Medium | You set any of the variables listed |
+| [`AUTORUN_ENABLED` defaults to `false`](#autorun_enabled-defaults-to-false) | Medium | You rely on the Laravel automations |
+| [`SSL_MODE` defaults to `off`](#ssl_mode-defaults-to-off) | Medium | You relied on HTTPS by default |
+| [S6 Overlay only in `fpm-nginx` and `fpm-apache`](#s6-overlay-only-in-fpm-nginx-and-fpm-apache) | Low | You added S6 services to `cli` or `fpm` |
+| [MSMTP is removed](#msmtp-is-removed) | Low | Your app sends mail through MSMTP |
+
+### Web servers listen on 8080 and 8443
+
+Unprivileged users can't bind ports below 1024. NGINX and Apache now listen on `8080` for HTTP and `8443` for HTTPS. Update your port mappings, for example `80:8080` and `443:8443`. See [Default configurations](/docs/getting-started/default-configurations).
+
+### Files belong to `www-data`, not `webuser`
+
+Version 2 ran as `webuser` (UID `9999`) with a login shell. Version 3 uses the `www-data` user that ships with the official PHP images: UID and GID `33` on Debian and `82` on Alpine.
+
+- `chown` mounted volumes on your hosts to match, for example `33:33`.
+- Copy files with the right owner: `COPY --chown=www-data:www-data . /var/www/html`.
+- To match your own UID in development, see [Understanding file permissions](/docs/customizing-the-image/understanding-file-permissions). For Docker Swarm host volumes, the [change-volume-permissions script](https://github.com/serversideup/docker-volume-change-permission-script){target="_blank"} can help.
+
+### Debian and Alpine replace Ubuntu
+
+The images build on the official PHP images, which use Debian or Alpine. PHP comes from php.net instead of `ppa:ondrej/php`. Remove Ubuntu-specific commands from your Dockerfile. Replace packages like `php-redis` with `install-php-extensions`. See [Installing additional PHP extensions](/docs/customizing-the-image/installing-additional-php-extensions).
+
+### Renamed and removed variables
+
+| v2 | v3 |
+|---|---|
+| `WEBUSER_HOME` | `APP_BASE_DIR` |
+| `DEBUG_OUTPUT` | `LOG_OUTPUT_LEVEL=debug` |
+| `PHP_POOL_NAME` | `PHP_FPM_POOL_NAME` |
+| `PHP_PM_CONTROL`, `PHP_PM_MAX_CHILDREN`, `PHP_PM_START_SERVERS`, `PHP_PM_MIN_SPARE_SERVERS`, `PHP_PM_MAX_SPARE_SERVERS` | `PHP_FPM_PM_CONTROL`, `PHP_FPM_PM_MAX_CHILDREN`, `PHP_FPM_PM_START_SERVERS`, `PHP_FPM_PM_MIN_SPARE_SERVERS`, `PHP_FPM_PM_MAX_SPARE_SERVERS`. The old names are ignored, so your PHP-FPM tuning is lost until you rename them. |
+| `PUID`, `PGID` | Removed, because changing IDs at runtime needs root. See [Understanding file permissions](/docs/customizing-the-image/understanding-file-permissions). |
+| `MSMTP_RELAY_SERVER_HOSTNAME`, `MSMTP_RELAY_SERVER_PORT` | Removed with MSMTP |
+
+Check every variable you set against the [environment variable reference](/docs/reference/environment-variable-specification).
+
+### `AUTORUN_ENABLED` defaults to `false`
+
+The Laravel automations no longer run unless you set `AUTORUN_ENABLED=true`. See [Laravel automations](/docs/framework-guides/laravel/automations).
+
+### `SSL_MODE` defaults to `off`
+
+The images serve HTTP only by default. Set `SSL_MODE` to turn HTTPS on. See [Configuring SSL](/docs/deployment-and-production/configuring-ssl).
+
+### S6 Overlay only in `fpm-nginx` and `fpm-apache`
+
+The other variations don't include S6 Overlay. Move start-up scripts for them to `/etc/entrypoint.d`. See [Adding your own start-up scripts](/docs/customizing-the-image/adding-your-own-start-up-scripts).
+
+### MSMTP is removed
+
+MSMTP was removed for security and image size. Send mail through an external SMTP service. If you need MSMTP, install it in your own Dockerfile.
+
+## New features
+
+- Built on the official PHP Docker images.
+- Runs as an unprivileged user by default.
+- Tags for an exact PHP patch, such as `8.2.12` instead of `8.2`.
+- Start-up scripts in `/etc/entrypoint.d`, with no S6 Overlay knowledge needed.
+- Laravel automations for `config:cache`, `route:cache`, `view:cache`, `event:cache`, `migrate --force --isolated`, and `storage:link`.
+- Images published to GitHub Packages as well as Docker Hub.
+
+## If your CI runs builds as root
+
+Build a separate CI target that stays as `root`. Keep `www-data` for the image you deploy. When the container runs as root, the image sets PHP-FPM's worker user to `www-data` for you (`PHP_FPM_CHILD_PROCESS_USER`). Start from the multi-stage Dockerfile in [Understanding file permissions](/docs/customizing-the-image/understanding-file-permissions) and add a `ci` target that sets `USER root`.
+
+## Migration checklist
+
+1. Test in a non-production environment first.
+2. Update the image tag and the port mappings to `8080` and `8443`.
+3. Check every environment variable you set.
+4. In your Dockerfile, remove Ubuntu commands and `ppa:ondrej/php`, install extensions with `install-php-extensions`, and `COPY` with `--chown=www-data:www-data`.
+5. `chown` host volumes to `33:33` (Debian) or `82:82` (Alpine).
+6. Add `PHP_OPCACHE_ENABLE=1` in production.
+7. Deploy.
diff --git a/docs/content/docs/7.troubleshooting/1.common-issues.md b/docs/content/docs/7.troubleshooting/1.common-issues.md
index 28ff2dc19..ebef1bc98 100644
--- a/docs/content/docs/7.troubleshooting/1.common-issues.md
+++ b/docs/content/docs/7.troubleshooting/1.common-issues.md
@@ -1,88 +1,94 @@
---
-title: Common Issues
-description: 'Solutions to frequently encountered problems when using serversideup/php Docker images.'
+head.title: 'Common issues - Docker PHP - Server Side Up'
+title: Common issues
+description: 'Symptoms, causes, and fixes for the problems people hit most often with serversideup/php.'
+layout: docs
---
-## Overview
+::lead-p
+Find your symptom below. Each entry gives the cause and the fix. If yours isn't here, see [getting help](/docs/troubleshooting/getting-help).
+::
-This guide covers the most common issues users encounter and their solutions. If you don't find your issue here, check out our [Getting Help](/docs/troubleshooting/getting-help) guide for community and professional support options.
+## The page doesn't load on port 80 or 443
+**Cause:** The containers run unprivileged, so the web server listens on `8080` for HTTP and `8443` for HTTPS, not 80 and 443. The `fpm` image listens on `9000` for FastCGI and serves no HTTP.
-## Permission Issues
+**Fix:** Map your host ports to the container's ports:
-Permission problems are one of the most common issues when working with Docker containers. They typically manifest as "Permission denied" errors when trying to write files.
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - 80:8080
+ - 443:8443
+```
-### Understanding the Problem
+## Port is already allocated or in use
+Docker reports one of these when the container starts:
-By default, these images run as a non-root user (`www-data`) for security. When your host machine uses a different user ID (UID) or group ID (GID), file permission conflicts can occur.
+```text
+Bind for 0.0.0.0:80 failed: port is already allocated
+listen tcp4 0.0.0.0:80: bind: address already in use
+```
-If your command is failing during build, then you likely need to switch to root to perform root tasks.
+**Cause:** Another container or program on your host is using that port.
-```dockerfile [Dockerfile]
-FROM serversideup/php:8.5-fpm-nginx
+**Fix:** Stop the other program, or map a different host port, such as `8000:8080`, and open `http://localhost:8000`. To find what is using the port, run `sudo lsof -i :80` on Linux or macOS, or `netstat -ano | findstr :80` on Windows.
-USER root
+## 404 or "File not found" for every page
+**Cause:** The web server's document root is `/var/www/html/public`. An app with `index.php` in its project root, instead of in `public/`, has nothing to serve.
-# Install system packages
-RUN install-php-extensions intl bcmath
+**Fix:** Move your public files into `public/`, or point the document root at your folder. The variable depends on the image:
-# Switch back to www-data
-USER www-data
-```
+| Image | Variable |
+|-------|----------|
+| `fpm-nginx` | `NGINX_WEBROOT` |
+| `fpm-apache` | `APACHE_DOCUMENT_ROOT` |
+| `frankenphp` | `CADDY_SERVER_ROOT` |
-::warning
-Always switch back to a non-root user after completing privileged operations. Running containers as root is a security risk.
-::
+For example, `NGINX_WEBROOT: "/var/www/html"`.
-If your container is failing to run during runtime, then you may have a more advanced permissions issue. See our guide on understanding file permissions.
+## Code changes don't show up
+**Cause:** OPcache is on (`PHP_OPCACHE_ENABLE=1`) and, by default, never checks files for changes (`PHP_OPCACHE_VALIDATE_TIMESTAMPS=0`). PHP keeps serving the compiled copy until the container restarts. Unless `SHOW_WELCOME_MESSAGE=false`, the startup log says so: `OPcache is enabled and PHP_OPCACHE_VALIDATE_TIMESTAMPS=0. Code changes require a container restart.`
-:u-button{to="/docs/guide/understanding-file-permissions" label="Understanding File Permissions" aria-label="Understanding File Permissions" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+**Fix:** In development, leave `PHP_OPCACHE_ENABLE` at its default of `0`, or set `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`. In production, deploy a new container. See [production performance tuning](/docs/deployment-and-production/production-performance-tuning#start-a-new-container-for-every-deployment).
-## Port Already in Use
+## Permission denied when writing files
+**Cause:** With your code mounted from the host, the container's `www-data` user (UID 33 on Debian, 82 on Alpine) doesn't own your files, so PHP can't write to directories like `storage/`. Files it does create belong to UID 33 on your host.
-### Error Message
+**Fix:** Build a development image that gives `www-data` your UID and GID. See [understanding file permissions](/docs/customizing-the-image/understanding-file-permissions).
-```
-Error starting xyz service: listen tcp 0.0.0.0:80: bind: address already in use
-```
+## Permission denied during `docker build`
+**Cause:** The images run as `www-data`, so `RUN` steps that install packages or extensions can't write to system directories.
-### Solution
+**Fix:** Switch to `root` for those steps and back afterward:
-Another process is using the port. Find and stop it, or use a different port:
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
-**Find what's using the port:**
+USER root
+RUN install-php-extensions intl
+USER www-data
+```
-```bash
-# On Linux/macOS
-sudo lsof -i :80
+## The container stops right after it starts
+**Cause:** A script in `/etc/entrypoint.d/` exited with an error, which stops the container before PHP or the web server starts. This includes your own scripts and [Laravel automations](/docs/framework-guides/laravel/automations), for example when migrations can't reach the database.
-# On Windows
-netstat -ano | findstr :80
-```
+**Fix:** Read the last lines of `docker compose logs`. Set `LOG_OUTPUT_LEVEL=debug` to log each script's name as it runs, so you can see which one failed. See [adding start up scripts](/docs/customizing-the-image/adding-your-own-start-up-scripts).
+
+## 502 Bad Gateway with "upstream sent too big header"
+**Cause:** In `fpm-nginx`, a PHP response's headers (often large cookies) are bigger than NGINX's FastCGI buffer, which is `8k` by default. The NGINX log shows `upstream sent too big header while reading response header from upstream`.
-**If you must use a different port:**
+**Fix:** Raise the buffer sizes:
-```yaml [compose.yml] {4-6}
+```yaml [compose.yml]
services:
php:
image: serversideup/php:8.5-fpm-nginx
- ports:
- - "8080:8080" # Use port 8080 instead
- - "8443:8443"
+ environment:
+ NGINX_FASTCGI_BUFFER_SIZE: "32k"
+ NGINX_FASTCGI_BUFFERS: "16 16k"
```
-Access your application at `http://localhost:8080`.
-
-## Getting More Help
-
-If your issue isn't covered here:
-
-1. **Search GitHub Discussions** - Someone may have encountered the same problem
-2. **Check the Documentation** - Review guides specific to your setup
-3. **Ask the Community** - Post in GitHub Discussions or Discord
-4. **Review Container Logs** - Most issues show helpful error messages
-
-:u-button{to="/docs/troubleshooting/getting-help" label="View All Support Options" aria-label="View All Support Options" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-::tip
-When asking for help, always include your image version, `compose.yml`, relevant error messages, and what you've already tried. This helps others help you faster!
-::
+## Before you ask for help
+Collect the image tag, your `compose.yml` or `Dockerfile`, and the full log output from `docker compose logs`. Then see [getting help](/docs/troubleshooting/getting-help).
diff --git a/docs/content/docs/7.troubleshooting/2.getting-help.md b/docs/content/docs/7.troubleshooting/2.getting-help.md
index fcd8979cf..7500a3b32 100644
--- a/docs/content/docs/7.troubleshooting/2.getting-help.md
+++ b/docs/content/docs/7.troubleshooting/2.getting-help.md
@@ -1,123 +1,44 @@
---
-title: Getting Help
-description: 'Find the support you need - from free community resources to professional consulting services.'
+head.title: 'Getting help - Docker PHP - Server Side Up'
+title: Getting help
+description: 'Where to ask questions, report bugs, and get paid support for serversideup/php.'
+layout: docs
---
-## You're Not Alone
-
-Getting stuck is a normal part of development. Whether you're troubleshooting a configuration issue, planning a complex deployment, or need help with a custom implementation, there are support options available to help you succeed.
-
-This guide will help you find the right resource to get unblocked quickly.
-
-## Free Community Support
-::note
-Want guaranteed response times from the maintainers? View our [Professional Support](#professional-support) options.
+::lead-p
+Check [common issues](/docs/troubleshooting/common-issues) first. If your problem isn't there, ask in one of the places below.
::
-Our community is active, helpful, and welcoming. Most questions can be answered through these free resources:
-### GitHub Discussions
+## Ask the community
+| Where | Use it for |
+|-------|------------|
+| [GitHub Discussions](https://github.com/serversideup/docker-php/discussions){target="_blank"} | Questions, setup help, and feature ideas. Answers stay searchable for the next person. |
+| [Discord](https://serversideup.net/discord){target="_blank"} | Quick questions and real-time chat |
+| [GitHub Issues](https://github.com/serversideup/docker-php/issues/new){target="_blank"} | Reproducible bugs, where the image doesn't behave as documented |
-**Best for:** General questions, feature requests, and community discussions
+Search Discussions before you post. Please don't open an issue for a general question. To report a security vulnerability, follow the [security policy](https://github.com/serversideup/docker-php/security/policy){target="_blank"} instead of opening a public issue.
-Our GitHub Discussions is the most popular place to get help from both the maintainers and the community. It's searchable, so your question might help others in the future.
+When you ask, include:
-:u-button{to="https://github.com/serversideup/docker-php/discussions" target="_blank" label="Browse GitHub Discussions" aria-label="Browse GitHub Discussions" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-::tip
-Before posting, search existing discussions - your question might already be answered!
-::
+- The image tag, such as `serversideup/php:8.5-fpm-nginx`
+- Your `compose.yml` or `Dockerfile`
+- The full output of `docker compose logs`
+- What you expected, what happened, and what you've tried
-### Discord Community
+[How to ask for help](https://serversideup.net/ask-for-help/){target="_blank"} has more on writing a question that gets answered.
-**Best for:** Quick questions, real-time chat, and community interaction
+## Paid support
+The maintainers offer paid help:
-Join over 3,000+ developers in our Discord server. It's a great place for quick questions, real-time troubleshooting, and connecting with other developers using serversideup/php.
+- [A one-hour session](https://schedule.serversideup.net/team/serversideup/quick-chat-with-jay){target="_blank"} with screen sharing, for a specific question or problem.
+- [Ongoing development and support](https://serversideup.net/hire-us/){target="_blank"} for teams that need continuing help with Docker, deployment, or their application.
-:u-button{to="https://serversideup.net/discord" label="Join Our Discord" aria-label="Join Our Discord" size="md" color="primary" variant="outline" trailing-icon="i-lucide-message-circle" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500" target="_blank"}
+## Who maintains this project
+[Server Side Up](https://serversideup.net){target="_blank"} is [Jay Rogers](https://x.com/jaydrogers){target="_blank"} and [Dan Pastori](https://x.com/danpastori){target="_blank"}. They maintain these images and use them in production for their own projects and clients.
-::note
-Discord is great for quick questions, but GitHub Discussions is better for complex issues that need detailed troubleshooting or permanent documentation.
+::about
::
-### GitHub Issues
-
-**Best for:** Bug reports and specific technical issues with the images
-
-Found a bug or experiencing unexpected behavior? Open a GitHub issue with detailed reproduction steps.
-
-:u-button{to="https://github.com/serversideup/docker-php/issues/new" label="Report an Issue" aria-label="Report an Issue" size="md" color="primary" variant="outline" trailing-icon="i-lucide-bug" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500" target="_blank"}
-
-**When to use GitHub Issues:**
-- The Docker images aren't behaving as documented
-- You've found a security vulnerability
-- You have a specific, reproducible bug
-
-::warning
-Please don't use GitHub Issues for general questions or support requests. Use GitHub Discussions or Discord instead.
-::
-
-
-## How to Ask for Help Effectively
-
-We've compile and entire guide on how to get answers quickly from the community.
-
-:u-button{to="https://serversideup.net/ask-for-help/" label="How to Ask for Help Effectively" aria-label="How to Ask for Help Effectively" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500" target="_blank"}
-
-## Professional Support
-
-Need guaranteed response times, direct access to the maintainers, or help with your specific implementation? We offer professional support options.
-
-### One-Time Questions
-
-**Perfect for:** Specific technical questions or getting unstuck on a particular issue
-
-Schedule a focused session to get expert help with:
-- Docker-specific implementation questions
-- Complex deployment scenarios
-- Performance optimization guidance
-- Security configuration review
-- Custom implementation planning
-
-:u-button{to="https://schedule.serversideup.net/team/serversideup/quick-chat-with-jay" label="Schedule a Session" aria-label="Schedule a Session" size="md" color="primary" variant="solid" trailing-icon="i-lucide-calendar" class="font-bold text-white bg-blue-600 hover:bg-blue-700" target="_blank"}
-
-**What you get:**
-- Direct access to serversideup/php maintainers
-- 1-hour focused troubleshooting or consulting
-- Screen sharing and hands-on help
-- Clear next steps and recommendations
-
-### Ongoing Development & Support
-
-**Perfect for:** Teams needing continuous development support or complex projects
-
-Get dedicated access to senior engineers for ongoing development, deployment assistance, and technical support.
-
-:u-button{to="https://serversideup.net/hire-us/" label="Learn About Retainer Services" aria-label="Learn About Retainer Services" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500" target="_blank"}
-
-**What's included:**
-- 🎨 **Full-Stack Development** - Frontend, backend, and everything in between
-- 🏗️ **Application Architecture** - Design scalable, maintainable solutions
-- 🎯 **UI/UX Design** - Beautiful, functional interfaces using Figma
-- 🐳 **Docker & DevOps** - Container orchestration, CI/CD pipelines, and automation
-- 🖥️ **Managed Hosting** - Deploy anywhere (self-hosted or cloud)
-- 📊 **Database Design** - Optimize for performance and scale
-- 🔒 **Security Hardening** - Production-ready security configurations
-- 📱 **Mobile App Development** - iOS & Android using modern frameworks
-
-**Key advantages:**
-- **Run Anywhere** - We specialize in both self-hosted infrastructure and cloud deployments (AWS, Google Cloud, DigitalOcean, etc.)
-- **No Vendor Lock-In** - Your infrastructure, your control
-- **Fixed Monthly Pricing** - Predictable costs, no hourly billing surprises
-- **Development-First** - 90%+ of time spent on actual development, not meetings
-- **Complete Stack** - One team handling design, development, deployment, and maintenance
-
-::note
-**Why choose us?** We built serversideup/php and maintain it in production for our own clients. We know these images inside and out because we use them every day for real-world applications.
-::
-
-## We're Here to Help
-
-Whether you choose free community support or professional services, we're committed to your success with serversideup/php. Don't hesitate to reach out - we were all beginners once, and there's no such thing as a "stupid question."
-
-Happy coding! 🚀
+The project is free and open source. If it saves you time, you can [sponsor it](https://serversideup.net/sponsor/){target="_blank"}.
+Thanks to [Chris Fidao](https://github.com/fideloper){target="_blank"}, whose course [Shipping Docker](https://serversforhackers.com/shipping-docker){target="_blank"} taught the maintainers much of what's in these images, and to [Joel Clermont](https://github.com/joelclermont/){target="_blank"}, [Patricio](https://github.com/ijpatricio){target="_blank"}, and [linuxserver.io](https://www.linuxserver.io/){target="_blank"} for their feedback and support.
diff --git a/docs/content/docs/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
deleted file mode 100644
index 08eea1ecf..000000000
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ /dev/null
@@ -1,142 +0,0 @@
----
-head.title: 'Environment Variable Specifications - Docker PHP - Server Side Up'
-description: 'View the complete reference for environment variables available in the Docker PHP images.'
-layout: docs
----
-
-::lead-p
-Environment variables are a very powerful way to customize your container. Use the document below to see what options are available to customize without requiring you to mount any custom configuration files.
-::
-
-Setting environment variables all depends on what method you're using to run your container, but for most cases you might be using Docker Compose.
-
-:u-button{to="https://docs.docker.com/compose/environment-variables/#set-environment-variables-in-containers" target="_blank" label="Learn environment variables work with Docker Compose" aria-label="Learn how environment variables work with Docker Compose" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
-
-## Available Environment Variables
-**Variable Name**|**Description**|**Used in variation**
-:-----:|:-----:|:-----:
-`APACHE_DOCUMENT_ROOT` *Default: "/var/www/html/public"*|Sets the directory from which Apache will serve files. (Official docs)|fpm-apache
-`APACHE_HTTP_PORT` *Default: "8080"*|Set the port for HTTP. (Official docs)|fpm-apache
-`APACHE_HTTPS_PORT` *Default: "8443"*|Set the port for HTTPS. (Official docs)|fpm-apache
-`APACHE_MAX_CONNECTIONS_PER_CHILD` *Default: "0"*|Sets the limit on the number of connections that an individual child server process will handle.(Official docs)|fpm-apache
-`APACHE_MAX_REQUEST_WORKERS` *Default: "150"*|Sets the limit on the number of simultaneous requests that will be served. (Official docs)|fpm-apache
-`APACHE_MAX_SPARE_THREADS` *Default: "75"*|Maximum number of idle threads. (Official docs)|fpm-apache
-`APACHE_MIN_SPARE_THREADS` *Default: "10"*|Minimum number of idle threads to handle request spikes. (Official docs)|fpm-apache
-`APACHE_RUN_GROUP` *Default: "www-data"*|Set the username of what Apache should run as.|fpm-apache
-`APACHE_RUN_USER` *Default: "www-data"*|Set the username of what Apache should run as.|fpm-apache
-`APACHE_START_SERVERS` *Default: "2"*|Sets the number of child server processes created on startup.(Official docs)|fpm-apache
-`APACHE_THREAD_LIMIT` *Default: "64"*|Set the maximum configured value for ThreadsPerChild for the lifetime of the Apache httpd process. (Official docs)|fpm-apache
-`APACHE_THREADS_PER_CHILD` *Default: "25"*|This directive sets the number of threads created by each child process. (Official docs)|fpm-apache
-`APP_BASE_DIR` *Default: "/var/www/html"*|Change this only if you mount your application to a different directory within the container. ℹ️ Be sure to change `NGINX_WEBROOT`, `APACHE_DOCUMENT_ROOT`, or `CADDY_SERVER_ROOT` if it applies to your use case as well.|all
-`AUTORUN_DEBUG` *Default: "false"*|Enable debug mode for the Laravel automations. | all
-`AUTORUN_ENABLED` *Default: "false"*|Enable or disable all automations. It's advised to set this to `false` in certain CI environments (especially during a composer install). If this is set to `false`, all `AUTORUN_*` behaviors will also be disabled.| all
-`AUTORUN_LARAVEL_OPTIMIZE` *Default: "true"*|Automatically run "php artisan optimize" on container, attempting to `--except` in Laravel > `v11.38.0` (Official docs) ℹ️ Requires `AUTORUN_ENABLED = true` to run. | all
-`AUTORUN_LARAVEL_CONFIG_CACHE` *Default: "true"*|Automatically run "php artisan config:cache" on container start. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_EVENT_CACHE` *Default: "true"*|Automatically run "php artisan event:cache" on container start. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_MIGRATION` *Default: "true"*|Automatically run `php artisan migrate --force` on container start. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_MIGRATION_DATABASE` *Default: null*| Run migrations on a specific database. In the rare case you need to use multiple databases, you can provide a comma-delimited list of connection names (e.g., "mysql,pgsql"). If `null`, it will use the default database connection. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_MIGRATION_FORCE` *Default: "true"*|Force migrations to run in production without confirmation. Set to `false` to disable the `--force` flag. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_MIGRATION_ISOLATION` *Default: "false"*|Requires Laravel v9.38.0 or higher and a database that supports table locks. Automatically run `php artisan migrate --force --isolated` on container start.
ℹ️ Requires `AUTORUN_ENABLED = true` to run. ℹ️ Does not work with SQLite. ℹ️ Only works with `AUTORUN_LARAVEL_MIGRATION_MODE = default`.| all
-`AUTORUN_LARAVEL_MIGRATION_MODE` *Default: "default"*|Set the migration mode. Valid options: `default` (runs `php artisan migrate`), `fresh` (runs `php artisan migrate:fresh`), or `refresh` (runs `php artisan migrate:refresh`). ℹ️ Requires `AUTORUN_ENABLED = true` to run. ⚠️ WARNING:`fresh` and `refresh` are destructive and will drop all tables. Only use these in development or testing environments.| all
-`AUTORUN_LARAVEL_MIGRATION_SEED` *Default: "false"*|Automatically seed the database after migrations by adding the `--seed` flag. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_MIGRATION_SKIP_DB_CHECK` *Default: "false"*|Skip the database connection check before running migrations. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_MIGRATION_TIMEOUT` *Default: "30"*|The number of seconds to wait for the database to come online before attempting `php artisan migrate`.. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_ROUTE_CACHE` *Default: "true"*|Automatically run "php artisan route:cache" on container start. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND` *Default: "false"*|When set to `true`, the Laravel Automations script will exit gracefully (without error) if Laravel is not detected in `APP_BASE_DIR`, instead of failing the container. Useful when `AUTORUN_ENABLED=true` is set on a shared image where Laravel may not always be present (e.g. before the first `composer install`). ℹ️ Requires `AUTORUN_ENABLED = true` to have any effect.| all
-`AUTORUN_LARAVEL_STORAGE_LINK` *Default: "true"*|Automatically run "php artisan storage:link" on container start. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`AUTORUN_LARAVEL_VIEW_CACHE` *Default: "true"*|Automatically run "php artisan view:cache" on container start. ℹ️ Requires `AUTORUN_ENABLED = true` to run.| all
-`CADDY_ACME_PROFILE` *Default: "off"*|Select a Let's Encrypt ACME certificate profile. Valid options: `off` (default, no profile — keeps the stock Let's Encrypt + ZeroSSL issuers), `shortlived` (~6-day certs; also required for IP-address certificates), `tlsserver`, or `classic`. Setting any profile pins issuance to Let's Encrypt only and renews more frequently, so the container needs reliable egress to the ACME CA. (Official docs)|frankenphp
-`CADDY_ADMIN` *Default: "off"*|Enable Caddy admin interface. ℹ️ Ignored when Laravel Octane starts FrankenPHP. Octane needs the admin API for `octane:status`, `octane:reload`, and `octane:stop`, so the Caddyfile enables it on `localhost` instead. See [Laravel Octane](/docs/framework-guides/laravel/octane#octane-options). (Official docs)|frankenphp
-`CADDY_AUTO_HTTPS` *Default: "off"*|Enable automatic HTTPS. (Official docs)|frankenphp
-`CADDY_GLOBAL_OPTIONS` *Default: ""*|Set global options for the Caddy server. ℹ️ Ignored when Laravel Octane starts FrankenPHP, because `octane:start` overwrites this variable with its own value. Mount a `.caddyfile` into `/etc/frankenphp/caddyfile-global.d/` instead. See [Laravel Octane](/docs/framework-guides/laravel/octane#octane-options). (Official docs)|frankenphp
-`CADDY_HTTP_PORT` *Default: "8080"*|Set the port for HTTP. (Official docs)|frankenphp
-`CADDY_HTTP_SERVER_ADDRESS` *Default: "http://"*|Set the server address for HTTP. (Official docs)|frankenphp
-`CADDY_HTTPS_PORT` *Default: "8443"*|Set the port for HTTPS. (Official docs)|frankenphp
-`CADDY_HTTPS_SERVER_ADDRESS` *Default: "https://"*|Set the server address for HTTPS. (Official docs)|frankenphp
-`CADDY_LOG_FORMAT` *Default: "auto"*|Set the format for the Caddy log. Valid options: `auto` (Caddy's default: human-readable `console` lines when `stderr` is a terminal and `json` otherwise, so a container gets JSON), `console`, or `json`. Every format redacts the `authorization` query parameter from request logs. ℹ️ Leave this at `auto` when you run Laravel Octane, which only understands JSON. See [Laravel Octane](/docs/framework-guides/laravel/octane#logging). (Official docs)|frankenphp
-`CADDY_LOG_OUTPUT` *Default: "stderr"*|Set the output for the Caddy log. Caddy's access log and runtime log share the same structured format, and `stderr` is Caddy's default output for both. ℹ️ Laravel Octane only relays `stderr`, so leave this at the default when you run Octane. See [how we approach logging](/docs/getting-started/default-configurations#logging). (Official docs)|frankenphp
-`CADDY_PHP_SERVER_OPTIONS` *Default: ""*|Set PHP server options for the Caddy server. (Official docs)|frankenphp
-`CADDY_SERVER_EXTRA_DIRECTIVES` *Default: ""*|Set extra directives for the Caddy server. ℹ️ `octane:start` overwrites this variable with the Mercure settings from `config/octane.php`. Mount a `.caddyfile` into `/etc/frankenphp/caddyfile-server.d/` instead. See [Adding your own Caddyfile rules](/docs/image-variations/frankenphp#adding-your-own-caddyfile-rules). (Official docs)|frankenphp
-`CADDY_SERVER_LOG_LEVEL` *Default: "warn"*|Set the server log level for the Caddy server. This can also be changed with `LOG_OUTPUT_LEVEL`, but `CADDY_SERVER_LOG_LEVEL` takes precedence. ℹ️ `octane:start` sets this variable itself. Use its `--log-level` option instead. See [Laravel Octane](/docs/framework-guides/laravel/octane#logging). (Official docs)|frankenphp
-`CADDY_SERVER_ROOT` *Default: "/var/www/html/public"*|Set the root directory for the Caddy server. (Official docs)|frankenphp
-`COMPOSER_ALLOW_SUPERUSER` *Default: "1"*|Disable warning about running as super-user|all
-`COMPOSER_HOME` *Default: "/composer"*|The COMPOSER_HOME variable allows you to change the Composer home directory. This is a hidden, global (per-user on the machine) directory that is shared between all projects.|all
-`COMPOSER_MAX_PARALLEL_HTTP` *Default: "24"*|Set to an integer to configure how many files can be downloaded in parallel. Composer ships with 12 by default and must be between 1 and 50. If your proxy has issues with concurrency maybe you want to lower this. Increasing it should generally not result in performance gains.|all
-`DISABLE_DEFAULT_CONFIG` *Default: "false"*|Get full customization of the image and disable all default configurations and automations.| all
-`FRANKENPHP_CONFIG` *Default: ""*|Set the configuration for FrankenPHP. (Official docs)|frankenphp
-`HEALTHCHECK_PATH` *Default: "/healthcheck"*|Set the path for the health check endpoint. (Official docs)|all (except `cli` and `frankenphp`)
-`HEALTHCHECK_SSL_CERTIFICATE_FILE` *Default: "/etc/ssl/healthcheck/localhost.crt"*|Set the path to the SSL certificate for the health check endpoint.| fpm-apache, fpm-nginx, frankenphp
-`HEALTHCHECK_SSL_PRIVATE_KEY_FILE` *Default: "/etc/ssl/healthcheck/localhost.key"*|Set the path to the SSL private key for the health check endpoint.| fpm-apache, fpm-nginx, frankenphp
-`LARAVEL_OCTANE` *Default: unset* *Set by Laravel Octane*|ℹ️ You do not set this variable. Laravel Octane passes `LARAVEL_OCTANE=1` to FrankenPHP when `octane:start` launches it, and `/etc/frankenphp/Caddyfile` uses it to detect Octane. When it is present, the Caddyfile loads `frankenphp-worker.php` from `CADDY_SERVER_ROOT` as a worker, routes requests to it instead of `index.php`, and enables the Caddy admin API on `localhost` (port `2019` unless Octane passes another). ⚠️ If it is set outside of Octane, the value must be exactly `1`. The Caddyfile imports a file named after the value, so `true`, `on`, or an empty string stops FrankenPHP from starting with a "File to import not found" error. See [Laravel Octane](/docs/framework-guides/laravel/octane).|frankenphp
-`LOG_OUTPUT_LEVEL` *Default:* *"warn" (for all)* *"info" (for frankenphp)*|Set the verbosity level for container output and service logs. Valid values (least to most verbose): `emerg`, `alert`, `crit`, `error`, `warn`, `notice`, `info`, `debug`. Each level is translated to the native log configuration for PHP, PHP-FPM, and the active web server. ℹ️ FrankenPHP defaults to `info` because Caddy unifies access and error logs — setting `warn` would suppress HTTP request logs entirely (unlike Apache/NGINX where access logs are a separate directive).|all
-`MERCURE_ENABLED` *Default: "false"*|Set to `true` to turn on FrankenPHP's Mercure hub at `/.well-known/mercure`. Requires `MERCURE_PUBLISHER_JWT_KEY` and `MERCURE_SUBSCRIBER_JWT_KEY`. See [Mercure](/docs/image-variations/frankenphp#mercure).|frankenphp
-`MERCURE_EXTRA_DIRECTIVES` *Default: ""*|Add directives to the Mercure hub, one per line, such as `anonymous` or `cors_origins`. (Official docs)|frankenphp
-`MERCURE_PUBLISHER_JWT_ALG` *Default: "HS256"*|Set the algorithm for the publisher key. A PEM key needs an asymmetric algorithm such as `RS256`.|frankenphp
-`MERCURE_PUBLISHER_JWT_KEY` *Default: unset*|Set the shared secret or PEM public key that verifies publisher tokens.|frankenphp
-`MERCURE_SUBSCRIBER_JWT_ALG` *Default: "HS256"*|Set the algorithm for the subscriber key.|frankenphp
-`MERCURE_SUBSCRIBER_JWT_KEY` *Default: unset*|Set the shared secret or PEM public key that verifies subscriber tokens.|frankenphp
-`MERCURE_TRUSTED_ISSUERS` *Default: "https://localhost"*|Set the `iss` claim your Mercure tokens carry, usually your app's URL. The hub rejects tokens from any other issuer.|frankenphp
-`NGINX_ACCESS_LOG` *Default: "/dev/stdout"*|Set the default output stream for access log.|fpm-nginx
-`NGINX_ERROR_LOG` *Default: "/dev/stderr"*|Set the default output stream for error log.|fpm-nginx
-`NGINX_FASTCGI_BUFFERS` *Default: "8 8k"*|Sets the number and size of the buffers used for reading a response from a FastCGI server. (Official Docs)|fpm-nginx
-`NGINX_FASTCGI_BUFFER_SIZE` *Default: "8k"*|Sets the size of the buffer used for reading a response from a FastCGI server. (Official Docs)|fpm-nginx
-`NGINX_LISTEN_IP_PROTOCOL` *Default: "all"*|Set the IP protocol for NGINX to listen on. Valid values are "all", "ipv4", and "ipv6". (Official Docs)|fpm-nginx
-`NGINX_SERVER_TOKENS` *Default: "off"*|Display NGINX version in responses. (Official Docs)|fpm-nginx
-`NGINX_WEBROOT` *Default: "`/var/www/html/public"*|Sets the root directory for requests. (Official Docs)|fpm-nginx
-`NGINX_CLIENT_MAX_BODY_SIZE` *Default: "100M"*|Sets the max body size for requests. (Official Docs)|fpm-nginx
-`PHP_DATE_TIMEZONE` *Default: "UTC"*|Control your timezone. (Official Docs)|all
-`PHP_DISABLE_FUNCTIONS` *Default: ""*|Comma-separated list of functions to disable, such as `exec,shell_exec`. Empty by default because Laravel, Composer, and Symfony Process rely on `proc_open`. (Official docs)|all
-`PHP_DISPLAY_ERRORS` *Default: Off*|Show PHP errors on screen. (Official docs)|all
-`PHP_DISPLAY_STARTUP_ERRORS` *Default: Off*|Even when display_errors is on, errors that occur during PHP's startup sequence are not displayed. (Official docs)| all
-`PHP_ERROR_LOG` *Default: "/dev/stderr"*|Name of the file where script errors should be logged. . (Official docs)|all
-`PHP_ERROR_REPORTING` *Default: "22527"*|Set PHP error reporting level. Must be a number. Use this tool for help. (Official docs)|all
-`PHP_FILE_UPLOADS` *Default: "On"*|Whether to allow HTTP file uploads. (Official docs)|all
-`PHP_FPM_CHILD_PROCESS_USER` *Default: "www-data"*| ⚠️ Only used if container is running as root. Set the user of the PHP-FPM child processes. (Official docs)|fpm*
-`PHP_FPM_CHILD_PROCESS_GROUP` *Default: "www-data"*| ⚠️ Only used if container is running as root. Set the group of the PHP-FPM child processes. (Official docs)|fpm*
-`PHP_FPM_PM_CONTROL` *Defaults: fpm: dynamic fpm-apache: ondemand fpm-nginx: ondemand*|Choose how the process manager will control the number of child processes. (Official docs)|fpm*
-`PHP_FPM_PM_MAX_CHILDREN` *Default: "20"*|The number of child processes to be created when pm is set to static and the maximum number of child processes to be created when pm is set to dynamic. (Official docs)|fpm*
-`PHP_FPM_PM_MAX_REQUESTS` *Default: "0"*|The number of requests each child process should execute before respawning. This can be useful to work around memory leaks in 3rd party libraries. (Official docs)|fpm*
-`PHP_FPM_PM_MAX_SPARE_SERVERS` *Default: "3"*|The desired maximum number of idle server processes. Used only when pm is set to dynamic. (Official docs)|fpm*
-`PHP_FPM_PM_MIN_SPARE_SERVERS` *Default: "1"*|The desired minimum number of idle server processes. Used only when pm is set to dynamic. (Official docs)|fpm*
-`PHP_FPM_PM_START_SERVERS` *Default: "2"*|The number of child processes created on startup. Used only when pm is set to dynamic. (Official docs)|fpm*
-`PHP_FPM_PM_STATUS_PATH` *Default: ""*|The path to the PHP-FPM status page. (Official docs)|fpm*
-`PHP_FPM_POOL_NAME` *Default: "www"*|Set the name of your PHP-FPM pool (helpful when running multiple sites on a single server).|fpm*
-`PHP_FPM_PROCESS_CONTROL_TIMEOUT` *Default: "10s"*|Set the timeout for the process control commands. (Official docs)|fpm*
-`PHP_HTML_ERRORS` *Default: "On"*|Format displayed errors as HTML. Only affects errors shown on screen when `PHP_DISPLAY_ERRORS` is on, never logged errors. PHP hardcodes this to Off for the CLI SAPI. (Official docs)|all
-`PHP_MAX_EXECUTION_TIME` *Default: "99"*|Set the maximum time in seconds a script is allowed to run before it is terminated by the parser. (Official docs)|all
-`PHP_MAX_FILE_UPLOADS` *Default: "20"*|The maximum number of files allowed to be uploaded in a single request. (Official docs)|all
-`PHP_MAX_INPUT_TIME` *Default: "-1"*|This sets the maximum time in seconds a script is allowed to parse input data, like POST and GET. Timing begins at the moment PHP is invoked at the server and ends when execution begins. The default setting is -1, which means that max_execution_time is used instead. Set to 0 to allow unlimited time. This directive is hardcoded to -1 for the CLI SAPI by PHP. (Official docs)|all
-`PHP_MAX_INPUT_VARS` *Default: "1000"*|Set the limits for number of input variables (e.g., POST, GET, or COOKIE variables) that PHP will process in a single request. (Official docs)|all
-`PHP_MEMORY_LIMIT` *Default: "256M"*|Set the maximum amount of memory in bytes that a script is allowed to allocate. (Official docs)|all
-`PHP_OPCACHE_ENABLE` *Default: "0" (to keep developers sane)*|Enable or disable OPcache. `1` also applies the tuned `PHP_OPCACHE_*` defaults below. CLI commands like `php artisan` also use OPcache when `PHP_OPCACHE_ENABLE_CLI=1`. See the [production performance tuning guide](/docs/guide/production-performance-tuning#php-opcache). (Official docs)|all
-`PHP_OPCACHE_ENABLE_CLI` *Default: "1"*|Enable or disable OPcache for CLI commands like `php artisan`. Only takes effect when `PHP_OPCACHE_ENABLE=1`. Set to `0` to keep OPcache on for the web server only, for example so `PHP_OPCACHE_PRELOAD` does not run on every CLI command. (Official docs)|all
-`PHP_OPCACHE_ENABLE_FILE_OVERRIDE` *Default: "0"*|Enable or disable file existence override (file_exists, etc.). (Official docs)|all
-`PHP_OPCACHE_FORCE_RESTART_TIMEOUT` *Default: "180"*|The number of seconds to wait for a scheduled restart to begin if the cache isn't active, in seconds. If the timeout is hit, then OPcache assumes that something is wrong and will kill the processes holding locks on the cache to permit a restart. (Official docs)|all
-`PHP_OPCACHE_INTERNED_STRINGS_BUFFER` *Default: "32"*|The amount of memory used to store interned strings, in megabytes. This is reserved inside `PHP_OPCACHE_MEMORY_CONSUMPTION`, not in addition to it. (Official docs)|all
-`PHP_OPCACHE_JIT` *Default: "off"*|Enable or disable the JIT compiler. To turn it on, set this to `tracing` **and** set `PHP_OPCACHE_JIT_BUFFER_SIZE` to a non-zero value like `64M`. (Official docs)|all
-`PHP_OPCACHE_JIT_BUFFER_SIZE` *Default: "0"*|The amount of shared memory to reserve for compiled JIT code. A zero value disables the JIT. This is added on top of `PHP_OPCACHE_MEMORY_CONSUMPTION`, so the shared memory segment becomes the sum of both. (Official docs)|all
-`PHP_OPCACHE_MAX_ACCELERATED_FILES` *Default: "32531"*|The maximum number of keys (scripts) in the OPcache hash table. PHP rounds this up to the next value in its prime number table (16229, 32531, 65407, and so on), so `32531` is the exact value PHP uses and reports in `opcache_get_status()`. (Official docs)|all
-`PHP_OPCACHE_MEMORY_CONSUMPTION` *Default: "256"*|The amount of shared memory reserved for OPcache, in megabytes. Memory is only used as files are cached, so a larger value costs nothing until it is needed. (Official docs)|all
-`PHP_OPCACHE_PRELOAD` *Default: ""*|Path to a PHP script that OPcache compiles and runs at startup (preloading). Empty disables preloading. ⚠️ The script also runs on every `php` command unless `PHP_OPCACHE_ENABLE_CLI=0`. Preloading as root requires `PHP_OPCACHE_PRELOAD_USER`. See the [production performance tuning guide](/docs/guide/production-performance-tuning#php-opcache). (Official docs)|all
-`PHP_OPCACHE_PRELOAD_USER` *Default: ""*|The system user to run the preload script as. Only needed when the web server runs as root, since PHP refuses to preload as root without it. (Official docs)|all
-`PHP_OPCACHE_REVALIDATE_FREQ` *Default: "2"*|How often the OPcache checks for updates to cached files (in seconds). Only applies when `PHP_OPCACHE_VALIDATE_TIMESTAMPS=1`. (Official docs)|all
-`PHP_OPCACHE_SAVE_COMMENTS` *Default: "1"*|Keep PHPDoc comments in the cached code. Setting this to `0` saves a little memory but breaks any code that reads PHPDoc annotations at runtime (Doctrine, Zend Framework 2, and PHPUnit). (Official docs)|all
-`PHP_OPCACHE_VALIDATE_TIMESTAMPS` *Default: "0"*|Whether OPcache checks for changes to files. With `0`, PHP files are cached until the container restarts, which is ideal for immutable containers. Set to `1` if you mount your code as a volume and still want OPcache enabled. (Official docs)|all
-`PHP_OPEN_BASEDIR` *Default: "None"* |Limit the files that can be accessed by PHP to the specified directory-tree, including the file itself. `open_basedir` is just an extra safety net, that is in no way comprehensive, and can therefore not be relied upon when security is needed. (Official docs)| all
-`PHP_POST_MAX_SIZE` *Default: "100M"*|Sets max size of post data allowed. (Official docs)|all
-`PHP_REALPATH_CACHE_SIZE` *Default: "4096K"*|Size of the realpath cache. Applications with many files (large `vendor/` directories) may benefit from a larger cache. Note: the cache is disabled when `PHP_OPEN_BASEDIR` is set. (Official docs)|all
-`PHP_REALPATH_CACHE_TTL` *Default: "600"*|The duration of time, in seconds for which to cache realpath information for a given file or directory. `600` is the value Symfony's performance guide recommends for applications that open many PHP files. (Symfony docs) (Official docs)|all
-`PHP_SESSION_COOKIE_HTTPONLY` *Default: "On"*|Add the `HttpOnly` flag to the session cookie so browser scripts cannot read it. On by default as recommended by PHP. Only applies to native PHP sessions. Laravel manages its own session cookie flags. (Official docs)|all
-`PHP_SESSION_COOKIE_SECURE` *Default: "false"*|Specifies whether the session cookie should only be sent over HTTPS. Off by default so local development over HTTP works. Set to `true` in production when serving over HTTPS. Only applies to native PHP sessions. Laravel manages its own session cookie flags. (Official docs)|all
-`PHP_SESSION_UPLOAD_PROGRESS_ENABLED` *Default: "0"*|Track file upload progress in `$_SESSION`. Off by default following PHP's October 23, 2026 security advisory. Set to `1` only if your app reads upload progress from the session and never changes `session.serialize_handler` at runtime. Only applies to native PHP sessions. (Official docs)|all
-`PHP_UPLOAD_MAX_FILE_SIZE` *Default: "100M"*|The maximum size of an uploaded file. (Official docs)|all
-`PHP_ZEND_DETECT_UNICODE` *Default: ""*|Check for BOM (Byte Order Mark) and see if the file contains valid multibyte characters. This detection is performed before processing of __halt_compiler(). Available only in Zend Multibyte mode. (Official docs)|all
-`PHP_ZEND_MULTIBYTE` *Default: "Off"*|Enable or disable Zend Multibyte. (Official docs)|all
-`S6_BEHAVIOUR_IF_STAGE2_FAILS` *Default: "2" (stop the container)*|Determines what the container should do if one of the service scripts fails (Official docs)|fpm-nginx, fpm-apache
-`S6_CMD_WAIT_FOR_SERVICES_MAXTIME` *Default: "0"*|The maximum time (in milliseconds) the services could take to bring up before proceeding to CMD executing (Official docs)|fpm-nginx, fpm-apache
-`S6_VERBOSITY` *Default: "1"*|Set the verbosity of "S6 Overlay" (the init system these images are based on). The default is "1" (print warnings and errors). The scale goes from 1 to 5, but the output will quickly become very noisy. If you're having issues, start here. You can also customize many other variables. (Official docs)|fpm-nginx, fpm-apache
-`SHOW_WELCOME_MESSAGE` *Default: "true"*|Show a helpful welcome message showing container information when the container starts.|all
-`SSL_CERTIFICATE_FILE` *Default: "/etc/ssl/private/self-signed-web.crt"*|Path to public certificate file for HTTPS. You must provide this file otherwise a self-signed key pair will be generated for you.|fpm-nginx, fpm-apache
-`SSL_MODE` *Default: "off"*|Configure how you would like to handle SSL. This can be "off" (HTTP only), "mixed" (HTTP + HTTPS), or "full" (HTTPS only). If you use HTTP, you may need to also change `PHP_SESSION_COOKIE_SECURE`.|fpm-nginx, fpm-apache, frankenphp
-`SSL_PRIVATE_KEY_FILE` *Default: "/etc/ssl/private/self-signed-web.key"*|Path to private key file for HTTPS. You must provide this file otherwise a self-signed key pair will be generated for you.|fpm-nginx, fpm-apache, frankenphp
-`TRUSTED_PROXY` *Default: "cloudflare"*|Configure which proxy IPs are trusted to pass the real client IP address. Valid options: `cloudflare`, `sucuri`, `local`, or `off`. See [Configuring Trusted Proxies](/docs/guide/configuring-trusted-proxies) for details.|fpm-nginx, fpm-apache, frankenphp
diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md
deleted file mode 100644
index ddcb1975f..000000000
--- a/docs/content/docs/8.reference/2.command-reference.md
+++ /dev/null
@@ -1,143 +0,0 @@
----
-head.title: 'Command reference - Docker PHP - Server Side Up'
-description: 'Learn about all the commands available within the serversideup/php image to improve your developer experience.'
-layout: docs
----
-
-::lead-p
-We included a few commands in the `/usr/local/bin` directory to help you with your development experience. These commands are available in the `serversideup/php` image.
-::
-
-::note
-Since our images are unprivileged by default, you'll need to switch to the `root` user to run these commands. This is a security feature that ensures that your container is as secure as possible.
-::
-
-## Official PHP Docker Commands
-Since our images are based off the official PHP Docker images, any commands from the Official PHP Docker images are available in our images. You can find the full list of commands on the [official PHP Docker images documentation](https://hub.docker.com/_/php).
-
-## docker-php-serversideup-dep-install-alpine
-This command will detect the operating system and install packages if it is an Alpine based system. This is helpful if you're building multiple operating systems from the same Dockerfile.
-
-
-```bash [Example - Installing the git package on Alpine]
-# Usage: docker-php-serversideup-dep-install-alpine [alpine-packages]
-docker-php-serversideup-dep-install-alpine git
-```
-
-Packages can be passed as separate arguments, as a comma-separated list, or split across multiple lines with backslash continuations:
-
-```bash [Example - Installing multiple packages on Alpine]
-# Separate arguments
-docker-php-serversideup-dep-install-alpine git zip unzip
-
-# Comma-separated
-docker-php-serversideup-dep-install-alpine "git, zip, unzip"
-
-# Backslash continuations (useful in a Dockerfile)
-docker-php-serversideup-dep-install-alpine \
- git \
- zip \
- unzip
-```
-
-## docker-php-serversideup-dep-install-debian
-This command will detect the operating system and install packages if it is a Debian based system. This is helpful if you're building multiple operating systems from the same Dockerfile.
-
-```bash [Example - Installing the git package on Debian]
-# Usage: docker-php-serversideup-dep-install-debian [debian-packages]
-docker-php-serversideup-dep-install-debian git
-```
-
-Packages can be passed as separate arguments, as a comma-separated list, or split across multiple lines with backslash continuations:
-
-```bash [Example - Installing multiple packages on Debian]
-# Separate arguments
-docker-php-serversideup-dep-install-debian git zip unzip
-
-# Comma-separated
-docker-php-serversideup-dep-install-debian "git, zip, unzip"
-
-# Backslash continuations (useful in a Dockerfile)
-docker-php-serversideup-dep-install-debian \
- git \
- zip \
- unzip
-```
-
-## docker-php-serversideup-download
-A build-time helper that downloads a file with retries. Our build matrix starts every image at the same moment, and hosts like GitHub occasionally answer that burst with a `500`. This command retries five times with a short backoff and fails the build if the file never arrives. It's useful in your own Dockerfiles for the same reason.
-
-```bash [Example - Downloading a file during a build]
-# Usage: docker-php-serversideup-download [output-file]
-docker-php-serversideup-download https://example.com/tool.tar.gz /tmp/tool.tar.gz
-
-# Without an output file, the download is written to stdout
-docker-php-serversideup-download https://example.com/key.asc | gpg --dearmor > /usr/share/keyrings/example.gpg
-```
-
-## docker-php-serversideup-entrypoint
-Our default entrypoint script that is used for all images.
-
-## docker-php-serversideup-install-php-extensions
-This command installs PHP extensions with `install-php-extensions`. It optionally accepts a second argument of space separated `name=source` pairs, which swap an extension for a different source. This is useful when an extension needs to be built from a branch or tag instead of the latest PECL release.
-
-```bash [Example - Installing extensions with an override]
-# Usage: docker-php-serversideup-install-php-extensions [extensions] [overrides]
-docker-php-serversideup-install-php-extensions "opcache redis zip" "redis=phpredis/phpredis@develop"
-```
-
-The example above installs `opcache` and `zip` normally, but builds `redis` from the `develop` branch of `phpredis/phpredis`.
-
-## docker-php-serversideup-install-php-ext-installer
-This is an internal helper script to shorten up the syntax for the installation of the PHP extension installer.
-
-```bash [Example - Installing the PHP extension installer]
-# Usage: docker-php-serversideup-install-php-ext-installer [version]
-docker-php-serversideup-install-php-ext-installer 2.2.0
-```
-
-## docker-php-serversideup-set-file-permissions
-This command is used to set the file permissions of a service in the container. This is useful for development environments where you want to match the user and group ID of the host machine.
-
-[Learn more about working with file permissions →](/docs/guide/understanding-file-permissions)
-
-```bash [Example - Setting the file permissions of the NGINX service]
-# Usage: docker-php-serversideup-set-file-permissions --owner USER:GROUP
-docker-php-serversideup-set-file-permissions --owner 1000:1000
-```
-
-The above command will automatically detect the service and update the file permissions accordingly.
-
-## docker-php-serversideup-set-id
-This command is used to set the user and group ID of the `www-data` user in the container. This is useful for development environments where you want to match the user and group ID of the host machine.
-
-[Learn more about working with file permissions →](/docs/guide/understanding-file-permissions)
-
-```bash [Example - Setting the user and group ID of the www-data user]
-# docker-php-serversideup-set-id [username] [uid]:[gid]
-docker-php-serversideup-set-id www-data 1000:1000
-```
-
-## docker-php-serversideup-s6-init
-This command converts every script in `/etc/entrypoint.d` into an S6 Overlay oneshot service that runs in the same order the entrypoint would have run it. This is useful when your own S6 service needs to wait for one of our scripts. It is safe to run more than once, so a base image and an image built on it can both use it.
-
-[Learn more about using S6 Overlay dependencies →](/docs/customizing-the-image/adding-your-own-start-up-scripts#advanced-scenarios-s6-overlay-dependencies)
-
-## docker-php-serversideup-s6-install
-This is a command used at build time to install a specific version of S6 Overlay.
-
-## healthcheck-octane
-Runs `php artisan octane:status` so a container health check can confirm that the Octane server is running. It ships in every image and works with any Octane server (FrankenPHP, Swoole, Open Swoole, or RoadRunner), but only in a container where Octane started the server.
-
-[Learn more about Laravel Octane →](/docs/framework-guides/laravel/octane#health-checks)
-
-## install-php-extensions
-This command is a wrapper around the `docker-php-ext-install` command that comes with the official PHP Docker images. This command allows you to install PHP extensions with a single command.
-
-[Check out the docker-php-extension-installer project on GitHub →](https://github.com/mlocati/docker-php-extension-installer){target="_blank"}
-
-Here's an example of how you can use this command:
-
-```bash [Example - Installing the "intl" extension]
-install-php-extensions intl
-```
\ No newline at end of file
diff --git a/docs/content/docs/8.security/1.how-images-are-built.md b/docs/content/docs/8.security/1.how-images-are-built.md
new file mode 100644
index 000000000..92c73420e
--- /dev/null
+++ b/docs/content/docs/8.security/1.how-images-are-built.md
@@ -0,0 +1,56 @@
+---
+head.title: 'How Images Are Built - Docker PHP - Server Side Up'
+description: 'Every serversideup/php image is built in public, tested before it ships, and signed so you can verify where it came from.'
+layout: docs
+title: How images are built
+---
+
+::lead-p
+You're about to run our images in production. You should be able to see exactly how they're made. Every image is built in public, tested before anyone can pull it, and signed so you can prove where it came from.
+::
+
+## Built in public, never on a laptop
+
+Every published image comes from [GitHub Actions](https://github.com/serversideup/docker-php/actions){target="_blank"} in this repository. Images are never built or pushed from a local machine. The logs of every run are public.
+
+The workflows in [`.github/workflows/`](https://github.com/serversideup/docker-php/tree/main/.github/workflows){target="_blank"} are the full recipe. This page is the short version.
+
+## The same PHP as the official images
+
+We compile PHP from the php.net source with the same [Dockerfiles that build the official images](https://github.com/docker-library/php){target="_blank"}. The build checks php.net's checksum and GPG signature, exactly as upstream does.
+
+We make three small changes. We set the PHP patch to build, skip a PECL step so a PECL outage can't fail the build, and name Server Side Up in `php -v`. [`scripts/prepare-php-base.sh`](https://github.com/serversideup/docker-php/blob/main/scripts/prepare-php-base.sh){target="_blank"} shows every change.
+
+## Security fixes don't wait
+
+- **New PHP patches:** we check php.net every hour. A new patch starts a build right away.
+- **Operating system fixes:** every image rebuilds each Tuesday. PHP is recompiled from scratch, never from a cache, so a patched library such as OpenSSL lands in that week's images.
+
+| Trigger | Tags it updates |
+|---|---|
+| A new PHP patch on php.net | Floating tags |
+| Every Tuesday at 08:00 UTC | Floating tags |
+| A GitHub release | Floating tags, plus pinned `-vX.Y.Z` tags |
+| A GitHub pre-release, or every Monday at 08:25 UTC | `beta-` tags |
+
+The [upgrade guide](/docs/upgrading/upgrade-guide) explains floating and pinned tags.
+
+## Nothing ships until every image passes
+
+Each image is tested on native `amd64` and `arm64` hardware before it's published. If a single image fails to build or test, nothing from that run is published.
+
+The image you pull is byte for byte the image the tests ran. After publishing, the pipeline checks that the digest on Docker Hub and GitHub Packages matches the tested one.
+
+## Signed, with an SBOM and provenance
+
+Every image carries an SPDX SBOM and SLSA build provenance. Each one is signed with [cosign](https://github.com/sigstore/cosign){target="_blank"} keyless signing, tied to our publishing workflow. There's no private key that could leak.
+
+Check them yourself before you deploy. [Verifying images](/docs/security/verifying-images) has the commands. Published images are also [scanned for vulnerabilities](/docs/security/vulnerability-scanning).
+
+## Every dependency is pinned and reviewed
+
+Every pinned dependency version lives in one file: [`php-versions-base-config.yml`](https://github.com/serversideup/docker-php/blob/main/scripts/conf/php-versions-base-config.yml){target="_blank"}. [Renovate](https://docs.renovatebot.com/){target="_blank"} proposes each update as a pull request. It waits three days after a release before it does. A maintainer reviews each one, except the weekly docker-library/php bump, which merges once every check passes. Every GitHub Action is pinned to a commit SHA.
+
+## Every change is tested the same way
+
+A pull request that changes an image builds every variation and runs the same tests. The results are public on the pull request. [Contributing](/docs/getting-started/contributing) shows how to run the same build and tests on your own machine.
diff --git a/docs/content/docs/8.security/2.verifying-images.md b/docs/content/docs/8.security/2.verifying-images.md
new file mode 100644
index 000000000..6885d8ca5
--- /dev/null
+++ b/docs/content/docs/8.security/2.verifying-images.md
@@ -0,0 +1,70 @@
+---
+head.title: 'Verifying Images - Docker PHP - Server Side Up'
+description: 'Check that a serversideup/php image was built and published by our GitHub Actions workflows, and see what is inside it.'
+layout: docs
+title: Verifying images
+---
+
+::lead-p
+Every image we publish is signed and carries a software bill of materials (SBOM) and build provenance. Use them to confirm that the image you pulled came from our GitHub Actions workflows and to see exactly what went into it.
+::
+
+## Why verify an image
+A tag is only a pointer. Whoever controls it can point `8.5-cli` at a different image. A signature check proves the image was published by the `serversideup/docker-php` repository's own publish workflow, from `main` or a release tag. The SBOM and provenance tell you what is in the image and how it was built, which is what most supply-chain policies and audits ask for.
+
+::note
+Images published before signing started have provenance but no SBOM or signature. Version-pinned tags from v5.0.5 and earlier are never rebuilt, so they always fail the check, while floating tags such as `8.5-cli` are signed from their next rebuild.
+::
+
+## Verify the signature
+Install cosign by following the [Sigstore installation guide](https://docs.sigstore.dev/cosign/system_config/installation/), then run:
+
+```bash [Terminal]
+cosign verify serversideup/php:8.5-cli \
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
+ --certificate-identity-regexp '^https://github\.com/serversideup/docker-php/\.github/workflows/service_publish-images\.yml@refs/(heads/main|tags/v.+)$'
+```
+
+The two flags say who you trust. The issuer is GitHub Actions. The identity is our publish workflow running from `main` or a `v` release tag. Beta images are signed from their pre-release tag, such as `v5.0.0-beta5`, so they pass this check too. Verifying proves an image came from us, not that it is a stable release. cosign exits with an error if the image has no signature from that identity.
+
+Signing is keyless: there is no public key to download. Each signature is tied to the workflow run that made it through GitHub's OIDC token and recorded in Sigstore's public transparency log.
+
+Docker Hub and GitHub Packages each hold their own signature. The same command works for `ghcr.io/serversideup/php:8.5-cli`. The signature covers the image index, which references the image for every platform along with its SBOM and provenance. One check covers all of them.
+
+## Read the SBOM
+The SBOM is an [SPDX](https://spdx.dev/) document that lists the packages in the image. Read it for one platform with Docker Buildx:
+
+```bash [Terminal]
+docker buildx imagetools inspect serversideup/php:8.5-cli \
+ --format '{{ json (index .SBOM "linux/amd64").SPDX }}'
+```
+
+Use `linux/arm64` for the arm64 image. On an image published before SBOMs were added, the command prints `null`.
+
+## Read the build provenance
+The provenance is a [SLSA](https://slsa.dev/) record of how the image was built: the source commit, the Dockerfile, the build arguments, and the base images. We publish it in full (`mode=max`):
+
+```bash [Terminal]
+docker buildx imagetools inspect serversideup/php:8.5-cli \
+ --format '{{ json (index .Provenance "linux/amd64").SLSA }}'
+```
+
+To see only the commit and Dockerfile an image was built from, read `invocation.configSource`:
+
+```bash [Terminal]
+docker buildx imagetools inspect serversideup/php:8.5-cli \
+ --format '{{ json (index .Provenance "linux/amd64").SLSA.invocation.configSource }}'
+```
+
+```json [Output]
+{
+ "digest": {
+ "sha1": "af1462e3abf8c67ff3fb78141255adc33de5bf2c"
+ },
+ "entryPoint": "src/variations/cli/Dockerfile",
+ "uri": "https://github.com/serversideup/docker-php.git#af1462e3abf8c67ff3fb78141255adc33de5bf2c"
+}
+```
+
+## Scan it yourself
+The images are public, so you can run any scanner against them. [Vulnerability scanning](/docs/security/vulnerability-scanning) shows a Grype command and explains why most findings have no fix.
diff --git a/docs/content/docs/8.security/3.vulnerability-scanning.md b/docs/content/docs/8.security/3.vulnerability-scanning.md
new file mode 100644
index 000000000..5424468bd
--- /dev/null
+++ b/docs/content/docs/8.security/3.vulnerability-scanning.md
@@ -0,0 +1,34 @@
+---
+head.title: 'Vulnerability Scanning - Docker PHP - Server Side Up'
+description: 'How every published serversideup/php image is scanned for known vulnerabilities, and where to see the results.'
+layout: docs
+title: Vulnerability scanning
+---
+
+::lead-p
+Every image we publish is scanned for known vulnerabilities after each production build. The results are public. The numbers to watch are the fixable ones.
+::
+
+## When and how images are scanned
+A scan runs after every production publish. That includes releases, the weekly rebuild on Tuesdays at 08:00 UTC, and new PHP patches. [Grype](https://github.com/anchore/grype) scans the `linux/amd64` image of every published tag, pulled from GitHub Packages.
+
+The scan job has no access token and no registry credentials. Grype runs in its own container. A compromised scanner would have nothing to steal and nothing it could publish.
+
+## See the latest results
+Open the [latest successful production run](https://github.com/serversideup/docker-php/actions/workflows/action_publish-images-production.yml?query=is%3Asuccess). Its summary page has a table for each variation with every image's critical, high, and medium counts, and how many findings have a fix.
+
+Findings that have a fix also become code scanning alerts for the maintainers. Each one closes once a scan no longer finds it.
+
+## Most findings have no fix yet
+A scanner matches every package in the image against public CVE databases. Many of the CVEs it reports are ones the distribution has marked "not fixed" or "won't fix". There is no newer package to install for those.
+
+No rebuild can change an unfixed finding. The fixable count is the meaningful number. The docker-library project explains this in more depth in [Why does my security scanner show that an image has CVEs?](https://github.com/docker-library/faq#why-does-my-security-scanner-show-that-an-image-has-cves)
+
+## Scan it yourself
+You don't have to rely on our scan. The images are public and work with any scanner:
+
+```bash [Terminal]
+grype serversideup/php:8.5-cli --only-fixed
+```
+
+To report a problem in our own scripts, configs, or workflows, use [private vulnerability reporting](https://github.com/serversideup/docker-php/security/policy) instead.
diff --git a/docs/content/docs/9.reference/1.environment-variable-specification.md b/docs/content/docs/9.reference/1.environment-variable-specification.md
new file mode 100644
index 000000000..19b960d57
--- /dev/null
+++ b/docs/content/docs/9.reference/1.environment-variable-specification.md
@@ -0,0 +1,26 @@
+---
+head.title: 'Environment variable specification - Docker PHP - Server Side Up'
+description: 'Every environment variable the serversideup/php images read, with its default and the variations that use it.'
+layout: docs
+title: Environment variable specification
+---
+
+::lead-p
+Every setting in these images is an environment variable, so you can change PHP, the web server, and the Laravel automations without mounting config files.
+::
+
+Set them in your `compose.yml`:
+
+```yaml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ environment:
+ PHP_MEMORY_LIMIT: "512M"
+ PHP_OPCACHE_ENABLE: "1"
+```
+
+Search by name or setting, or pick a variation to see only the variables it reads, with its defaults. Select a variable for its accepted values, the official docs, and a line to copy into `compose.yml`. An empty default (`""`) leaves the setting off.
+
+::env-var-explorer
+::
diff --git a/docs/content/docs/9.reference/2.command-reference.md b/docs/content/docs/9.reference/2.command-reference.md
new file mode 100644
index 000000000..72666e5f7
--- /dev/null
+++ b/docs/content/docs/9.reference/2.command-reference.md
@@ -0,0 +1,15 @@
+---
+head.title: 'Command reference - Docker PHP - Server Side Up'
+description: 'The helper commands in /usr/local/bin of the serversideup/php images, which variations ship them, and how to use them.'
+layout: docs
+title: Command reference
+---
+
+::lead-p
+The images add helper commands to `/usr/local/bin` for installing packages and extensions, fixing file permissions, and checking Laravel processes.
+::
+
+Every command from the [official PHP image](https://hub.docker.com/_/php){target="_blank"}, such as `docker-php-ext-install` and `pecl`, also works. The images run as `www-data`, so commands marked **Requires `USER root`** need `USER root` in your Dockerfile and `USER www-data` again afterward.
+
+::command-explorer
+::
diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts
index aa60224d0..44022e99d 100644
--- a/docs/nuxt.config.ts
+++ b/docs/nuxt.config.ts
@@ -26,12 +26,17 @@ export default defineNuxtConfig({
highlight: {
theme: 'github-dark',
langs: [
+ 'apache',
'jinja',
'bash',
'diff',
'dockerfile',
+ 'ini',
+ 'json',
'nginx',
'php',
+ 'sh',
+ 'yaml',
]
},
toc: {
@@ -43,10 +48,43 @@ export default defineNuxtConfig({
compatibilityDate: '2024-07-11',
+ routeRules: {
+ '/docs/getting-started/upgrade-guide': { redirect: '../upgrading/upgrade-guide' },
+ '/docs/getting-started/changelog': { redirect: 'https://github.com/serversideup/docker-php/releases' },
+ '/docs/getting-started/about': { redirect: '../troubleshooting/getting-help' },
+ '/docs/guide/migrating-from-official-php-images': { redirect: '../getting-started/migrating-from-official-php-images' },
+ '/docs/guide/using-healthchecks-with-laravel': { redirect: '../framework-guides/laravel/health-checks' },
+ '/docs/guide/using-s6-overlay': { redirect: '../customizing-the-image/using-s6-overlay' },
+ '/docs/guide/understanding-file-permissions': { redirect: '../customizing-the-image/understanding-file-permissions' },
+ '/docs/guide/configuring-trusted-proxies': { redirect: '../deployment-and-production/configuring-trusted-proxies' },
+ '/docs/guide/major-version-migrations': { redirect: '../upgrading/upgrade-guide#upgrade-to-a-new-major-version' },
+ '/docs/guide/production-performance-tuning': { redirect: '../deployment-and-production/production-performance-tuning' },
+ '/docs/guide/verifying-images': { redirect: '../security/verifying-images' },
+ '/docs/reference/verifying-images': { redirect: '../security/verifying-images' },
+ '/docs/reference/vulnerability-scanning': { redirect: '../security/vulnerability-scanning' },
+ '/docs/reference/how-images-are-built': { redirect: '../security/how-images-are-built' },
+ '/docs/deployment-and-production/development-to-production': { redirect: 'packaging-your-app-for-deployment' }
+ },
+
nitro: {
prerender: {
routes: [
- '/'
+ '/',
+ '/docs/getting-started/upgrade-guide',
+ '/docs/getting-started/changelog',
+ '/docs/getting-started/about',
+ '/docs/guide/migrating-from-official-php-images',
+ '/docs/guide/using-healthchecks-with-laravel',
+ '/docs/guide/using-s6-overlay',
+ '/docs/guide/understanding-file-permissions',
+ '/docs/guide/configuring-trusted-proxies',
+ '/docs/guide/major-version-migrations',
+ '/docs/guide/production-performance-tuning',
+ '/docs/guide/verifying-images',
+ '/docs/reference/verifying-images',
+ '/docs/reference/vulnerability-scanning',
+ '/docs/reference/how-images-are-built',
+ '/docs/deployment-and-production/development-to-production'
],
crawlLinks: true,
autoSubfolderIndex: false
@@ -122,17 +160,17 @@ export default defineNuxtConfig({
]
},
{
- title: 'Advanced Guides',
+ title: 'Customizing The Image',
contentCollection: 'docs',
contentFilters: [
- { field: 'path', operator: 'LIKE', value: '/docs/guide%' }
+ { field: 'path', operator: 'LIKE', value: '/docs/customizing-the-image%' }
]
},
{
- title: 'Customizing The Image',
+ title: 'Upgrading',
contentCollection: 'docs',
contentFilters: [
- { field: 'path', operator: 'LIKE', value: '/docs/customizing-the-image%' }
+ { field: 'path', operator: 'LIKE', value: '/docs/upgrading%' }
]
},
{
@@ -142,6 +180,13 @@ export default defineNuxtConfig({
{ field: 'path', operator: 'LIKE', value: '/docs/troubleshooting%' }
]
},
+ {
+ title: 'Security',
+ contentCollection: 'docs',
+ contentFilters: [
+ { field: 'path', operator: 'LIKE', value: '/docs/security%' }
+ ]
+ },
{
title: 'Reference',
contentCollection: 'docs',
diff --git a/docs/server/plugins/reference-llms.ts b/docs/server/plugins/reference-llms.ts
new file mode 100644
index 000000000..929cec5a9
--- /dev/null
+++ b/docs/server/plugins/reference-llms.ts
@@ -0,0 +1,8 @@
+import type { H3Event } from 'h3'
+
+export default defineNitroPlugin((nitroApp) => {
+ // Nuxt Content calls this for every page right before writing it into llms-full.txt.
+ nitroApp.hooks.hook('content:llms:generate:document' as never, async (event: H3Event, doc: { body?: { value: unknown[] } }) => {
+ await expandReferenceComponents(event, doc.body)
+ })
+})
diff --git a/docs/server/routes/raw/[...slug].md.get.ts b/docs/server/routes/raw/[...slug].md.get.ts
index 0bdd7966c..024c2c90b 100644
--- a/docs/server/routes/raw/[...slug].md.get.ts
+++ b/docs/server/routes/raw/[...slug].md.get.ts
@@ -22,6 +22,8 @@ export default eventHandler(async (event) => {
page.body.value.unshift(['h1', {}, page.title])
}
+ await expandReferenceComponents(event, page.body)
+
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' })
})
diff --git a/docs/server/utils/reference-markdown.ts b/docs/server/utils/reference-markdown.ts
new file mode 100644
index 000000000..388d08232
--- /dev/null
+++ b/docs/server/utils/reference-markdown.ts
@@ -0,0 +1,125 @@
+import type { H3Event } from 'h3'
+import { queryCollection } from '@nuxt/content/nitro'
+import type { Collections } from '@nuxt/content'
+
+type MinimarkNode = [string, Record, ...(MinimarkNode | string)[]]
+type Child = MinimarkNode | string
+
+type Reference = { label: string, url: string }
+
+type Variable = {
+ name: string
+ default: string | null
+ variation_defaults?: Record
+ variations?: string[]
+ requires?: string
+ values?: string[]
+ description: string
+ reference?: Reference
+}
+
+type Command = {
+ name: string
+ summary: string
+ variations?: string[]
+ root?: boolean
+ description?: string
+ usage?: string
+ options?: { name: string, description: string }[]
+ example?: { language: string, filename: string, code: string }
+ reference?: Reference
+}
+
+// Reference data only uses inline code and links. Build real nodes from them, because
+// Markdown left in a text node gets escaped when the page is written back out.
+function inline(text: string): Child[] {
+ const children: Child[] = []
+ const pattern = /\[([^\]]+)\]\(([^)]+)\)|`([^`]+)`/g
+ let last = 0
+ for (const match of text.matchAll(pattern)) {
+ if (match.index > last) children.push(text.slice(last, match.index))
+ if (match[3] !== undefined) {
+ children.push(['code', {}, match[3]])
+ } else {
+ const label = match[1] ?? ''
+ const content: Child = label.startsWith('`') && label.endsWith('`') ? ['code', {}, label.slice(1, -1)] : label
+ children.push(['a', { href: match[2] }, content])
+ }
+ last = match.index + match[0].length
+ }
+ if (last < text.length) children.push(text.slice(last))
+ return children
+}
+
+function table(headings: string[], rows: Child[][][]): MinimarkNode {
+ return ['table', {},
+ ['thead', {}, ['tr', {}, ...headings.map((label): MinimarkNode => ['th', {}, label])]],
+ ['tbody', {}, ...rows.map((cells): MinimarkNode => ['tr', {}, ...cells.map((cell): MinimarkNode => ['td', {}, ...cell])])]
+ ]
+}
+
+const byName = (items: T[]) => [...items].sort((a, b) => a.name.localeCompare(b.name))
+
+function variableNodes(variables: Variable[]): MinimarkNode[] {
+ const rows = byName(variables).map((variable) => {
+ const defaults: Child[] = [variable.default === null ? 'Unset' : ['code', {}, variable.default === '' ? '""' : variable.default]]
+ for (const [name, value] of Object.entries(variable.variation_defaults ?? {})) {
+ defaults.push(', ', ['code', {}, value], ' on ', ['code', {}, name])
+ }
+ const notes = [variable.description]
+ if (variable.values) notes.push(`Values: ${variable.values.map(value => `\`${value}\``).join(', ')}.`)
+ if (variable.requires) notes.push(`Needs \`${variable.requires}=true\`.`)
+ if (variable.reference) notes.push(`Further reading: [${variable.reference.label}](${variable.reference.url}).`)
+
+ return [
+ [['code', {}, variable.name] as MinimarkNode],
+ defaults,
+ [(variable.variations ?? ['all']).join(', ')],
+ inline(notes.join(' '))
+ ]
+ })
+ return [table(['Variable', 'Default', 'Variations', 'Description'], rows)]
+}
+
+function commandNodes(commands: Command[]): MinimarkNode[] {
+ return byName(commands).flatMap((command) => {
+ const notes = [command.summary]
+ if (command.description) notes.push(command.description)
+ notes.push(`Variations: ${(command.variations ?? ['all']).join(', ')}.`)
+ if (command.root) notes.push('Needs `USER root`.')
+ if (command.reference) notes.push(`Further reading: [${command.reference.label}](${command.reference.url}).`)
+
+ const nodes: MinimarkNode[] = [
+ ['h2', { id: command.name }, command.name],
+ ['p', {}, ...inline(notes.join(' '))]
+ ]
+ if (command.usage) nodes.push(['pre', { language: 'bash', code: command.usage }, ['code', {}, command.usage]])
+ if (command.options) nodes.push(table(['Option', 'Description'], command.options.map(option => [[['code', {}, option.name] as MinimarkNode], inline(option.description)])))
+ if (command.example) {
+ const { language, filename, code } = command.example
+ nodes.push(['pre', { language, filename, code }, ['code', {}, code]])
+ }
+ return nodes
+ })
+}
+
+const COMPONENTS: Record Promise> = {
+ 'env-var-explorer': async (event) => {
+ const data = await queryCollection(event, 'envVars' as keyof Collections).first() as { variables: Variable[] } | null
+ return data ? variableNodes(data.variables) : []
+ },
+ 'command-explorer': async (event) => {
+ const data = await queryCollection(event, 'commands' as keyof Collections).first() as { commands: Command[] } | null
+ return data ? commandNodes(data.commands) : []
+ }
+}
+
+// The explorers render from content/data/*.yml in the browser. Markdown outputs
+// (the raw page and llms-full.txt) get the same data as plain Markdown instead.
+export async function expandReferenceComponents(event: H3Event, body: { value: unknown[] } | undefined) {
+ if (!body) return
+ for (const [tag, build] of Object.entries(COMPONENTS)) {
+ const index = body.value.findIndex(node => Array.isArray(node) && node[0] === tag)
+ if (index !== -1) body.value.splice(index, 1, ...await build(event))
+ }
+}
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 000000000..d1cb568f6
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,100 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
+ "enabledManagers": ["custom.regex", "github-actions", "npm"],
+ "customManagers": [
+ {
+ "customType": "regex",
+ "description": "Versions with a '# renovate:' comment on the line above",
+ "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml", ".github/workflows/service_lint.yml", ".github/workflows/service_scan-images.yml"],
+ "matchStrings": [
+ "#\\s*renovate:\\s*datasource=(?\\S+)\\s+depName=(?\\S+)(?:\\s+versioning=(?\\S+))?\\s*\\n\\s*[A-Z0-9_]+:\\s*\"?(?[^\"\\s@]+)(?:@(?sha256:[a-f0-9]{64}))?\"?"
+ ]
+ },
+ {
+ "customType": "regex",
+ "description": "The docker-library/php commit the PHP base images are compiled from",
+ "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"],
+ "matchStrings": ["DOCKER_LIBRARY_PHP_REF: (?[a-f0-9]{40})"],
+ "currentValueTemplate": "master",
+ "depNameTemplate": "docker-library/php",
+ "packageNameTemplate": "https://github.com/docker-library/php",
+ "datasourceTemplate": "git-refs"
+ },
+ {
+ "customType": "regex",
+ "description": "NGINX package for each Alpine version",
+ "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"],
+ "matchStrings": ["number: (?3\\.\\d+)\\n\\s+nginx_version: (?\\S+)"],
+ "depNameTemplate": "nginx-alpine{{{packageName}}}",
+ "datasourceTemplate": "custom.nginx-alpine",
+ "versioningTemplate": "apk",
+ "extractVersionTemplate": "^nginx-(?\\d+\\.\\d+\\.\\d+-r\\d+)\\.apk$"
+ },
+ {
+ "customType": "regex",
+ "description": "NGINX package for each Debian version",
+ "managerFilePatterns": ["scripts/conf/php-versions-base-config.yml"],
+ "matchStrings": ["version: (?[a-z]+)\\n\\s+number: \\d+\\n\\s+nginx_version: (?\\S+)"],
+ "depNameTemplate": "nginx-{{{packageName}}}",
+ "datasourceTemplate": "custom.nginx-debian",
+ "versioningTemplate": "deb",
+ "extractVersionTemplate": "^nginx_(?\\d+\\.\\d+\\.\\d+-\\d+~{{{packageName}}})_amd64\\.deb$"
+ }
+ ],
+ "customDatasources": {
+ "nginx-alpine": {
+ "defaultRegistryUrlTemplate": "https://nginx.org/packages/alpine/v{{packageName}}/main/x86_64/",
+ "format": "html"
+ },
+ "nginx-debian": {
+ "defaultRegistryUrlTemplate": "https://nginx.org/packages/debian/pool/nginx/n/nginx/",
+ "format": "html"
+ }
+ },
+ "packageRules": [
+ {
+ "description": "Wait three days before proposing a new release, so a hijacked or broken one is usually caught first. git-refs and the nginx.org listings have no release dates, so they are not covered.",
+ "matchDatasources": ["github-releases", "github-tags", "docker"],
+ "minimumReleaseAge": "3 days"
+ },
+ {
+ "description": "zizmor's releases are tagged v1.2.3 but its container image is tagged 1.2.3",
+ "matchDepNames": ["zizmorcore/zizmor"],
+ "extractVersion": "^v(?.+)$"
+ },
+ {
+ "description": "Bump NGINX on every OS in one pull request",
+ "matchDatasources": ["custom.nginx-alpine", "custom.nginx-debian"],
+ "groupName": "nginx"
+ },
+ {
+ "description": "Merge the weekly docker-library/php bump once every check passes. Renovate merges it itself because GitHub's auto-merge would merge right away without required checks.",
+ "matchDepNames": ["docker-library/php"],
+ "schedule": ["before 6am on monday"],
+ "automerge": true,
+ "platformAutomerge": false
+ },
+ {
+ "description": "Go and watcher follow FrankenPHP, so its pull request reminds you to bump them together",
+ "matchDepNames": ["php/frankenphp"],
+ "prBodyNotes": ["Update `GOLANG_VERSION` in the same pull request to match `GO_VERSION` in [FrankenPHP's docker-bake.hcl](https://github.com/php/frankenphp/blob/v{{{newVersion}}}/docker-bake.hcl), and update `WATCHER_VERSION` to the [watcher release](https://github.com/e-dant/watcher/releases) that was current when this FrankenPHP version shipped."]
+ },
+ {
+ "description": "The docs site only tracks the project switcher bar",
+ "matchManagers": ["npm"],
+ "matchPackageNames": ["!@serversideup/project-switcher-bar"],
+ "enabled": false
+ },
+ {
+ "matchManagers": ["github-actions"],
+ "groupName": "github-actions",
+ "schedule": ["* 0-3 1 * *"]
+ },
+ {
+ "description": "Runner images (ubuntu-24.04) are upgraded by hand",
+ "matchDatasources": ["github-runners"],
+ "enabled": false
+ }
+ ]
+}
diff --git a/scripts/check-command-docs.sh b/scripts/check-command-docs.sh
new file mode 100755
index 000000000..33e02f31d
--- /dev/null
+++ b/scripts/check-command-docs.sh
@@ -0,0 +1,92 @@
+#!/bin/bash
+###################################################
+# Usage: check-command-docs.sh
+###################################################
+# Fails when docs/content/data/commands.yml and the commands the images ship
+# disagree: a command in /usr/local/bin that isn't documented, a documented
+# command that no longer exists, or a variation list that doesn't match.
+#
+# A variation ships the scripts in each src//usr/local/bin it copies with
+# `COPY ... src// /`, plus the downloaded commands listed below.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+DOCS_FILE="docs/content/data/commands.yml"
+
+# Build steps only, never run by users.
+INTERNAL_COMMANDS="docker-php-serversideup-s6-install"
+
+# Commands the Dockerfiles download, as "command:text that installs it in a Dockerfile".
+DOWNLOADED_COMMANDS="install-php-extensions:docker-php-serversideup-install-php-ext-installer php-fpm-healthcheck:/usr/local/bin/php-fpm-healthcheck"
+
+work_dir=$(mktemp -d)
+trap 'rm -rf "$work_dir"' EXIT
+
+if ! yq -r '.commands[].name' "$DOCS_FILE" | LC_ALL=C sort -c 2>/dev/null; then
+ echo "❌ Keep the commands in $DOCS_FILE sorted A-Z by name."
+ exit 1
+fi
+
+variations=$(find src/variations -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | tr '\n' ' ')
+
+# command, variation: one line for each variation that ships the command
+for variation in $variations; do
+ dockerfile="src/variations/$variation/Dockerfile"
+ for dir in $(grep -oE '^COPY .* src/[a-z0-9-]+/ /' "$dockerfile" | grep -oE 'src/[a-z0-9-]+' | sort -u); do
+ [ -d "$dir/usr/local/bin" ] || continue
+ for file in "$dir"/usr/local/bin/*; do
+ printf '%s\t%s\n' "$(basename "$file")" "$variation"
+ done
+ done
+ for entry in $DOWNLOADED_COMMANDS; do
+ if grep -qF "${entry#*:}" "$dockerfile"; then
+ printf '%s\t%s\n' "${entry%%:*}" "$variation"
+ fi
+ done
+done | sort -u > "$work_dir/shipped.tsv"
+
+yq -r '.commands[] | [.name, ((.variations // ["*"]) | join(" "))] | @tsv' "$DOCS_FILE" > "$work_dir/docs.tsv"
+
+awk -F'\t' -v variations="$variations" -v internal="$INTERNAL_COMMANDS" -v docs_file="$DOCS_FILE" '
+ BEGIN {
+ variation_count = split(variations, all_variations, " ")
+ split(internal, internal_list, " ")
+ for (i in internal_list) is_internal[internal_list[i]] = 1
+ }
+ FNR == NR {
+ documented[$1] = 1
+ if ($2 == "*") {
+ for (i = 1; i <= variation_count; i++) in_docs[$1, all_variations[i]] = 1
+ } else {
+ count = split($2, list, " ")
+ for (i = 1; i <= count; i++) in_docs[$1, list[i]] = 1
+ }
+ next
+ }
+ function fail(message) { print "❌ " message; failures++ }
+ {
+ if (is_internal[$1]) next
+ shipped[$1] = 1
+ ships[$1, $2] = 1
+ if (!($1 in documented) && !reported[$1]++) fail($1 " ships in /usr/local/bin but is missing from " docs_file)
+ }
+ END {
+ total = 0
+ for (name in documented) {
+ total++
+ if (!shipped[name]) { fail(name " is documented but no variation ships it"); continue }
+ for (i = 1; i <= variation_count; i++) {
+ v = all_variations[i]
+ if (ships[name, v] && !in_docs[name, v]) fail(name " ships in " v " but is not listed for it in the docs")
+ if (!ships[name, v] && in_docs[name, v]) fail(name " is listed for " v " in the docs but " v " does not ship it")
+ }
+ }
+ if (failures) {
+ print ""
+ print "Fix " docs_file " or the code so they match."
+ exit 1
+ }
+ print "✅ All " total " documented commands match src/."
+ }
+' "$work_dir/docs.tsv" "$work_dir/shipped.tsv"
diff --git a/scripts/check-env-var-docs.sh b/scripts/check-env-var-docs.sh
new file mode 100755
index 000000000..98a2372ea
--- /dev/null
+++ b/scripts/check-env-var-docs.sh
@@ -0,0 +1,215 @@
+#!/bin/bash
+###################################################
+# Usage: check-env-var-docs.sh
+###################################################
+# Fails when docs/content/data/env-vars.yml and the variables in src/ disagree:
+# a variable the images read that isn't documented, a documented variable that
+# no longer exists, or a default or variation list that doesn't match.
+# Also fails when a docs page table with "Variable" and "Default" columns shows
+# a default that env-vars.yml doesn't have.
+#
+# Variables come from three places in src/:
+# - ENV lines in each variation's final image stage (with per-variation defaults)
+# - `: "${NAME:=default}"` in scripts
+# - `{$NAME:default}` placeholders in the FrankenPHP Caddyfiles
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+DOCS_FILE="docs/content/data/env-vars.yml"
+DOCS_PAGES_DIR="docs/content/docs"
+
+# Read by build steps or internal plumbing only, never set by users.
+INTERNAL_VARIABLES="AUTORUN_LIB_DIR PHP_EXT_INSTALLER_VERSION PHPREDIS_LIBLZF_REF PHPREDIS_VERSION S6_RUNTIME_BUNDLEDIR"
+
+work_dir=$(mktemp -d)
+trap 'rm -rf "$work_dir"' EXIT
+
+variations=$(find src/variations -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | tr '\n' ' ')
+
+if ! yq -r '.variables[].name' "$DOCS_FILE" | LC_ALL=C sort -c 2>/dev/null; then
+ echo "❌ Keep the variables in $DOCS_FILE sorted A-Z by name. This lists them in order:"
+ echo " yq -r '.variables[].name' $DOCS_FILE | LC_ALL=C sort"
+ exit 1
+fi
+
+# name, default ("~" when unset), variations ("*" for all), variation defaults ("variation=value;...")
+yq -r '.variables[] | [
+ .name,
+ (.default // "~"),
+ ((.variations // ["*"]) | join(" ")),
+ ((.variation_defaults // {}) | to_entries | map(.key + "=" + .value) | join(";"))
+ ] | @tsv' "$DOCS_FILE" > "$work_dir/docs.tsv"
+
+# source, variation, name, value ("" when the source gives no default)
+found="$work_dir/found.tsv"
+: > "$found"
+
+for variation in $variations; do
+ # Walk from the last stage up through the stages it builds FROM, so only ENV
+ # that reaches the published image counts. A later stage overrides an earlier one.
+ awk -v variation="$variation" '
+ /^FROM / {
+ stage++
+ parent[stage] = $2
+ for (i = 3; i < NF; i++) if (toupper($i) == "AS") stage_index[$(i + 1)] = stage
+ next
+ }
+ /^[[:space:]]*ENV[[:space:]]/ { in_env = 1; sub(/^[[:space:]]*ENV[[:space:]]+/, "") }
+ in_env {
+ line = $0
+ continued = (line ~ /\\[[:space:]]*$/)
+ sub(/\\[[:space:]]*$/, "", line)
+ while (match(line, /[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|[^[:space:]]*)/)) {
+ pair = substr(line, RSTART, RLENGTH)
+ line = substr(line, RSTART + RLENGTH)
+ name = pair; sub(/=.*/, "", name)
+ value = pair; sub(/^[^=]*=/, "", value); gsub(/^"|"$/, "", value)
+ if (!((stage, name) in env)) names[stage] = names[stage] " " name
+ env[stage, name] = value
+ }
+ if (!continued) in_env = 0
+ }
+ END {
+ s = stage
+ while (s > 0) {
+ count = split(names[s], list, " ")
+ for (i = 1; i <= count; i++) {
+ if (!(list[i] in seen)) {
+ seen[list[i]] = 1
+ print "env\t" variation "\t" list[i] "\t" env[s, list[i]]
+ }
+ }
+ s = (parent[s] in stage_index) ? stage_index[parent[s]] : 0
+ }
+ }
+ ' "src/variations/$variation/Dockerfile" >> "$found"
+done
+
+grep -rhoE ': "\$\{[A-Z_][A-Z0-9_]*:=[^}]*\}"' src \
+ | sed -E 's/^: "\$\{([A-Z0-9_]+):=(.*)\}"$/script\t*\t\1\t\2/' >> "$found" || true
+
+grep -rhv '^[[:space:]]*#' src/variations/frankenphp/etc \
+ | grep -oE '\{(\$|env\.)[A-Z_][A-Z0-9_]*(:[^}]*)?\}' \
+ | sed -E 's/^\{(\$|env\.)([A-Z0-9_]+):(.*)\}$/caddy\tfrankenphp\t\2\t\3/; s/^\{(\$|env\.)([A-Z0-9_]+)\}$/caddy\tfrankenphp\t\2\t/' >> "$found" || true
+
+# Every name found anywhere in src/, for documented variables read without a default
+grep -rhoE '\$\{?[A-Z_][A-Z0-9_]*' src | tr -d '${' | sort -u > "$work_dir/referenced.txt" || true
+
+awk -F'\t' -v variations="$variations" -v internal="$INTERNAL_VARIABLES" \
+ -v referenced="$work_dir/referenced.txt" -v docs_file="$DOCS_FILE" '
+ BEGIN {
+ variation_count = split(variations, all_variations, " ")
+ split(internal, internal_list, " ")
+ for (i in internal_list) is_internal[internal_list[i]] = 1
+ while ((getline name < referenced) > 0) is_referenced[name] = 1
+ }
+ FNR == NR {
+ documented[$1] = 1
+ base_default[$1] = $2
+ if ($3 == "*") {
+ for (i = 1; i <= variation_count; i++) in_docs[$1, all_variations[i]] = 1
+ } else {
+ count = split($3, list, " ")
+ for (i = 1; i <= count; i++) in_docs[$1, list[i]] = 1
+ }
+ count = split($4, pairs, ";")
+ for (i = 1; i <= count; i++) {
+ split(pairs[i], pair, "=")
+ variation_default[$1, pair[1]] = substr(pairs[i], length(pair[1]) + 2)
+ has_variation_default[$1] = 1
+ }
+ next
+ }
+ function expected(name, variation) {
+ return ((name, variation) in variation_default) ? variation_default[name, variation] : base_default[name]
+ }
+ function fail(message) { print "❌ " message; failures++ }
+ {
+ source = $1; variation = $2; name = $3; value = $4
+ in_src[name] = 1
+ if (is_internal[name]) next
+ if (!(name in documented)) {
+ if (!reported_missing[name]++) fail(name " is read in src/ but missing from " docs_file)
+ next
+ }
+ if (source == "env") {
+ in_env[name, variation] = 1
+ has_env[name] = 1
+ }
+ if (base_default[name] == "~" || value == "") next
+ if (source == "env" || source == "caddy") {
+ if (value != expected(name, variation)) fail(name " defaults to \"" value "\" in " variation ", but the docs say \"" expected(name, variation) "\"")
+ } else if (source == "script" && !has_variation_default[name]) {
+ if (value != base_default[name] && !reported_script[name]++) fail(name " defaults to \"" value "\" in a script, but the docs say \"" base_default[name] "\"")
+ }
+ }
+ END {
+ for (name in documented) {
+ if (!in_src[name] && !is_referenced[name]) fail(name " is documented but not read anywhere in src/")
+ if (!has_env[name]) continue
+ for (i = 1; i <= variation_count; i++) {
+ v = all_variations[i]
+ if (in_env[name, v] && !in_docs[name, v]) fail(name " is set in " v " but not listed for it in the docs")
+ if (!in_env[name, v] && in_docs[name, v]) fail(name " is listed for " v " in the docs but not set in its Dockerfile")
+ }
+ }
+ total = 0
+ for (name in documented) total++
+ if (failures) {
+ print ""
+ print "Fix " docs_file " or the code so they match."
+ exit 1
+ }
+ print "✅ All " total " documented variables match src/."
+ }
+' "$work_dir/docs.tsv" "$found" || status=1
+
+# A page may repeat a default in a table for context, as long as it matches env-vars.yml.
+# Write the cell as `value`, `""` for empty, or Unset. Any documented default
+# (the base one or a variation's) is accepted.
+find "$DOCS_PAGES_DIR" -name '*.md' -print0 | sort -z | xargs -0 awk -F'|' -v docs_file="$DOCS_FILE" -v docs_tsv="$work_dir/docs.tsv" '
+ function trim(text) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", text); return text }
+ function fail(message) { print "❌ " FILENAME ":" FNR ": " message; failures++ }
+ BEGIN {
+ while ((getline line < docs_tsv) > 0) {
+ split(line, field, "\t")
+ documented[field[1]] = 1
+ accepted[field[1], field[2]] = 1
+ expected[field[1]] = (field[2] == "~") ? "Unset" : "`" field[2] "`"
+ if (field[4] != "") expected[field[1]] = expected[field[1]] " (or a variation default: " field[4] ")"
+ count = split(field[4], pairs, ";")
+ for (i = 1; i <= count; i++) accepted[field[1], substr(pairs[i], index(pairs[i], "=") + 1)] = 1
+ }
+ }
+ FNR == 1 { in_table = 0 }
+ !/^\|/ { in_table = 0; next }
+ !in_table {
+ in_table = 1; variable_column = 0; default_column = 0
+ for (i = 2; i < NF; i++) {
+ if (trim($i) == "Variable") variable_column = i
+ if (trim($i) == "Default") default_column = i
+ }
+ next
+ }
+ /^\|[[:space:]:|-]+$/ || !variable_column || !default_column { next }
+ {
+ name = trim($variable_column)
+ if (name !~ /^`[A-Z_][A-Z0-9_]*`$/) next
+ gsub(/`/, "", name)
+ cell = trim($default_column)
+ if (!(name in documented)) { fail(name " is not in " docs_file); next }
+ if (tolower(cell) ~ /^unset/) value = "~"
+ else if (match(cell, /^`[^`]*`/)) {
+ value = substr(cell, 2, RLENGTH - 2)
+ if (value == "\"\"") value = ""
+ } else { fail(name " default \"" cell "\" should be `value`, `\"\"`, or Unset"); next }
+ if (!((name, value) in accepted)) fail(name " shows `" value "`, but " docs_file " has " expected[name])
+ }
+ END {
+ if (failures) { print ""; print "Fix the table or " docs_file " so they match."; exit 1 }
+ print "✅ Every default in a docs page table matches " docs_file "."
+ }
+' || status=1
+
+exit "${status:-0}"
diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml
index f160a263a..f01ef9a91 100644
--- a/scripts/conf/php-versions-base-config.yml
+++ b/scripts/conf/php-versions-base-config.yml
@@ -1,13 +1,49 @@
+# Every version that ships in the image, other than PHP (read from php.net at build time)
+# and NGINX (operating_systems below). Each key is passed to the Dockerfiles as a build arg
+# of the same name. Renovate reads the comments and opens
+# a pull request when a newer version is out (see renovate.json).
+dependencies:
+ # The docker-library/php commit the PHP base images are compiled from. Renovate moves it
+ # to the latest commit on master once a week and merges it when every check passes. A minor version can pin its own with docker_library_ref.
+ DOCKER_LIBRARY_PHP_REF: 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb
+ # renovate: datasource=github-releases depName=phpredis/phpredis
+ PHPREDIS_VERSION: 6.3.0
+ # The liblzf commit that phpredis bundles as a submodule. GitHub tarballs leave submodules
+ # out, so it is downloaded separately. Check it when bumping phpredis:
+ # gh api repos/phpredis/phpredis/contents/liblzf?ref= --jq .sha
+ PHPREDIS_LIBLZF_REF: fb25820c3c0aeafd127956ae6c115063b47e459a
+ # renovate: datasource=github-releases depName=mlocati/docker-php-extension-installer
+ PHP_EXT_INSTALLER_VERSION: 2.12.6
+ # renovate: datasource=github-releases depName=just-containers/s6-overlay versioning=loose
+ S6_VERSION: v3.2.3.2
+ # renovate: datasource=github-releases depName=renatomefi/php-fpm-healthcheck
+ PHP_FPM_HEALTHCHECK_VERSION: v0.6.0
+ # renovate: datasource=github-releases depName=php/frankenphp
+ FRANKENPHP_VERSION: 1.13.4
+ # Go and watcher follow FrankenPHP rather than their own releases, so bump them in the
+ # same pull request as FRANKENPHP_VERSION. Go matches GO_VERSION in FrankenPHP's
+ # docker-bake.hcl at that tag. FrankenPHP downloads the latest watcher release when it
+ # builds, so use the watcher release that was current when that FrankenPHP version shipped.
+ GOLANG_VERSION: "1.27"
+ WATCHER_VERSION: 0.14.5
+
+# php_base is the docker-library/php variant each variation is built on. A minor version
+# can swap it with php_base_overrides (8.6 has no separate zts variant, for example).
php_variations:
- name: cli
+ php_base: cli
default: true
- name: fpm
+ php_base: fpm
- name: fpm-apache
+ php_base: fpm
supported_os: # Open a discussion on serversideup/php if you want to see Alpine support for fpm-apache (https://github.com/serversideup/docker-php/discussions/66)
- bookworm
- trixie
- name: fpm-nginx
+ php_base: fpm
- name: frankenphp
+ php_base: zts
excluded_minor_versions:
- "8.1"
- "8.2"
@@ -16,10 +52,12 @@ php_versions:
- major: "8"
minor_versions:
# PHP 8.1 is end of life, so php.net no longer lists it in the active releases feed
- # and the patch version must be pinned here. The official php:8.1.34 images still
- # exist on Docker Hub, and Debian Bookworm, Trixie, and Alpine 3.22 still receive
- # security updates, so weekly rebuilds keep delivering OS patches.
+ # and the patch version must be pinned here. docker-library/php removed 8.1 from master,
+ # so it builds from the last commit that had it ("Update 8.1 to 8.1.34"). Debian Bookworm,
+ # Trixie, and Alpine 3.22 still receive security updates, so weekly rebuilds keep
+ # delivering OS patches.
- minor: "8.1"
+ docker_library_ref: 5c037abaf9dc0095e29807c048d9d1b97e6a9716
base_os:
- name: alpine3.22
- name: bookworm
@@ -58,21 +96,24 @@ php_versions:
- name: trixie
patch_versions:
# - 8.5.8 # Pull latest from Official PHP source
- # PHP 8.6 is blocked upstream: the official php:8.6-rc images no longer ship pecl/pear,
- # and install-php-extensions does not support 8.6 yet (its pickle fallback is not 8.6
- # compatible), so no PECL extension can be installed. Uncomment once that is resolved.
- # https://github.com/serversideup/docker-php/issues/698
- #
- # - minor: "8.6-rc"
- # base_os:
- # - name: alpine3.23
- # - name: alpine3.24
- # - name: bookworm
- # - name: trixie
- # patch_versions:
- # - 8.6-rc
- # php_extension_overrides:
- # - redis=phpredis/phpredis@develop
+ # "8.6-rc" builds the newest release candidate php.net lists. docker-library builds
+ # every 8.6 image with ZTS and has no separate zts variant, so FrankenPHP uses cli.
+ - minor: "8.6-rc"
+ base_os:
+ - name: alpine3.23
+ - name: alpine3.24
+ - name: bookworm
+ - name: trixie
+ patch_versions:
+ - 8.6-rc
+ php_base_overrides:
+ frankenphp: cli
+ # phpredis 6.3.0 does not compile on PHP 8.6, so 8.6 builds the last develop commit
+ # before #2959. That change added an enable-redis-phpser build option, and
+ # install-php-extensions answers phpredis's build prompts in order, so newer commits
+ # turn on phpser and fail. Remove this once a phpredis release supports PHP 8.6.
+ php_extension_overrides:
+ - redis=phpredis/phpredis@810984b5a5aedc37e3ff3c19e33af8dc08c8290f
operating_systems:
- family: alpine
diff --git a/scripts/dev.sh b/scripts/dev.sh
index 9a2a3d400..f79936444 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -4,7 +4,7 @@
set -oe pipefail
##########################################################################
-# Usage: build.sh --variation --version --os
+# Usage: dev.sh --variation --version --os
##########################################################################
# This script is used to build a Docker image for a specific PHP version
# and variation. It is intended to be used for local development. You may
@@ -28,6 +28,7 @@ DOCKER_REPOSITORY="${DOCKER_REPOSITORY:-"serversideup/php"}"
DOCKER_ADDITIONAL_BUILD_ARGS=()
PUSH_TO_REGISTRY=false
PLATFORM=""
+BUILD_PHP_BASE=false
# UI Colors
function ui_set_yellow {
@@ -95,21 +96,57 @@ php_minor_version() {
fi
}
+# Compiles PHP the way CI does (see scripts/prepare-php-base.sh) and builds on that
+# instead of the official image from Docker Hub.
+build_php_base_image() {
+ if ! [[ "$PHP_BUILD_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+|-rc)$ ]]; then
+ echo_color_message red "❌ --build-php-base needs a full patch version (e.g., 8.4.27) or a release candidate (e.g., 8.6-rc), not '$PHP_BUILD_VERSION'"
+ exit 1
+ fi
+
+ local php_base docker_library_ref base_context
+ php_base=$(VARIATION="$PHP_BUILD_VARIATION" MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r '
+ [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .php_base_overrides[env(VARIATION)] // ""]
+ + [.php_variations[] | select(.name == env(VARIATION)) | .php_base]
+ | map(select(. != "")) | .[0]
+ ' "$BASE_PHP_VERSIONS_CONFIG_FILE")
+ docker_library_ref=$(MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r '
+ [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .docker_library_ref // ""] + [.dependencies.DOCKER_LIBRARY_PHP_REF]
+ | map(select(. != "")) | .[0]
+ ' "$BASE_PHP_VERSIONS_CONFIG_FILE")
+ base_context="$PROJECT_ROOT_DIR/.php-base/${PHP_BUILD_VERSION}-${php_base}-${PHP_BUILD_BASE_OS}"
+ BASE_IMAGE="php-base:${PHP_BUILD_VERSION}-${php_base}-${PHP_BUILD_BASE_OS}"
+
+ bash "$SCRIPT_DIR/prepare-php-base.sh" "$docker_library_ref" "$PHP_BUILD_VERSION" "$PHP_BUILD_BASE_OS" "$php_base" "$base_context"
+
+ echo_color_message yellow "🐳 Compiling PHP base image: $BASE_IMAGE"
+ docker buildx build \
+ --load \
+ --platform "$PLATFORM" \
+ --tag "$BASE_IMAGE" \
+ "$base_context"
+ echo_color_message green "✅ PHP base image built: $BASE_IMAGE"
+}
+
build_docker_image() {
build_tag="${DOCKER_REPOSITORY}:${PHP_BUILD_PREFIX}${PHP_BUILD_VERSION}-${PHP_BUILD_VARIATION}-${PHP_BUILD_BASE_OS}"
echo_color_message yellow "🐳 Building Docker Image: $build_tag"
- # Set default platform if not specified
- if [ -z "$PLATFORM" ]; then
- PLATFORM=$(detect_platform)
- fi
-
# Assemble build arguments
local build_args=()
build_args+=(--build-arg PHP_VARIATION="$PHP_BUILD_VARIATION")
build_args+=(--build-arg PHP_VERSION="$PHP_BUILD_VERSION")
build_args+=(--build-arg BASE_OS_VERSION="$PHP_BUILD_BASE_OS")
+ # Every pinned dependency version, the same way CI passes them
+ while IFS= read -r dependency; do
+ build_args+=(--build-arg "$dependency")
+ done < <(yq -r '.dependencies | to_entries[] | "\(.key)=\(.value)"' "$BASE_PHP_VERSIONS_CONFIG_FILE")
+
+ if [ -n "$BASE_IMAGE" ]; then
+ build_args+=(--build-arg "BASE_IMAGE=$BASE_IMAGE")
+ fi
+
if [ -n "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then
build_args+=(--build-arg "NGINX_VERSION=$NGINX_VERSION")
fi
@@ -149,6 +186,8 @@ help_menu() {
echo " --prefix Set the prefix for the Docker image (e.g., beta)"
echo " --registry Set a custom registry (e.g., localhost:5000)"
echo " --platform Set the platform (default: detected from system architecture)"
+ echo " --build-php-base Compile PHP from source like CI does (needs a full patch version, e.g., 8.4.27, or 8.6-rc,"
+ echo " and the default docker builder, which can use local images)"
echo " --push Push the image to the registry"
echo " --* Any additional options will be passed to the docker buildx command"
}
@@ -183,6 +222,10 @@ while [[ $# -gt 0 ]]; do
PLATFORM="$2"
shift 2
;;
+ --build-php-base)
+ BUILD_PHP_BASE=true
+ shift
+ ;;
--*)
# If there's a next argument and it starts with '--', treat the current argument as standalone.
# Otherwise, pair the current argument with the next.
@@ -239,4 +282,12 @@ if [ -z "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then
echo_color_message green "✅ Using NGINX version '$NGINX_VERSION' for OS '$PHP_BUILD_BASE_OS'"
fi
-build_docker_image
\ No newline at end of file
+if [ -z "$PLATFORM" ]; then
+ PLATFORM=$(detect_platform)
+fi
+
+if [ "$BUILD_PHP_BASE" = true ]; then
+ build_php_base_image
+fi
+
+build_docker_image
diff --git a/scripts/generate-matrix.sh b/scripts/generate-matrix.sh
index 3b058e7bd..817f684c3 100755
--- a/scripts/generate-matrix.sh
+++ b/scripts/generate-matrix.sh
@@ -36,14 +36,23 @@ yq -o=json "$PHP_VERSIONS_FILE" | jq -c '
((($variation.supported_os // []) | any(os_family_match($os.name; .))));
. as $root
- | [ ($root.php_variations[] | {name, supported_os, excluded_minor_versions}) as $variation
+ | [ ($root.php_variations[] | {name, php_base, supported_os, excluded_minor_versions}) as $variation
| $root.php_versions[]
| .minor_versions[] as $minor
| select((($variation.excluded_minor_versions // []) | index($minor.minor)) | not)
| ($minor.base_os // [])[] as $os
| ($minor.patch_versions // [])[] as $patch
| select(is_supported($variation; $os))
- | {patch_version: $patch, base_os: $os.name, php_variation: $variation.name, php_extension_overrides: (($minor.php_extension_overrides // []) | join(" "))}
+ | ((($minor.php_base_overrides // {})[$variation.name]) // $variation.php_base) as $php_base
+ | {
+ patch_version: $patch,
+ base_os: $os.name,
+ php_variation: $variation.name,
+ php_extension_overrides: (($minor.php_extension_overrides // []) | join(" ")),
+ php_base: $php_base,
+ php_base_tag: "php-base-\($patch)-\($php_base)-\($os.name)",
+ docker_library_ref: ($minor.docker_library_ref // $root.dependencies.DOCKER_LIBRARY_PHP_REF)
+ }
]
| { include: ( . | sort_by(.patch_version | version_weight) | reverse ) }
'
\ No newline at end of file
diff --git a/scripts/get-nginx-versions.sh b/scripts/get-nginx-versions.sh
deleted file mode 100755
index 93066af19..000000000
--- a/scripts/get-nginx-versions.sh
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/bin/bash
-###################################################
-# Usage: get-nginx-versions.sh [--os ] [--write]
-###################################################
-# This script fetches the latest NGINX versions available for different
-# operating systems from the official NGINX repositories. By default, it
-# shows all operating systems from the base config file, but you can filter
-# to a specific OS if needed.
-
-set -oe pipefail
-
-##########################
-# Configuration
-os_config() {
- # Resolve config path relative to this script so --help works before SCRIPT_DIR is set
- local this_script_dir
- this_script_dir="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
- local config_file="$this_script_dir/conf/php-versions-base-config.yml"
-
- if ! command -v yq >/dev/null 2>&1; then
- echo "yq is required but not found. Install 'yq' (https://github.com/mikefarah/yq) to continue." 1>&2
- return 1
- fi
-
- # shellcheck disable=SC2016
- yq -r '.operating_systems[] | .family as $f | .versions[] | "\(.version)|\($f)|\(.name)"' "$config_file" \
- | while IFS='|' read -r version family name; do
- if [[ "$family" == "alpine" ]]; then
- # version comes as alpineX.Y (e.g., alpine3.24)
- key="$version"
- alpine_num_version="${version#alpine}"
- url="http://nginx.org/packages/alpine/v${alpine_num_version}/main/x86_64/"
- pattern='nginx-[0-9][^"\n]*\.apk'
- else
- key="$version"
- url="http://nginx.org/packages/debian/dists/${version}/nginx/binary-amd64/Packages"
- pattern='^Package: nginx$'
- fi
- printf '%s|%s|%s|%s\n' "$key" "$name" "$url" "$pattern"
- done
-}
-
-##########################
-# Functions
-
-help_menu() {
- echo "Usage: $0 [--os ] [--write]"
- echo
- echo "This script fetches the latest NGINX versions available for different"
- echo "operating systems from the PHP base config file."
- echo
- echo "Options:"
- echo " --os Filter to a specific operating system"
- echo " --write Write discovered versions back to the base config (yq)"
- echo " --help, -h Show this help message"
- echo
- echo "Available Operating Systems:"
- os_config | awk -F'|' '{printf " %-12s %s\n", $1, $2}'
- echo
- echo "Examples:"
- echo " $0 # Show all operating systems"
- echo " $0 --os alpine3.24 # Show only Alpine 3.24"
- echo " $0 --os bookworm # Show only Debian Bookworm"
-}
-
-##########################
-# Argument Parsing
-
-FILTER_OS=""
-WRITE_MODE=false
-while [[ "$#" -gt 0 ]]; do
- case $1 in
- --os)
- FILTER_OS="$2"
- shift 2
- ;;
- --write)
- WRITE_MODE=true
- shift 1
- ;;
- --help|-h)
- help_menu
- exit 0
- ;;
- *)
- echo "Unknown parameter passed: $1"
- help_menu
- exit 1
- ;;
- esac
-done
-
-##########################
-# Environment Settings
-
-# Script Configurations
-SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
-CONFIG_FILE="$SCRIPT_DIR/conf/php-versions-base-config.yml"
-
-# UI Colors
-function ui_set_yellow {
- printf $'\033[0;33m'
-}
-
-function ui_set_green {
- printf $'\033[0;32m'
-}
-
-function ui_set_red {
- printf $'\033[0;31m'
-}
-
-function ui_set_blue {
- printf $'\033[0;34m'
-}
-
-function ui_set_bold {
- printf $'\033[1m'
-}
-
-function ui_reset_colors {
- printf "\e[0m"
-}
-
-function echo_color_message (){
- color=$1
- message=$2
-
- ui_set_$color
- echo "$message"
- ui_reset_colors
-}
-
-##########################
-# Fetch helpers
-
-get_alpine_version() {
- local url="$1"
- local pattern="$2"
-
- local version
- version=$(curl -s "$url" | grep -o "$pattern" | sort -V | tail -1)
- if [[ -n "$version" ]]; then
- # Extract version number from package name (e.g., nginx-1.24.0-r7.apk -> 1.24.0-r7)
- echo "$version" | sed 's/nginx-\(.*\)\.apk/\1/'
- else
- echo "Unable to fetch"
- fi
-}
-
-get_debian_version() {
- local url="$1"
-
- local version
- version=$(curl -s "$url" \
- | awk 'BEGIN{RS=""; FS="\n"} { pkg=0; ver=""; for (i=1;i<=NF;i++){ if ($i ~ /^Package: nginx$/) pkg=1; if ($i ~ /^Version:/){ split($i,a,": *"); ver=a[2]; } } if (pkg && ver!="") print ver; }' \
- | sort -V | tail -1)
- if [[ -n "$version" ]]; then
- echo "$version"
- else
- echo "Unable to fetch"
- fi
-}
-
-compute_nginx_version() {
- local url="$1"
- local pattern="$2"
-
- local version=""
- if [[ "$url" == *"alpine"* ]]; then
- version=$(get_alpine_version "$url" "$pattern")
- else
- version=$(get_debian_version "$url")
- fi
-
- echo "$version"
-}
-
-update_config_nginx_version() {
- local version_key="$1" # e.g., alpine3.24 or bookworm
- local new_nginx_version="$2"
-
- if [[ -z "$new_nginx_version" || "$new_nginx_version" == "Unable to fetch" ]]; then
- echo_color_message red "⚠️ Skipping update for $version_key (no version found)"
- return 0
- fi
-
- # Preserve comments/spacing: perform a targeted line replacement using awk
- # Strategy: when we encounter the specific 'version: ' line, the next
- # 'nginx_version:' line in that list item will be replaced with the new value,
- # while keeping indentation and any spacing after the colon intact.
- local tmp_file
- tmp_file="$(mktemp)"
-
- VERSION_KEY="$version_key" NEW_VER="$new_nginx_version" awk '
- BEGIN { target = ENVIRON["VERSION_KEY"]; replacement = ENVIRON["NEW_VER"]; in_block = 0 }
- {
- # Detect a version line and check if it matches our target value
- if ($0 ~ /^[ \t]*version:[ \t]*/) {
- line = $0
- gsub(/^[ \t]*version:[ \t]*/, "", line)
- # Extract the first token before a space or comment
- split(line, parts, /[ \t#]/)
- val = parts[1]
- in_block = (val == target) ? 1 : 0
- }
-
- if (in_block && $0 ~ /^[ \t]*nginx_version:[ \t]*/) {
- # Capture exact prefix including key and any spaces after the colon
- mpos = match($0, /^[ \t]*nginx_version:[ \t]*/)
- if (mpos > 0) {
- prefix = substr($0, RSTART, RLENGTH)
- print prefix replacement
- in_block = 0
- next
- }
- }
-
- print $0
- }
- ' "$CONFIG_FILE" > "$tmp_file" && mv "$tmp_file" "$CONFIG_FILE"
-
- echo_color_message green "✍️ Updated nginx_version for $version_key -> $new_nginx_version"
-}
-
-##########################
-# Main script starts here
-
-echo_color_message yellow "🌐 NGINX Version Checker"
-echo
-
-# If a specific OS is requested, validate it exists
-if [[ -n "$FILTER_OS" ]]; then
- if ! grep -q "^$FILTER_OS|" < <(os_config); then
- echo_color_message red "❌ Unknown operating system: $FILTER_OS"
- echo
- help_menu
- exit 1
- fi
-fi
-
-# Process operating systems
-os_config | while IFS='|' read -r os_key os_name url pattern; do
- # Skip if filtering and this isn't the requested OS
- if [[ -n "$FILTER_OS" && "$os_key" != "$FILTER_OS" ]]; then
- continue
- fi
-
- echo_color_message blue "🔍 Fetching NGINX version for $os_name from $url..."
- version=$(compute_nginx_version "$url" "$pattern")
-
- ui_set_bold
- ui_set_green
- printf "%-20s" "$os_name:"
- ui_reset_colors
- echo " $version"
-
- if [[ "$WRITE_MODE" == true ]]; then
- update_config_nginx_version "$os_key" "$version"
- fi
-done
-
-echo
-if [[ "$WRITE_MODE" == true ]]; then
- echo_color_message green "✅ NGINX version check and write complete!"
-else
- echo_color_message green "✅ NGINX version check complete!"
-fi
\ No newline at end of file
diff --git a/scripts/get-php-release-candidate.sh b/scripts/get-php-release-candidate.sh
new file mode 100755
index 000000000..e287ed188
--- /dev/null
+++ b/scripts/get-php-release-candidate.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Usage: get-php-release-candidate.sh
+# Example: get-php-release-candidate.sh 8.6
+#
+# Prints the newest release candidate php.net lists for as one line of JSON
+# ({"version": ..., "url": ..., "sha256": ...}), or nothing when there is none. php.net
+# lists none between a final release and the next release candidate, such as right after
+# 8.6.0 ships. PHP_PRE_RELEASE_JSON_FEED reads a different feed (the tests use a file).
+
+if [ "$#" -ne 1 ]; then
+ sed -n '4,5p' "$0" | sed 's/^# //' >&2
+ exit 1
+fi
+
+scripts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+download="$scripts_dir/../src/common/usr/local/bin/docker-php-serversideup-download"
+feed="${PHP_PRE_RELEASE_JSON_FEED:-https://www.php.net/pre-release-builds.php?format=json}"
+
+"$download" "$feed" | jq -c --arg prefix "$1." '
+ [ to_entries[]
+ | select(.key | startswith($prefix))
+ | .value.release
+ | select(.version != null and .files.xz.sha256 != "")
+ | {version, url: .files.xz.path, sha256: .files.xz.sha256} ][0] // empty'
diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh
index 5808058f6..b88f444f2 100755
--- a/scripts/get-php-versions.sh
+++ b/scripts/get-php-versions.sh
@@ -1,23 +1,14 @@
#!/bin/bash
###################################################
-# Usage: get-php-versions.sh [--skip-download] [--skip-dockerhub-validation] [--no-fallback] [--input PATH] [--output PATH]
+# Usage: get-php-versions.sh [--skip-download] [--input PATH] [--output PATH]
###################################################
# This file takes the official latest PHP releases from php.net merges them with our
# "base php configuration". These files get merged into a final file called "php-versions.yml"
# which is used to build our GitHub Actions jobs.
#
-# 🔍 DOCKERHUB VALIDATION & FALLBACK
-# By default, this script validates that each PHP version from php.net is actually available
-# on DockerHub before including it in the final configuration. Every base image our variations
-# pull (cli, fpm, zts) is checked on every base OS configured for that minor version, because
-# DockerHub publishes those tags in batches and a build fails if any one is missing. A tag only
-# counts once it has both linux/amd64 and linux/arm64 images, since DockerHub creates tags empty.
-# If a version is not available:
-# 1. The script attempts to fall back to the previous patch version (e.g., 8.3.24 -> 8.3.23)
-# 2. A GitHub Actions warning is displayed explaining the fallback
-# 3. If the fallback version is also unavailable, the script exits with an error
-#
-# This ensures that Docker builds won't fail due to non-existent base images.
+# Images are compiled from the php.net tarball (see scripts/prepare-php-base.sh), so a
+# release can be built as soon as php.net lists it. A minor version with patch_versions
+# in the base config uses those instead of php.net's, which pins or holds back a release.
#
# 👉 REQUIRED FILES
# - BASE_PHP_VERSIONS_CONFIG_FILE must be valid and set to a valid file path
@@ -25,8 +16,6 @@
#
# 👉 OPTIONS
# --skip-download: Skip downloading from php.net and use existing base config
-# --skip-dockerhub-validation: Skip DockerHub validation (useful for testing/development)
-# --no-fallback: Fail instead of falling back to the previous patch version (use before a security release)
# --input: Input file path (defaults to scripts/conf/php-versions-base-config.yml)
# --output: Output file path (defaults to scripts/conf/php-versions.yml)
@@ -36,189 +25,13 @@ set -oue pipefail
# set -x
# trap read DEBUG
-##########################
-# DockerHub API Functions
-
-# Check if a PHP version exists on DockerHub
-check_dockerhub_php_version() {
- local version="$1"
- local variant="${2:-cli}"
- local os="${3:-}"
-
- # Our Dockerfiles always pull the OS-suffixed tag. The unsuffixed tag follows
- # DockerHub's default OS, which is not necessarily the one we build on.
- local image_tag
- if [ -n "$os" ]; then
- image_tag="${version}-${variant}-${os}"
- else
- image_tag="${version}-${variant}"
- fi
-
- # Use Docker Hub API v2 to check if the tag exists with timeout and retry
- local response body platform
- local max_retries=3
- local retry_count=0
-
- while [ $retry_count -lt $max_retries ]; do
- body=$(mktemp)
- response=$(curl -s --max-time 10 --connect-timeout 5 \
- -o "$body" -w "%{http_code}" \
- "https://registry.hub.docker.com/v2/repositories/library/php/tags/${image_tag}/")
-
- # Check if we got a valid HTTP response
- if [ "$response" = "200" ]; then
- # DockerHub creates the tag before any architecture is pushed to it, so a tag
- # can exist with no images yet. We build for amd64 and arm64, so both must be there.
- for platform in amd64 arm64; do
- if ! jq -e --arg arch "$platform" 'any(.images[]?; .os == "linux" and .architecture == $arch)' "$body" > /dev/null; then
- echo_color_message red "❌ php:${image_tag} exists but has no linux/${platform} image yet" >&2
- rm -f "$body"
- return 1
- fi
- done
- rm -f "$body"
- return 0 # Version exists for every platform we build
- elif [ "$response" = "404" ]; then
- rm -f "$body"
- return 1 # Version definitely does not exist
- else
- rm -f "$body"
- # Network error or other issue, retry
- retry_count=$((retry_count + 1))
- if [ $retry_count -lt $max_retries ]; then
- echo_color_message yellow "⚠️ DockerHub API request failed (HTTP $response), retrying in 2 seconds..."
- sleep 2
- fi
- fi
- done
-
- # If we get here, all retries failed
- echo_color_message red "❌ Failed to check DockerHub after $max_retries attempts for $image_tag"
- return 1
-}
-
-# Check every official base image our variations pull for this version (cli, fpm, zts)
-# on every base OS configured for its minor version. One missing tag fails the check.
-check_dockerhub_base_images() {
- local version="$1"
- local minor variant os base_os_list
- minor=$(echo "$version" | cut -d'.' -f1-2)
-
- base_os_list=$(yq -r ".php_versions[].minor_versions[] | select(.minor == \"$minor\") | .base_os[].name" "$BASE_PHP_VERSIONS_CONFIG_FILE")
- if [ -z "$base_os_list" ]; then
- echo_color_message yellow "⚠️ No base OS configured for PHP $minor. Checking the default cli image only." >&2
- check_dockerhub_php_version "$version" "cli"
- return $?
- fi
-
- for variant in cli fpm zts; do
- for os in $base_os_list; do
- if ! check_dockerhub_php_version "$version" "$variant" "$os"; then
- echo_color_message red "❌ Missing on DockerHub: php:${version}-${variant}-${os}" >&2
- return 1
- fi
- done
- done
- return 0
-}
-
-# Get previous patch version (e.g., 8.3.24 -> 8.3.23)
-get_previous_patch_version() {
- local version="$1"
- local major_minor patch
-
- # Split version into major.minor and patch
- major_minor=$(echo "$version" | cut -d'.' -f1-2)
- patch=$(echo "$version" | cut -d'.' -f3)
-
- # Decrement patch version
- if [ "$patch" -gt 0 ]; then
- patch=$((patch - 1))
- echo "${major_minor}.${patch}"
- else
- # If patch is 0, we can't go lower
- return 1
- fi
-}
-
-# Add a new function for GitHub Actions annotations (around line 193)
-function github_actions_annotation() {
- local type="$1" # warning, error, notice, debug
- local title="$2" # The title
- local message="$3" # The message
-
- # Output the official workflow command format
- echo "::${type} title=${title}::${message}"
-
- # Add to step summary for better visibility
- case "$type" in
- warning)
- echo "⚠️ **Warning: ${title}** - ${message}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
- ;;
- error)
- echo "❌ **Error: ${title}** - ${message}" >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
- ;;
- esac
-}
-
-# Validate and potentially fallback a PHP version
-validate_php_version_with_fallback() {
- local version="$1"
- local original_version="$version"
- local fallback_attempted=false
-
- echo_color_message yellow "🔍 Checking PHP version $version on DockerHub..." >&2
-
- # Check that every base image we build from exists on DockerHub
- if check_dockerhub_base_images "$version"; then
- echo_color_message green "✅ PHP $version is available on DockerHub" >&2
- echo "$version" # Output to stdout for capture
- return 0
- else
- echo_color_message red "❌ PHP $version is not available on DockerHub" >&2
-
- # Try to get previous patch version
- local fallback_version
- if [ "$NO_FALLBACK" = true ]; then
- github_actions_annotation "error" "PHP Version Unavailable" "PHP $original_version is not available on DockerHub and --no-fallback is set."
- return 1
- elif fallback_version=$(get_previous_patch_version "$version"); then
- fallback_attempted=true
- echo_color_message yellow "⚠️ Attempting fallback to PHP $fallback_version..." >&2
-
- if check_dockerhub_base_images "$fallback_version"; then
- # Output GitHub Actions annotation without color formatting
- github_actions_annotation "warning" "PHP Version Fallback" "PHP $original_version is not available on DockerHub. Falling back to PHP $fallback_version. This may indicate that DockerHub has not yet published the latest PHP release. Consider checking DockerHub availability before updating to newer versions."
- echo_color_message green "✅ Fallback successful: Using PHP $fallback_version" >&2
- echo "$fallback_version" # Output to stdout for capture
- return 0
- else
- echo_color_message red "❌ Fallback version PHP $fallback_version is also not available on DockerHub" >&2
- fi
- fi
-
- # If we get here, both original and fallback failed
- if [ "$fallback_attempted" = true ]; then
- github_actions_annotation "error" "PHP Version Unavailable" "Neither PHP $original_version nor fallback version $fallback_version are available on DockerHub. This suggests a significant lag in DockerHub publishing or a configuration issue. Please check DockerHub manually and consider using a known working version."
- else
- github_actions_annotation "error" "PHP Version Unavailable" "PHP $original_version is not available on DockerHub and no fallback version could be determined (patch version is 0). Please check DockerHub manually and use a known working version."
- fi
-
- return 1
- fi
-}
-
##########################
# Argument Parsing
SKIP_DOWNLOAD="${SKIP_DOWNLOAD:-false}"
-SKIP_DOCKERHUB_VALIDATION="${SKIP_DOCKERHUB_VALIDATION:-false}"
-NO_FALLBACK="${NO_FALLBACK:-false}"
while [[ "$#" -gt 0 ]]; do
case $1 in
--skip-download) SKIP_DOWNLOAD=true ;;
- --skip-dockerhub-validation) SKIP_DOCKERHUB_VALIDATION=true ;;
- --no-fallback) NO_FALLBACK=true ;;
--input)
if [ -z "$2" ]; then
echo "Error: --input requires a file path."
@@ -336,74 +149,8 @@ if [ "$SKIP_DOWNLOAD" = false ]; then
exit 1
fi
- # Parse the fetched JSON data and optionally validate PHP versions on DockerHub
- if [ "$SKIP_DOCKERHUB_VALIDATION" = true ]; then
- echo_color_message yellow "⚠️ Skipping DockerHub validation as requested..."
- processed_json="$php_net_version_json"
- else
- echo_color_message yellow "🔍 Parsing and validating PHP versions from php.net..."
-
- # First, extract versions from the JSON
- php_versions_raw=$(echo "$php_net_version_json" | jq -r "
- . as \$major |
- to_entries[] |
- .value |
- to_entries[] |
- .value.version" | grep -v "null" | sort -u)
-
- # Create temporary files to store validation results
- validated_versions_file=$(mktemp)
- version_map_file=$(mktemp)
-
- # Validate each version
- validation_failed=false
- while IFS= read -r version; do
- if [ -n "$version" ]; then
- echo_color_message yellow "🔍 Validating PHP $version..."
- # Capture validation result without color codes
- if validated_version=$(validate_php_version_with_fallback "$version" | tail -n1); then
- # Double check that we got a valid version back
- if [ -n "$validated_version" ] && [ "$validated_version" != "VALIDATION_FAILED" ]; then
- echo "$version:$validated_version" >> "$validated_versions_file"
- if [ "$version" != "$validated_version" ]; then
- # Escape special characters for sed
- escaped_original=$(echo "$version" | sed 's/[[\.*^$(){}?+|/]/\\&/g')
- escaped_validated=$(echo "$validated_version" | sed 's/[[\.*^$(){}?+|/]/\\&/g')
- echo "s/${escaped_original}/${escaped_validated}/g" >> "$version_map_file"
- fi
- else
- echo_color_message red "❌ Validation failed for PHP $version"
- validation_failed=true
- fi
- else
- echo_color_message red "❌ Validation failed for PHP $version"
- validation_failed=true
- fi
- fi
- done <<< "$php_versions_raw"
-
- # Exit if any validation failed
- if [ "$validation_failed" = true ]; then
- echo_color_message red "❌ One or more PHP versions failed validation. Stopping build."
- rm -f "$validated_versions_file" "$version_map_file"
- exit 1
- fi
-
- # Apply version substitutions if any fallbacks were used
- processed_json="$php_net_version_json"
- if [ -s "$version_map_file" ]; then
- echo_color_message yellow "📝 Applying version fallbacks..."
- while IFS= read -r substitution; do
- processed_json=$(echo "$processed_json" | sed "$substitution")
- done < "$version_map_file"
- fi
-
- # Clean up temporary files
- rm -f "$validated_versions_file" "$version_map_file"
- fi
-
# Parse the fetched JSON data and transform it to a specific YAML structure using jq and yq.
- php_net_yaml_data=$(echo "$processed_json" | jq -r "
+ php_net_yaml_data=$(echo "$php_net_version_json" | jq -r "
{
\"php_versions\": [
. as \$major |
@@ -434,29 +181,47 @@ if [ "$SKIP_DOWNLOAD" = false ]; then
# Use 'echo' to pass the JSON data to 'jq'
merged_json=$(jq -s '
{
+ dependencies: (.[1].dependencies // {}),
php_variations: (.[1].php_variations // []),
php_versions: (
- .[0].php_versions + .[1].php_versions
+ (.[0].php_versions + (.[1].php_versions | map(.minor_versions |= map(. + {from_base_config: true}))))
| group_by(.major)
| map({
major: .[0].major,
minor_versions: (
map(.minor_versions[] | select(. != null))
| group_by(.minor)
- | map({
- minor: .[0].minor,
- base_os: (map(.base_os // []) | add),
- patch_versions: (map(.patch_versions // []) | flatten | unique | select(. != null)),
- php_extension_overrides: (map(.php_extension_overrides // []) | add | unique)
- })
+ | map(
+ (map(select(.from_base_config) | (.patch_versions // [])) | flatten) as $pinned
+ | {
+ minor: .[0].minor,
+ base_os: (map(.base_os // []) | add),
+ patch_versions: (if ($pinned | length) > 0 then $pinned else (map(.patch_versions // []) | flatten | unique) end),
+ php_extension_overrides: (map(.php_extension_overrides // []) | add | unique),
+ docker_library_ref: (map(.docker_library_ref // empty) | first),
+ php_base_overrides: (map(.php_base_overrides // empty) | add)
+ }
+ | with_entries(select(.value != null))
+ )
)
})
),
operating_systems: (.[1].operating_systems // [])
-
}
' <(echo "$downloaded_and_normalized_json_data") <(echo "$base_json_data"))
+ # A release candidate minor such as "8.6-rc" builds only while php.net lists a release
+ # candidate for it. Publishing is all or nothing, so one missing base would otherwise
+ # hold back every other image, including PHP security releases.
+ for rc_minor in $(echo "$merged_json" | jq -r '.php_versions[].minor_versions[].minor | select(endswith("-rc"))'); do
+ release_candidate=$("$SCRIPT_DIR/get-php-release-candidate.sh" "${rc_minor%-rc}")
+ if [ -z "$release_candidate" ]; then
+ echo_color_message yellow "⚠️ php.net lists no release candidate for PHP ${rc_minor%-rc}, so $rc_minor is not built."
+ echo "::warning title=No PHP release candidate::php.net lists no release candidate for PHP ${rc_minor%-rc}, so $rc_minor is not built. Remove it from the base config once ${rc_minor%-rc} is released."
+ merged_json=$(echo "$merged_json" | jq --arg rc_minor "$rc_minor" '.php_versions[].minor_versions |= map(select(.minor != $rc_minor))')
+ fi
+ done
+
# Convert updated JSON data back to YAML
merged_and_finalized_yaml=$(echo "$merged_json" | yq eval -P -)
diff --git a/scripts/lint.sh b/scripts/lint.sh
new file mode 100644
index 000000000..7f1904fd6
--- /dev/null
+++ b/scripts/lint.sh
@@ -0,0 +1,118 @@
+#!/bin/bash
+###################################################
+# Usage: lint.sh [check...]
+###################################################
+# Runs the checks CI runs before it builds any image. With no arguments, runs every
+# check and reports each failure at the end. Each job in service_lint.yml runs one.
+#
+# Tool versions live in the env block of service_lint.yml, where Renovate updates them.
+# CI exports them; locally they're read from the workflow with yq.
+set -uo pipefail
+
+cd "$(dirname "$0")/.." || exit 1
+
+LINT_WORKFLOW=".github/workflows/service_lint.yml"
+ALL_CHECKS="actionlint zizmor shellcheck hadolint scripts"
+
+usage() {
+ echo "Usage: $0 [check...]"
+ echo "Checks: $ALL_CHECKS (default: all)"
+}
+
+tool_version() {
+ local name="$1"
+ if [ -n "${!name:-}" ]; then
+ echo "${!name}"
+ else
+ yq -r ".env.$name" "$LINT_WORKFLOW"
+ fi
+}
+
+check_actionlint() {
+ docker run --rm -v "$PWD:/repo" -w /repo "rhysd/actionlint:$(tool_version ACTIONLINT_VERSION)" -color
+}
+
+# GH_TOKEN is passed only when it's set. With it, zizmor also checks that every
+# pinned SHA belongs to its repository.
+check_zizmor() {
+ docker run --rm -e GH_TOKEN -v "$PWD:/repo:ro" -w /repo \
+ "ghcr.io/zizmorcore/zizmor:$(tool_version ZIZMOR_VERSION)" --color=always .github/workflows
+}
+
+check_shellcheck() {
+ local files=(
+ scripts/*.sh
+ scripts/tests/*.sh
+ scripts/tests/image/*.sh
+ src/common/usr/local/bin/*
+ src/s6/usr/local/bin/*
+ .oss-scanner/run-variation
+ )
+ local file
+ while IFS= read -r file; do
+ files+=("$file")
+ done < <(find src -path '*entrypoint.d*' -name '*.sh')
+
+ docker run --rm -v "$PWD:/repo" -w /repo "koalaman/shellcheck:$(tool_version SHELLCHECK_VERSION)" \
+ --color=always --severity=warning "${files[@]}"
+}
+
+check_hadolint() {
+ docker run --rm -v "$PWD:/repo" -w /repo "hadolint/hadolint:$(tool_version HADOLINT_VERSION)" \
+ hadolint src/variations/*/Dockerfile .oss-scanner/Dockerfile
+}
+
+# The whole build matrix starts at once and transient 5xx errors are common, so Dockerfiles
+# download with docker-php-serversideup-download, which retries. HEALTHCHECK runs curl in the
+# finished container, so it's allowed.
+verify_dockerfile_downloads() {
+ local calls
+ calls=$(grep -nE '(^|[^-[:alnum:]])curl[[:space:]]+(-|https?:|"|\$)' \
+ src/variations/*/Dockerfile .oss-scanner/Dockerfile | grep -v 'CMD \[')
+ [ -z "$calls" ] && return 0
+ echo "❌ Use docker-php-serversideup-download instead of curl in a Dockerfile:"
+ echo "$calls"
+ return 1
+}
+
+check_scripts() {
+ bash scripts/tests/run.sh &&
+ bash scripts/check-env-var-docs.sh &&
+ bash scripts/check-command-docs.sh &&
+ verify_dockerfile_downloads
+}
+
+if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
+ usage
+ exit 0
+fi
+
+checks="${*:-$ALL_CHECKS}"
+for check in $checks; do
+ case " $ALL_CHECKS " in
+ *" $check "*) ;;
+ *)
+ echo "❌ Unknown check: $check"
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+failed=""
+for check in $checks; do
+ echo "▶ $check"
+ if "check_$check"; then
+ echo "✅ $check passed"
+ else
+ echo "❌ $check failed"
+ failed="$failed $check"
+ fi
+ echo
+done
+
+if [ -n "$failed" ]; then
+ echo "❌ Failed:$failed"
+ exit 1
+fi
+echo "✅ All checks passed."
diff --git a/scripts/prepare-php-base.sh b/scripts/prepare-php-base.sh
new file mode 100755
index 000000000..02555c6b7
--- /dev/null
+++ b/scripts/prepare-php-base.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Usage: prepare-php-base.sh
+# Example: prepare-php-base.sh 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb 8.4.27 trixie fpm .php-base/8.4.27-fpm-trixie
+# prepare-php-base.sh 5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb 8.6-rc trixie cli .php-base/8.6-rc-cli-trixie
+#
+# A version like "8.6-rc" builds the newest release candidate php.net lists for 8.6.
+# Writes the build context for one official PHP image (docker-library/php at a pinned
+# commit) set to compile from the php.net tarball. That lets us ship a
+# release as soon as php.net publishes it, without waiting for Docker Hub. Only the version
+# lines change, "pecl update-channels" is removed so a PECL outage cannot fail the build, and
+# `php -v` names us as the builder. Everything else, including the GPG check, is exactly what
+# the official images run.
+
+if [ "$#" -ne 5 ]; then
+ sed -n '4,5p' "$0" | sed 's/^# //' >&2
+ exit 1
+fi
+
+ref="$1"
+patch="$2"
+base_os="$3"
+php_base="$4"
+output_dir="$5"
+
+scripts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+download="$scripts_dir/../src/common/usr/local/bin/docker-php-serversideup-download"
+work_dir="$(mktemp -d)"
+trap 'rm -rf "$work_dir"' EXIT
+
+build_provider="Server Side Up (https://serversideup.net/php)"
+
+fail() {
+ echo "🛑 ERROR (prepare-php-base.sh): $1" >&2
+ exit 1
+}
+
+# docker-library keeps release candidates in their own directory, such as 8.6-rc
+if [[ "$patch" == *-rc ]]; then
+ minor="$patch"
+ release=$("$scripts_dir/get-php-release-candidate.sh" "${patch%-rc}")
+ [ -n "$release" ] || fail "php.net does not list a release candidate for ${patch%-rc}"
+ version=$(jq -r '.version' <<< "$release")
+ tarball_url=$(jq -r '.url' <<< "$release")
+ sha256=$(jq -r '.sha256' <<< "$release")
+else
+ minor="${patch%.*}"
+ version="$patch"
+ tarball_url="https://www.php.net/distributions/php-${patch}.tar.xz"
+ sha256=$("$download" "https://www.php.net/releases/index.php?json&version=${patch}" \
+ | jq -r --arg file "php-${patch}.tar.xz" '(.source // [])[] | select(.filename == $file) | .sha256')
+fi
+[ -n "$sha256" ] || fail "php.net does not list a SHA256 for PHP ${version}"
+
+"$download" "https://codeload.github.com/docker-library/php/tar.gz/${ref}" "$work_dir/docker-library-php.tar.gz"
+rm -rf "$output_dir"
+mkdir -p "$output_dir"
+tar -xzf "$work_dir/docker-library-php.tar.gz" -C "$output_dir" --strip-components=4 "php-${ref}/${minor}/${base_os}/${php_base}" \
+ || fail "docker-library/php@${ref} has no ${minor}/${base_os}/${php_base}"
+
+dockerfile="$output_dir/Dockerfile"
+sed \
+ -e "s|^ENV PHP_VERSION .*|ENV PHP_VERSION ${version}|" \
+ -e "s|^ENV PHP_URL=.*|ENV PHP_URL=\"${tarball_url}\" PHP_ASC_URL=\"${tarball_url}.asc\"|" \
+ -e "s|^ENV PHP_SHA256=.*|ENV PHP_SHA256=\"${sha256}\"|" \
+ -e "s|PHP_BUILD_PROVIDER='[^']*'|PHP_BUILD_PROVIDER='${build_provider}'|" \
+ -e '/^[[:space:]]*pecl update-channels;/d' \
+ "$dockerfile" > "$dockerfile.new"
+mv "$dockerfile.new" "$dockerfile"
+
+# Check the result rather than the input, so a change in docker-library's layout fails here.
+[ "$(grep -c "^ENV PHP_VERSION ${version}\$" "$dockerfile")" = "1" ] || fail "PHP_VERSION was not set to ${version}"
+grep -qF "ENV PHP_URL=\"${tarball_url}\"" "$dockerfile" || fail "PHP_URL was not set to ${tarball_url}"
+grep -qF "ENV PHP_SHA256=\"${sha256}\"" "$dockerfile" || fail "PHP_SHA256 was not set"
+grep -qF "PHP_BUILD_PROVIDER='${build_provider}'" "$dockerfile" || fail "PHP_BUILD_PROVIDER was not set"
+if grep -E '^[^#]*pecl' "$dockerfile"; then
+ fail "the Dockerfile still calls pecl"
+fi
+
+echo "✅ ${output_dir}: docker-library/php ${minor}/${base_os}/${php_base} at ${ref:0:7}, PHP ${version}"
diff --git a/scripts/test-image.sh b/scripts/test-image.sh
index c35a4270e..db969b942 100755
--- a/scripts/test-image.sh
+++ b/scripts/test-image.sh
@@ -1,85 +1,50 @@
#!/usr/bin/env bash
+# shellcheck disable=SC2034,SC2154 # variables are shared with scripts/tests/image/lib.sh and the test files
set -euo pipefail
# Usage: test-image.sh [expected-php-version]
#
-# Runs a published or locally built image and checks the things a user would notice
-# first: it starts, it runs as an unprivileged user, nothing is world-writable, PHP reports the expected version,
-# the default extensions load, and images that ship a web server serve a PHP file
-# through it. Works against any image reference.
-# Commands go through the image's own entrypoint so every entrypoint.d script runs.
-# The entrypoint prints a welcome banner first, so a command's own output is the last line.
+# Runs a published or locally built image through the tests in scripts/tests/image/,
+# one file per feature area. A file skips itself when the image lacks its feature,
+# so the same tests run against every variation. Every test runs even after one
+# fails, and the script exits non-zero if any did.
+#
+# Set TEST_ONLY to a file name without .sh (for example web-server) to run one area.
image="${1:?Usage: test-image.sh [expected-php-version]}"
expected_php="${2:-}"
-health_timeout_seconds=90
-http_timeout_seconds=30
-
-# Every variation installs these (DEPENDENCY_PHP_EXTENSIONS in src/variations/*/Dockerfile).
-expected_extensions="opcache pcntl pdo_mysql pdo_pgsql redis zip"
-
-pass() { echo "✅ $1"; }
-fail() { echo "❌ $1" >&2; exit 1; }
-image_env() { docker image inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$image" | sed -n "s/^$1=//p"; }
-
-echo "🔎 Testing $image"
-
-php_version=$(docker run --rm "$image" php -r 'echo PHP_VERSION;' | tail -n1)
-if [ -n "$expected_php" ] && [ "$php_version" != "$expected_php" ]; then
- fail "PHP reports $php_version, expected $expected_php"
-fi
-pass "PHP $php_version"
-
-uid=$(docker run --rm "$image" id -u | tail -n1)
-[ "$uid" != "0" ] || fail "Container runs as root by default"
-pass "Runs as unprivileged user (uid $uid)"
+tests_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/tests/image" && pwd)"
-# A COPY without --chmod gives existing destination directories the build context's modes.
-# Sticky directories like /tmp are expected.
-world_writable=$(docker run --rm --user root --entrypoint sh "$image" -c \
- 'find / -xdev -perm -0002 \( -type d ! -perm -1000 -o -type f \) -print')
-[ -z "$world_writable" ] || fail "World-writable paths:"$'\n'"$world_writable"
-pass "No world-writable files or directories"
+# shellcheck source=tests/image/lib.sh
+source "$tests_dir/lib.sh"
-loaded_extensions=$(docker run --rm "$image" php -r 'echo implode(" ", array_map("strtolower", get_loaded_extensions()));' | tail -n1)
-missing_extensions=""
-for extension in $expected_extensions; do
- case " $loaded_extensions " in
- *" $extension "*) ;;
- *"zend $extension "*) ;;
- *) missing_extensions="$missing_extensions $extension" ;;
- esac
-done
-[ -z "$missing_extensions" ] || fail "PHP extensions not loaded:$missing_extensions"
-pass "Extensions loaded: $expected_extensions"
-
-# PHP_* environment variables reach php.ini through ${VAR} substitution. Override a few
-# of the different value types (size, boolean, list) and confirm PHP sees them.
-# OPcache is enabled so the CLI SAPI also allocates the shared cache with the tuned defaults.
-ini_values=$(docker run --rm \
- --env PHP_MEMORY_LIMIT=512M \
- --env PHP_REALPATH_CACHE_SIZE=8M \
- --env PHP_SESSION_COOKIE_HTTPONLY=0 \
- --env PHP_DISABLE_FUNCTIONS=shell_exec \
- --env PHP_OPCACHE_ENABLE=1 \
- --env PHP_OPCACHE_FORCE_RESTART_TIMEOUT=60 \
- "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions"), " ", ini_get("opcache.force_restart_timeout");' | tail -n1)
-[ "$ini_values" = "512M 8M 0 shell_exec 60" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values"
-pass "Environment variables apply to php.ini"
+test_label="serversideup-php-test=$$"
+work_dir=$(mktemp -d)
+cleanup() {
+ local containers images
+ containers=$(docker ps --all --quiet --filter "label=$test_label")
+ images=$(docker images --quiet --filter "label=$test_label" | sort -u)
+ # shellcheck disable=SC2086 # one ID per word
+ [ -z "$containers" ] || docker rm --force $containers >/dev/null 2>&1 || true
+ # shellcheck disable=SC2086
+ [ -z "$images" ] || docker rmi --force $images >/dev/null 2>&1 || true
+ rm -rf "$work_dir"
+}
+trap cleanup EXIT
-# PHP_OPCACHE_ENABLE_CLI turns OPcache off for the CLI SAPI while opcache.enable stays on for the web server.
-cli_opcache=$(docker run --rm --env PHP_OPCACHE_ENABLE=1 --env PHP_OPCACHE_ENABLE_CLI=0 \
- "$image" php -r 'echo function_exists("opcache_get_status") && opcache_get_status(false) !== false ? "on" : "off";' | tail -n1)
-[ "$cli_opcache" = "off" ] || fail "PHP_OPCACHE_ENABLE_CLI=0 did not disable OPcache for the CLI. Got: $cli_opcache"
-pass "PHP_OPCACHE_ENABLE_CLI disables the CLI cache"
+# The container runs unprivileged, so mounted fixtures must be world readable.
+fixtures_dir="$work_dir/fixtures"
+cp -R "$tests_dir/fixtures" "$fixtures_dir"
+chmod -R u=rwX,go=rX "$fixtures_dir"
+web_dir="$fixtures_dir/public"
+php_version=$(run_php 'echo PHP_VERSION;')
has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image")
-if [ -z "$has_healthcheck" ]; then
- pass "No HEALTHCHECK defined, skipping startup check"
- exit 0
-fi
+is_frankenphp=$(image_env CADDY_HTTP_PORT)
+is_fpm=$(image_env PHP_FPM_POOL_NAME)
-# Web images expose their HTTP port and document root as environment variables.
+# Web images expose their HTTP port and document root as environment variables, and
+# serve TLS on their HTTPS port once SSL_MODE is on.
http_port=""
web_root=""
for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT CADDY_HTTP_PORT:CADDY_SERVER_ROOT; do
@@ -91,229 +56,78 @@ for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT
break
fi
done
-
-# The same images serve TLS on their HTTPS port once SSL_MODE is on, which is the only
-# way to reach them over HTTP/2.
https_port=""
for variable in NGINX_HTTPS_PORT APACHE_HTTPS_PORT CADDY_HTTPS_PORT; do
https_port=$(image_env "$variable")
[ -z "$https_port" ] || break
done
-# Web images run with OPcache enabled so the health check and the served page
-# cover the FPM and FrankenPHP SAPIs starting with the tuned defaults.
-run_args=(--detach --env PHP_OPCACHE_ENABLE=1)
-if [ -n "$http_port" ]; then
- # The container runs unprivileged, so the mounted document root must be world readable.
- web_dir=$(mktemp -d)
- chmod 755 "$web_dir"
- echo ' "$web_dir/index.php"
- chmod 644 "$web_dir/index.php"
- mkdir -p "$web_dir/storage"
- chmod 755 "$web_dir/storage"
- echo ' "$web_dir/storage/uploaded.php"
- chmod 644 "$web_dir/storage/uploaded.php"
- # Symfony's Response::send() calls fastcgi_finish_request() and Laravel keeps running
- # terminate callbacks after it, so the web server has to survive a request that finishes
- # before PHP does.
- cat > "$web_dir/finish-request.php" <<'PHP'
-/dev/null 2>&1 || true
- done
- [ -z "${web_dir:-}" ] || rm -rf "$web_dir"
-}
-trap cleanup EXIT
-
-dump_container_state() {
- echo "--- container logs ---" >&2
- docker logs "$1" >&2 2>&1 || true
- echo "--- last health check ---" >&2
- docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true
+failures_file="$work_dir/failures"
+touch "$failures_file"
+
+output="$work_dir/output"
+
+report() {
+ local name="$1" status="$2"
+ case "$status" in
+ 0) echo "✅ $name" ;;
+ "$skip_status") echo "⏭️ $name, $(tail -n1 "$output")" ;;
+ *)
+ echo "❌ $name"
+ sed 's/^/ /' "$output"
+ echo "$area: $name" >> "$failures_file"
+ ;;
+ esac
}
-start_container() {
- container=$(docker run "${run_args[@]}" "$@" "$image")
- containers+=("$container")
-
- status=starting
- for _ in $(seq 1 "$health_timeout_seconds"); do
- status=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "gone")
- case "$status" in
- healthy) break ;;
- unhealthy|gone) break ;;
- esac
- sleep 1
- done
-
- if [ "$status" != "healthy" ]; then
- dump_container_state "$container"
- fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)"
+# A file stops early when requires skips it or setup fails.
+report_file_stopped() {
+ if [ "$1" -eq "$skip_status" ]; then
+ echo "⏭️ $(tail -n1 "$output")"
+ else
+ report setup "$1"
fi
- [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://')
- [ -z "$https_port" ] || https_host_port=$(docker port "$container" "$https_port" | head -n1 | sed 's/.*://')
-}
-
-# Retries until the response body matches, since the web server may still be warming up.
-expect_body() {
- path="$1"
- expected="$2"
- body=""
- for _ in $(seq 1 "$http_timeout_seconds"); do
- body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}${path}" 2>/dev/null || true)
- [ "$body" = "$expected" ] && return 0
- sleep 1
- done
- dump_container_state "$container"
- fail "Web server did not serve ${path} on port ${http_port}. Response: ${body:-}"
-}
-
-# Uploaded PHP files under /storage must never run, including through PATH_INFO
-# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute.
-expect_storage_blocked() {
- for path in /storage/uploaded.php /storage/uploaded.php/anything; do
- response=$(curl --silent --max-time 5 --output /dev/null --write-out '%{http_code}' "http://127.0.0.1:${host_port}${path}" || true)
- [ "$response" = "403" ] || fail "Expected ${path} to return 403, got ${response:-}"
- done
-}
-
-# Mercure subscribers pass their JWT as ?authorization=, which must never reach the access log.
-# Retries because the log line lands a moment after the response.
-expect_authorization_redacted() {
- curl --silent --max-time 5 --output /dev/null "http://127.0.0.1:${host_port}/?authorization=octane-secret" || true
- for _ in $(seq 1 "$http_timeout_seconds"); do
- logs=$(docker logs "$container" 2>&1)
- case "$logs" in
- *"octane-secret"*) fail "Access log contains the authorization query parameter" ;;
- *"authorization=REDACTED"*) return 0 ;;
- esac
- sleep 1
- done
- dump_container_state "$container"
- fail "Access log does not redact the authorization query parameter"
}
-# PHP carrying on after fastcgi_finish_request() has to leave the server able to take the
-# next request. Caddy 2.11.6 segfaulted the whole FrankenPHP process here, but only over
-# HTTP/2, so both protocol versions are worth checking.
-expect_work_after_finish_request() {
- for protocol in --http1.1 --http2; do
- body=""
- for _ in $(seq 1 "$http_timeout_seconds"); do
- body=$(curl --silent --insecure "$protocol" --max-time 5 "https://127.0.0.1:${https_host_port}/finish-request.php" 2>/dev/null || true)
- [ "$body" = "finish-request-ok:${php_version}" ] && break
- sleep 1
- done
- if [ "$body" != "finish-request-ok:${php_version}" ]; then
- dump_container_state "$container"
- fail "Web server did not serve /finish-request.php over ${protocol#--}. Response: ${body:-}"
- fi
-
- # The crash lands after the response, so give the process a moment to fall over.
- sleep 2
- status=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo gone)
- if [ "$status" != "running" ]; then
- dump_container_state "$container"
- fail "Web server stopped after a ${protocol#--} request finished before PHP did (status: $status)"
- fi
+# Runs in its own subshell so one file's containers and variables never reach the next.
+# The file and its setup() run in that shell so the tests can use the container setup
+# starts; an EXIT trap reports them when they stop it. Each test runs in a subshell with
+# errexit on, so a failed command ends that test only.
+run_test_file() {
+ local file="$1" test_function status
+ exec 3>&1
+ trap 'report_file_stopped $? >&3' EXIT
+ # shellcheck source=/dev/null
+ source "$file" >"$output" 2>&1
+ if declare -F setup >/dev/null; then
+ setup >"$output" 2>&1
+ fi
+ trap - EXIT
+ for test_function in $(grep -o '^test_[a-z0-9_]*' "$file"); do
+ set +e
+ ( set -e; "$test_function" ) >"$output" 2>&1
+ status=$?
+ set -e
+ report "$(echo "${test_function#test_}" | tr _ ' ')" "$status"
done
}
-# A detached container has no terminal, so Caddy's default format is json and its default
-# stream is stderr. Octane depends on both: it only relays stderr and only parses JSON.
-expect_json_logs_on_stderr() {
- stdout_logs=$(docker logs "$container" 2>/dev/null)
- stderr_logs=$(docker logs "$container" 2>&1 >/dev/null)
- case "$stderr_logs" in
- *'"msg":"handled request"'*) ;;
- *) dump_container_state "$container"; fail "Access log is not JSON on stderr" ;;
- esac
- case "$stdout_logs" in
- *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;;
- esac
-}
-
-start_container
-pass "Container became healthy"
-
-[ -n "$http_port" ] || exit 0
-
-expect_body / "serversideup-php-ok:${php_version}"
-pass "Web server serves PHP on port ${http_port}"
-
-expect_storage_blocked
-pass "Web server blocks PHP execution under /storage"
+echo "🔎 Testing $image (PHP $php_version)"
+for file in "$tests_dir"/*.sh; do
+ area=$(basename "$file" .sh)
+ [ "$area" != lib ] || continue
+ [ -z "${TEST_ONLY:-}" ] || [ "$area" = "$TEST_ONLY" ] || continue
+ echo
+ echo "$area"
+ set +e
+ ( set -e; run_test_file "$file" )
+ set -e
+done
-# HTTP/2 needs TLS, so this runs against its own container with SSL_MODE=full, which
-# generates a self-signed certificate and stops serving the plain HTTP port. The checks
-# after it still expect the container the rest of the suite has been using.
-if [ -n "$https_port" ]; then
- default_container="$container"
- default_host_port="$host_port"
- start_container --env SSL_MODE=full
- expect_work_after_finish_request
- pass "Web server survives a request that finishes before PHP does, over HTTP/1.1 and HTTP/2"
- container="$default_container"
- host_port="$default_host_port"
+echo
+if [ -s "$failures_file" ]; then
+ echo "$(wc -l < "$failures_file" | tr -d ' ') test(s) failed:" >&2
+ sed 's/^/ /' "$failures_file" >&2
+ exit 1
fi
-
-# The rest applies to FrankenPHP only: Caddy's log defaults and Laravel Octane.
-[ -n "$(image_env CADDY_HTTP_PORT)" ] || exit 0
-
-expect_authorization_redacted
-expect_json_logs_on_stderr
-pass "Web server writes JSON logs to stderr and redacts the authorization query parameter"
-
-# Octane starts FrankenPHP with LARAVEL_OCTANE=1 and the variables from
-# src/Commands/StartFrankenPhpCommand.php in laravel/octane, which must switch
-# /etc/frankenphp/Caddyfile to the Octane worker without losing the rest of the configuration.
-cat > "$web_dir/frankenphp-worker.php" <<'PHP'
-}"
-pass "Octane mode exposes the Caddy admin API on Octane's admin port"
-
-expect_authorization_redacted
-expect_json_logs_on_stderr
-pass "Octane mode writes JSON logs to stderr and redacts the authorization query parameter"
+echo "All tests passed"
diff --git a/scripts/tests/image/.shellcheckrc b/scripts/tests/image/.shellcheckrc
new file mode 100644
index 000000000..f8238048b
--- /dev/null
+++ b/scripts/tests/image/.shellcheckrc
@@ -0,0 +1,3 @@
+# The test files read and set variables that scripts/test-image.sh and lib.sh share
+# with them (image, php_version, container, ...), which ShellCheck can't see across files.
+disable=SC2034,SC2154
diff --git a/scripts/tests/image/entrypoint.sh b/scripts/tests/image/entrypoint.sh
new file mode 100644
index 000000000..c57c80cdf
--- /dev/null
+++ b/scripts/tests/image/entrypoint.sh
@@ -0,0 +1,38 @@
+# shellcheck shell=bash
+# docker-php-serversideup-entrypoint: the scripts in /etc/entrypoint.d, then the command.
+
+# Usage: run_entrypoint