Date: Thu, 16 Apr 2026 14:14:53 -0500
Subject: [PATCH 17/47] Changed sponsor URL
---
.github/workflows/scheduled-task_update-sponsors.yml | 4 ++--
README.md | 8 ++++----
docs/app/app.config.ts | 2 +-
docs/app/components/Badges.vue | 2 +-
docs/app/components/Sponsors.vue | 2 +-
docs/app/pages/[...slug].vue | 2 +-
docs/content/docs/1.getting-started/9.about.md | 2 +-
7 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/scheduled-task_update-sponsors.yml b/.github/workflows/scheduled-task_update-sponsors.yml
index 3d1d233b1..c309277e9 100644
--- a/.github/workflows/scheduled-task_update-sponsors.yml
+++ b/.github/workflows/scheduled-task_update-sponsors.yml
@@ -16,7 +16,7 @@ jobs:
organization: true
minimum: 4900
maximum: 5100
- fallback: 'No bronze sponsors yet. Become a sponsor â'
+ fallback: 'No bronze sponsors yet. Become a sponsor â'
token: ${{ secrets.SPONSORS_README_ACTION_PERSONAL_ACCESS_TOKEN }}
marker: 'bronze'
template: '
'
@@ -27,7 +27,7 @@ jobs:
with:
organization: true
maximum: 500
- fallback: '
'
+ fallback: '
'
token: ${{ secrets.SPONSORS_README_ACTION_PERSONAL_ACCESS_TOKEN }}
marker: 'supporters'
template: '
'
diff --git a/README.md b/README.md
index bc350c7aa..2d4e819f1 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+
@@ -141,13 +141,13 @@ Need help getting started? Join our Discord community and we'll help you out!
## Our Sponsors
All of our software is free and open to the world. None of this can be brought to you without the financial backing of our sponsors.
-

+
### Black Level Sponsors
#### Bronze Sponsors
-No bronze sponsors yet. Become a sponsor â
+No bronze sponsors yet. Become a sponsor â
#### Infrastructure Sponsors
This project requires significant computing power to build and maintain over 8,000 different Docker image tags. We're extremely grateful for the following sponsors:
@@ -176,7 +176,7 @@ We're [Dan](https://x.com/danpastori) and [Jay](https://x.com/jaydrogers) - a tw
* **đģ [GitHub](https://github.com/serversideup)** - Check out our other open source projects.
* **đĢ [Newsletter](https://serversideup.net/subscribe)** - Skip the algorithms and get quality content right to your inbox.
* **đĨ [Twitter](https://x.com/serversideup)** - You can also follow [Dan](https://x.com/danpastori) and [Jay](https://x.com/jaydrogers).
-* **â¤ī¸ [Sponsor Us](https://github.com/sponsors/serversideup)** - Please consider sponsoring us so we can create more helpful resources.
+* **â¤ī¸ [Sponsor Us](https://serversideup.net/sponsor/)** - 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.
diff --git a/docs/app/app.config.ts b/docs/app/app.config.ts
index 8fe6a9bb7..4646d4997 100644
--- a/docs/app/app.config.ts
+++ b/docs/app/app.config.ts
@@ -116,7 +116,7 @@ export default defineAppConfig({
},{
'trailingIcon': 'i-lucide-heart',
'label': 'Sponsor',
- 'to': 'https://github.com/sponsors/serversideup',
+ 'to': 'https://serversideup.net/sponsor/',
'target': '_blank',
'aria-label': 'Sponsor',
'size': 'xl',
diff --git a/docs/app/components/Badges.vue b/docs/app/components/Badges.vue
index 498535522..6cc6c1aac 100644
--- a/docs/app/components/Badges.vue
+++ b/docs/app/components/Badges.vue
@@ -3,6 +3,6 @@
-
+
\ No newline at end of file
diff --git a/docs/app/components/Sponsors.vue b/docs/app/components/Sponsors.vue
index bc157a2da..def702673 100644
--- a/docs/app/components/Sponsors.vue
+++ b/docs/app/components/Sponsors.vue
@@ -7,7 +7,7 @@
-
+
Become a Sponsor
diff --git a/docs/app/pages/[...slug].vue b/docs/app/pages/[...slug].vue
index b818cf0e9..4fd1f2157 100644
--- a/docs/app/pages/[...slug].vue
+++ b/docs/app/pages/[...slug].vue
@@ -65,7 +65,7 @@
:links="[{
label: 'Become a Sponsor',
icon: 'i-lucide-heart',
- to: 'https://github.com/sponsors/serversideup',
+ to: 'https://serversideup.net/sponsor/',
target: '_blank'
}]"
/>
diff --git a/docs/content/docs/1.getting-started/9.about.md b/docs/content/docs/1.getting-started/9.about.md
index a88d67a40..f58cc1f4a 100644
--- a/docs/content/docs/1.getting-started/9.about.md
+++ b/docs/content/docs/1.getting-started/9.about.md
@@ -26,7 +26,7 @@ We're [Dan](https://x.com/danpastori){target="_blank"} and [Jay](https://x.com/j
* **đģ [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://github.com/sponsors/serversideup){target="_blank"}** - Please consider sponsoring us so we can create more helpful resources.
+* **â¤ī¸ [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.
From e5c1e68c762a01287dbd397e85e7c5642fc3d748 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Mon, 27 Apr 2026 13:37:40 -0500
Subject: [PATCH 18/47] Add AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND environment
variable
- Introduced `AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND` to allow the Laravel Automations script to exit gracefully if Laravel is not detected in `APP_BASE_DIR`, preventing container failure.
- Updated documentation to reflect this new variable and its usage, particularly in shared image scenarios before the first `composer install`.
- Enhanced the entrypoint script to support this new behavior, improving flexibility for development environments.
---
.../docs/3.framework-guides/1.laravel/1.automations.md | 3 +++
.../docs/8.reference/1.environment-variable-specification.md | 1 +
src/common/etc/entrypoint.d/50-laravel-automations.sh | 5 +++++
3 files changed, 9 insertions(+)
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 6f88dbfc4..c320ba38e 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
@@ -31,6 +31,7 @@ In order for this script to run,`AUTORUN_ENABLED` must be set to `true`. Once th
| `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. |
@@ -153,6 +154,8 @@ In most cases, this is due to a bug in their application code that causes a migr
If a failure occurs in the Laravel Automations script, it will exit with a non-zero exit code -- preventing the container from starting.
::
+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.
+
If you are experiencing issues, you can enable the `AUTORUN_DEBUG` environment variable to get more detailed output of what could be going wrong.
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.
diff --git a/docs/content/docs/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
index 8094dd528..279f97d72 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -42,6 +42,7 @@ Setting environment variables all depends on what method you're using to run you
`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_ADMIN`
*Default: "off"*|Enable Caddy admin interface. (Official docs)|frankenphp
diff --git a/src/common/etc/entrypoint.d/50-laravel-automations.sh b/src/common/etc/entrypoint.d/50-laravel-automations.sh
index 3634c0a12..857f76fab 100644
--- a/src/common/etc/entrypoint.d/50-laravel-automations.sh
+++ b/src/common/etc/entrypoint.d/50-laravel-automations.sh
@@ -9,6 +9,7 @@ script_name="laravel-automations"
# Set default values for Laravel automations
: "${AUTORUN_ENABLED:=false}"
: "${AUTORUN_DEBUG:=false}"
+: "${AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND:=false}"
# Set default values for storage link
: "${AUTORUN_LARAVEL_STORAGE_LINK:=true}"
@@ -469,6 +470,10 @@ if laravel_is_installed; then
artisan_optimize
fi
else
+ if [ "$AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND" = "true" ]; then
+ debug_log "Laravel not detected in $APP_BASE_DIR. Skipping automations (AUTORUN_LARAVEL_SKIP_IF_NOT_FOUND=true)."
+ exit 0
+ fi
echo "â $script_name: Could not detect Laravel installation."
echo "âšī¸ Check that the application is installed in $APP_BASE_DIR"
exit 1
From a9f3c6c3ef045b476690bfac59fc4853abcb65e7 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Mon, 27 Apr 2026 14:02:02 -0500
Subject: [PATCH 19/47] Refactor logging in Laravel automation script
- Replaced echo statements with debug_log calls for improved logging in the `artisan_storage_link` and automation check sections of the entrypoint script.
- This change enhances the verbosity of log output, aiding in debugging and monitoring of Laravel automations.
---
src/common/etc/entrypoint.d/50-laravel-automations.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/common/etc/entrypoint.d/50-laravel-automations.sh b/src/common/etc/entrypoint.d/50-laravel-automations.sh
index 857f76fab..6f4a811a9 100644
--- a/src/common/etc/entrypoint.d/50-laravel-automations.sh
+++ b/src/common/etc/entrypoint.d/50-laravel-automations.sh
@@ -154,7 +154,7 @@ artisan_migrate() {
artisan_storage_link() {
if [ -d "$APP_BASE_DIR/public/storage" ]; then
- echo "â
Storage already linked..."
+ debug_log "â
Storage already linked..."
return 0
else
echo "đ Running storage link: \"php artisan storage:link\"..."
@@ -453,7 +453,7 @@ if laravel_is_installed; then
done
fi
- echo "đ¤ Checking for Laravel automations..."
+ debug_log "đ¤ Checking for Laravel automations..."
if [ "$AUTORUN_LARAVEL_STORAGE_LINK" = "true" ]; then
artisan_storage_link
fi
From c26ce9ec47052f3e51f6381d34c03068f8da03e3 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 4 Aug 2026 17:57:37 -0500
Subject: [PATCH 20/47] Add short-lived SSL support for IP addresses and other
Let's Encrypt profiles
- Added details on using Let's Encrypt short-lived certificates and the `CADDY_ACME_PROFILE` environment variable in the documentation.
- Updated the Dockerfile to set the default value for `CADDY_ACME_PROFILE` to "off".
- Modified the Caddyfile to include the option for selecting a Let's Encrypt ACME certificate profile.
These changes improve clarity on SSL options and enhance the configuration flexibility for users.
---
.../docs/2.image-variations/frankenphp.md | 3 +++
.../4.configuring-ssl.md | 24 +++++++++++++++++++
.../1.environment-variable-specification.md | 1 +
src/variations/frankenphp/Dockerfile | 1 +
.../frankenphp/etc/frankenphp/Caddyfile | 5 +++-
.../frankenphp/acme-profile/classic.caddyfile | 7 ++++++
.../etc/frankenphp/acme-profile/off.caddyfile | 5 ++++
.../acme-profile/shortlived.caddyfile | 12 ++++++++++
.../acme-profile/tlsserver.caddyfile | 7 ++++++
9 files changed, 64 insertions(+), 1 deletion(-)
create mode 100644 src/variations/frankenphp/etc/frankenphp/acme-profile/classic.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/acme-profile/off.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/acme-profile/shortlived.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/acme-profile/tlsserver.caddyfile
diff --git a/docs/content/docs/2.image-variations/frankenphp.md b/docs/content/docs/2.image-variations/frankenphp.md
index 278d601c7..6d5e43515 100644
--- a/docs/content/docs/2.image-variations/frankenphp.md
+++ b/docs/content/docs/2.image-variations/frankenphp.md
@@ -304,6 +304,8 @@ services:
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:
@@ -339,6 +341,7 @@ The FrankenPHP variation supports extensive customization through environment va
| `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 |
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
index 940688ff5..f84bbe846 100644
--- a/docs/content/docs/4.deployment-and-production/4.configuring-ssl.md
+++ b/docs/content/docs/4.deployment-and-production/4.configuring-ssl.md
@@ -150,6 +150,30 @@ phpinfo();
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"}.
diff --git a/docs/content/docs/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
index 279f97d72..73cd102c4 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -45,6 +45,7 @@ Setting environment variables all depends on what method you're using to run you
`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. (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. (Official docs)|frankenphp
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index d88b8a9cd..0cd377a6a 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -127,6 +127,7 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
org.opencontainers.image.licenses="GPL-3.0-or-later"
ENV APP_BASE_DIR=/var/www/html \
+ CADDY_ACME_PROFILE="off" \
CADDY_ADMIN="off" \
CADDY_AUTO_HTTPS="off" \
CADDY_GLOBAL_OPTIONS="" \
diff --git a/src/variations/frankenphp/etc/frankenphp/Caddyfile b/src/variations/frankenphp/etc/frankenphp/Caddyfile
index cc883e8e1..704cee3dd 100644
--- a/src/variations/frankenphp/etc/frankenphp/Caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/Caddyfile
@@ -19,8 +19,11 @@
import trusted-proxy/{$TRUSTED_PROXY:cloudflare}.caddyfile
+ # Select a Let's Encrypt ACME certificate profile (default: off)
+ import acme-profile/{$CADDY_ACME_PROFILE:off}.caddyfile
+
# Add additional Caddy configuration files from the caddyfile-global.d directory
- import caddyfile-global.d/*.caddyfile
+ import caddyfile-global.d/*.caddyfile
{$CADDY_GLOBAL_OPTIONS}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/acme-profile/classic.caddyfile b/src/variations/frankenphp/etc/frankenphp/acme-profile/classic.caddyfile
new file mode 100644
index 000000000..34d207326
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/acme-profile/classic.caddyfile
@@ -0,0 +1,7 @@
+# Request Let's Encrypt's "classic" certificate profile.
+#
+# Note: configuring cert_issuer pins issuance to Let's Encrypt only (this
+# drops the default ZeroSSL fallback).
+cert_issuer acme {
+ profile classic
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/acme-profile/off.caddyfile b/src/variations/frankenphp/etc/frankenphp/acme-profile/off.caddyfile
new file mode 100644
index 000000000..42d840342
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/acme-profile/off.caddyfile
@@ -0,0 +1,5 @@
+# No ACME certificate profile is configured (default).
+#
+# Caddy keeps its stock issuer behavior, including the automatic
+# Let's Encrypt + ZeroSSL fallback. Set CADDY_ACME_PROFILE to select a
+# specific Let's Encrypt certificate profile instead.
diff --git a/src/variations/frankenphp/etc/frankenphp/acme-profile/shortlived.caddyfile b/src/variations/frankenphp/etc/frankenphp/acme-profile/shortlived.caddyfile
new file mode 100644
index 000000000..ef7b1a93d
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/acme-profile/shortlived.caddyfile
@@ -0,0 +1,12 @@
+# Request Let's Encrypt's "shortlived" certificate profile (~6-day certs).
+#
+# Note: configuring cert_issuer pins issuance to Let's Encrypt only (this
+# drops the default ZeroSSL fallback). Short-lived certs renew roughly every
+# 2 days, so the container needs reliable egress to the ACME CA.
+#
+# The shortlived profile is also required for IP-address certificates. For
+# raw-IP / SNI-less access, also set a default SNI via CADDY_GLOBAL_OPTIONS,
+# e.g. CADDY_GLOBAL_OPTIONS="default_sni {$APP_DOMAIN}".
+cert_issuer acme {
+ profile shortlived
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/acme-profile/tlsserver.caddyfile b/src/variations/frankenphp/etc/frankenphp/acme-profile/tlsserver.caddyfile
new file mode 100644
index 000000000..73bd5d6e8
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/acme-profile/tlsserver.caddyfile
@@ -0,0 +1,7 @@
+# Request Let's Encrypt's "tlsserver" certificate profile.
+#
+# Note: configuring cert_issuer pins issuance to Let's Encrypt only (this
+# drops the default ZeroSSL fallback).
+cert_issuer acme {
+ profile tlsserver
+}
From bd12ac83c119304ebc15c41b5f29806625e451c3 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 4 Aug 2026 18:00:24 -0500
Subject: [PATCH 21/47] Update PHP extension installer version to 2.11.12
---
.../local/bin/docker-php-serversideup-install-php-ext-installer | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
index cad78735d..c2cee43b3 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
+++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
@@ -11,7 +11,7 @@ script_name="docker-php-serversideup-install-php-ext-installer"
############
# Environment variables
############
-PHP_EXT_INSTALLER_VERSION="2.10.15"
+PHP_EXT_INSTALLER_VERSION="2.11.12"
############
# Main
From 6aa2e3358cfd62bb38347d47f7c24235d322ec3d Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 4 Aug 2026 18:01:47 -0500
Subject: [PATCH 22/47] Update S6 version to 3.2.3.2
---
src/s6/usr/local/bin/docker-php-serversideup-s6-install | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-install b/src/s6/usr/local/bin/docker-php-serversideup-s6-install
index a07f0a1a9..57ea405b2 100644
--- a/src/s6/usr/local/bin/docker-php-serversideup-s6-install
+++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-install
@@ -9,7 +9,7 @@ set -oue
# Be sure to set the S6_SRC_URL, S6_SRC_DEP, and S6_DIR
# environment variables before running this script.
-S6_VERSION=v3.2.2.0
+S6_VERSION=v3.2.3.2
mkdir -p $S6_DIR
export SYS_ARCH=$(uname -m)
case "$SYS_ARCH" in
From 49e286e4dbf2412633d617c93368520d5d8c95b7 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Fri, 7 Aug 2026 07:59:47 -0500
Subject: [PATCH 23/47] Update FrankenPHP version to 1.12.7
---
src/variations/frankenphp/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index 0cd377a6a..9639b660b 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -2,7 +2,7 @@
ARG BASE_OS_VERSION='trixie'
ARG PHP_VERSION='8.5'
ARG BASE_IMAGE="php:${PHP_VERSION}-zts-${BASE_OS_VERSION}"
-ARG FRANKENPHP_VERSION='1.12.6'
+ARG FRANKENPHP_VERSION='1.12.7'
ARG GOLANG_VERSION='1.26'
########################
From 32407147708c5edce2a20a6c9d29b4e90322b54f Mon Sep 17 00:00:00 2001
From: agabi10
Date: Tue, 8 Sep 2026 20:42:05 +0200
Subject: [PATCH 24/47] Add PHP_FILE_UPLOADS and PHP_MAX_FILE_UPLOADS to
environment variables (#697)
---
docs/content/docs/2.image-variations/fpm-apache.md | 2 ++
docs/content/docs/2.image-variations/fpm-nginx.md | 2 ++
docs/content/docs/2.image-variations/frankenphp.md | 2 ++
.../docs/8.reference/1.environment-variable-specification.md | 2 ++
.../usr/local/etc/php/conf.d/serversideup-docker-php.ini | 4 ++--
src/variations/cli/Dockerfile | 2 ++
src/variations/fpm-apache/Dockerfile | 2 ++
src/variations/fpm-nginx/Dockerfile | 2 ++
src/variations/fpm/Dockerfile | 2 ++
src/variations/frankenphp/Dockerfile | 2 ++
10 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/docs/content/docs/2.image-variations/fpm-apache.md b/docs/content/docs/2.image-variations/fpm-apache.md
index 194245e6c..58cab7791 100644
--- a/docs/content/docs/2.image-variations/fpm-apache.md
+++ b/docs/content/docs/2.image-variations/fpm-apache.md
@@ -235,6 +235,8 @@ For a complete list of available environment variables, see the [Environment Var
| `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"}
diff --git a/docs/content/docs/2.image-variations/fpm-nginx.md b/docs/content/docs/2.image-variations/fpm-nginx.md
index 077cb1390..7e635a734 100644
--- a/docs/content/docs/2.image-variations/fpm-nginx.md
+++ b/docs/content/docs/2.image-variations/fpm-nginx.md
@@ -236,6 +236,8 @@ For a complete list of available environment variables, see the [Environment Var
| `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 |
## Performance Tuning
diff --git a/docs/content/docs/2.image-variations/frankenphp.md b/docs/content/docs/2.image-variations/frankenphp.md
index 6d5e43515..f432b9277 100644
--- a/docs/content/docs/2.image-variations/frankenphp.md
+++ b/docs/content/docs/2.image-variations/frankenphp.md
@@ -366,6 +366,8 @@ For a complete list of available environment variables, see the [Environment Var
| `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) |
diff --git a/docs/content/docs/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
index 73cd102c4..fba7426c0 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -81,6 +81,7 @@ Setting environment variables all depends on what method you're using to run you
`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*
@@ -93,6 +94,7 @@ Setting environment variables all depends on what method you're using to run you
`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_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
diff --git a/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini b/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
index 0bbeec28c..36c103def 100644
--- a/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
+++ b/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
@@ -849,7 +849,7 @@ enable_dl = Off
; Whether to allow HTTP file uploads.
; https://php.net/file-uploads
-file_uploads = On
+file_uploads = ${PHP_FILE_UPLOADS}
; Temporary directory for HTTP uploaded files (will use system default if not
; specified).
@@ -861,7 +861,7 @@ file_uploads = On
upload_max_filesize = ${PHP_UPLOAD_MAX_FILE_SIZE}
; Maximum number of files that can be uploaded via a single request
-max_file_uploads = 20
+max_file_uploads = ${PHP_MAX_FILE_UPLOADS}
;;;;;;;;;;;;;;;;;;
; Fopen wrappers ;
diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile
index 9e64137f5..faadcfff6 100644
--- a/src/variations/cli/Dockerfile
+++ b/src/variations/cli/Dockerfile
@@ -33,7 +33,9 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
+ PHP_FILE_UPLOADS="On" \
PHP_MAX_EXECUTION_TIME="99" \
+ PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile
index 0fa0d3fc1..7c1c5e364 100644
--- a/src/variations/fpm-apache/Dockerfile
+++ b/src/variations/fpm-apache/Dockerfile
@@ -61,6 +61,7 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
+ PHP_FILE_UPLOADS="On" \
PHP_FPM_PM_CONTROL=ondemand \
PHP_FPM_PM_MAX_CHILDREN="20" \
PHP_FPM_PM_MAX_REQUESTS="0" \
@@ -71,6 +72,7 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
PHP_FPM_POOL_NAME="www" \
PHP_FPM_PROCESS_CONTROL_TIMEOUT="10s" \
PHP_MAX_EXECUTION_TIME="99" \
+ PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile
index e945a3d57..83a4d944a 100644
--- a/src/variations/fpm-nginx/Dockerfile
+++ b/src/variations/fpm-nginx/Dockerfile
@@ -135,6 +135,7 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
+ PHP_FILE_UPLOADS="On" \
PHP_FPM_PM_CONTROL=ondemand \
PHP_FPM_PM_MAX_CHILDREN="20" \
PHP_FPM_PM_MAX_REQUESTS="0" \
@@ -145,6 +146,7 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_FPM_POOL_NAME="www" \
PHP_FPM_PROCESS_CONTROL_TIMEOUT="10s" \
PHP_MAX_EXECUTION_TIME="99" \
+ PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile
index 33294b908..2d39f76f6 100644
--- a/src/variations/fpm/Dockerfile
+++ b/src/variations/fpm/Dockerfile
@@ -33,6 +33,7 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
+ PHP_FILE_UPLOADS="On" \
PHP_FPM_PM_CONTROL=dynamic \
PHP_FPM_PM_MAX_CHILDREN="20" \
PHP_FPM_PM_MAX_REQUESTS="0" \
@@ -43,6 +44,7 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_FPM_POOL_NAME="www" \
PHP_FPM_PROCESS_CONTROL_TIMEOUT="10s" \
PHP_MAX_EXECUTION_TIME="99" \
+ PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index 9639b660b..c85285712 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -152,7 +152,9 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
+ PHP_FILE_UPLOADS="On" \
PHP_MAX_EXECUTION_TIME="99" \
+ PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
From a8e29aa026247f86bc0839644f89889212e22180 Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:23:30 -0500
Subject: [PATCH 25/47] Drop PHP 7.4, 8.0, and 8.1 support (#699)
* Update documentation and scripts for PHP version changes and migration
- Added a section on dropped PHP versions in the major version migrations guide, detailing the last built dates and reasons for PHP 8.1, 8.0, and 7.4.
- Updated the Docker tags documentation to reflect changes in supported OS versions and PHP patch versions.
- Modified the assemble-docker-tags script to align with the new PHP versioning scheme.
- Revised the PHP versions base config to remove deprecated versions and adjust supported operating systems.
- Updated development scripts to reflect the new PHP versioning and OS options.
- Enhanced the generate-matrix script to ensure compatibility with the updated PHP versions and OS configurations.
- Adjusted the get-nginx-versions and get-php-versions scripts to accommodate the removal of older OS versions.
* Add support for PHP extension overrides in Dockerfiles and scripts
---
.github/ISSUE_TEMPLATE/bug.yml | 2 +-
.../service_docker-build-and-publish.yml | 1 +
README.md | 10 +--
SECURITY.md | 16 +++-
.../1.getting-started/5.choosing-an-image.md | 29 ++++----
.../docs/1.getting-started/7.upgrade-guide.md | 2 +-
.../docs/1.getting-started/99.contributing.md | 8 +-
.../5.guide/5.major-version-migrations.md | 14 ++++
.../docs/8.reference/2.command-reference.md | 10 +++
scripts/assemble-docker-tags.sh | 4 +-
scripts/conf/php-versions-base-config.yml | 73 ++++---------------
scripts/dev.sh | 27 ++++++-
scripts/generate-matrix.sh | 8 +-
scripts/get-nginx-versions.sh | 6 +-
scripts/get-php-versions.sh | 5 +-
...er-php-serversideup-install-php-extensions | 46 ++++++++++++
src/variations/cli/Dockerfile | 3 +-
src/variations/fpm-apache/Dockerfile | 3 +-
src/variations/fpm-nginx/Dockerfile | 3 +-
src/variations/fpm/Dockerfile | 3 +-
src/variations/frankenphp/Dockerfile | 3 +-
21 files changed, 174 insertions(+), 102 deletions(-)
create mode 100644 src/common/usr/local/bin/docker-php-serversideup-install-php-extensions
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index 4bf7f1922..614b6c6c2 100644
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -29,7 +29,7 @@ body:
- type: textarea
attributes:
label: Affected Docker Images
- description: "Which images does this issue happen in? (for example: `serversideup/php:8.1-cli`, `serversideup/php:8.1-fpm`, etc). You can also run Run `docker inspect --format='{{json .Config.Labels}}' <>` to get additional information."
+ description: "Which images does this issue happen in? (for example: `serversideup/php:8.4-cli`, `serversideup/php:8.4-fpm`, etc). You can also run Run `docker inspect --format='{{json .Config.Labels}}' <>` to get additional information."
validations:
required: true
- type: textarea
diff --git a/.github/workflows/service_docker-build-and-publish.yml b/.github/workflows/service_docker-build-and-publish.yml
index 4de7de2e8..fb8619ef0 100644
--- a/.github/workflows/service_docker-build-and-publish.yml
+++ b/.github/workflows/service_docker-build-and-publish.yml
@@ -174,6 +174,7 @@ jobs:
PHP_VERSION=${{ matrix.patch_version }}
PHP_VARIATION=${{ matrix.php_variation }}
REPOSITORY_BUILD_VERSION=${{ env.REPOSITORY_BUILD_VERSION }}
+ PHP_EXTENSION_OVERRIDES=${{ matrix.php_extension_overrides }}
${{ steps.compute_nginx.outputs.nginx_arg }}
platforms: |
linux/amd64
diff --git a/README.md b/README.md
index 01eaf5746..bcf475266 100644
--- a/README.md
+++ b/README.md
@@ -97,17 +97,17 @@ serversideup/php:{{version}}-{{variation-name}}
> [!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 7.4 through 8.5** with both **Debian** and **Alpine** base images.
+We support **PHP 8.2 through 8.5** with both **Debian** and **Alpine** base images.
[Learn More About Choosing an Image â](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image)
| âī¸ Variation | đ Version |
| ------------ | ---------- |
-| cli | **Debian Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-cli&page=1&ordering=-name)
**Alpine Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-cli-alpine&page=1&ordering=-name) |
-| fpm | **Debian Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-fpm&page=1&ordering=-name)
**Alpine Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-fpm-alpine&page=1&ordering=-name) |
-| 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)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-apache&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-apache&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-nginx&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-nginx&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-nginx-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-nginx-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=7.4-fpm-nginx-alpine&page=1&ordering=-name) |
+| cli | **Debian Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-cli&page=1&ordering=-name)
**Alpine Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-cli-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-cli&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-cli-alpine&page=1&ordering=-name) |
+| fpm | **Debian Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm&page=1&ordering=-name)
**Alpine Based**
[](https://hub.docker.com/r/serversideup/php/tags?name=8.5-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.3-fpm-alpine&page=1&ordering=-name)
[](https://hub.docker.com/r/serversideup/php/tags?name=8.2-fpm-alpine&page=1&ordering=-name) |
+| 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) |
diff --git a/SECURITY.md b/SECURITY.md
index 73628be17..336188efc 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -49,7 +49,21 @@ Our images bundle third-party software, each with its own support window. Before
| Apache HTTP Server | [endoflife.date/apache](https://endoflife.date/apache) |
| Composer | [endoflife.date/composer](https://endoflife.date/composer) |
-We continue to publish images for end-of-life PHP versions and operating system bases so legacy applications have a path into containers â but those bases will not receive new upstream security fixes. Use them 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.
+We publish images for end-of-life PHP versions and operating system bases so legacy applications have a path into containers â but only for as long as the base OS still serves a signed package repository. Once a distribution stops signing its repositories, we can no longer build a patched image at all, so we stop rebuilding that base and its existing tags freeze at their last successful build. 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.
+
+## EOL versions and the legacy-modernization path
+
+The following images are no longer built. Their existing tags remain pullable on Docker Hub and GitHub Packages, but they are frozen at their last successful build and will receive no further security updates â not for PHP, and not for the operating system underneath them.
+
+| Image | Last built | Why it stopped |
+| --- | --- | --- |
+| PHP 7.4 (all variations) | 2026-09-03 | Debian 11 reached end of LTS on 2026-08-31 and Alpine 3.16 is long EOL. These were the only bases the official `php:7.4` images ever shipped, and upstream stopped building them in November 2022. |
+| PHP 8.0 (all variations) | 2026-09-03 | Same as above. Upstream stopped building `php:8.0` in November 2023. |
+| PHP 8.1 (all variations) | 2025-12-16 | PHP 8.1 reached end of security support and upstream removed the `8.1` branch, so there is no base image left to rebuild from. |
+| Anything on Debian Bullseye | 2026-09-03 | Debian 11's final `bullseye-security` release file expired on 2026-09-07. `apt-get update` now fails inside the build, so a patched image cannot be produced. |
+| Anything on Alpine 3.16 | 2026-09-03 | Alpine 3.16 is past end of support and was only ever used by PHP 7.4 and 8.0. |
+
+If you are running one of these images, treat it as a migration deadline rather than a stable base. Move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. The [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide) covers moving between our releases, and [Choosing an image](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image) covers picking a supported base.
## How updates flow
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 3cbb70288..fde82b69a 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
@@ -35,10 +35,10 @@ If you don't specify a variation, it defaults to `cli` and the latest supported
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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-cli&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-cli&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-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) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.1-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=8.0-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto :zoom=false}](https://hub.docker.com/r/serversideup/php/tags?name=7.4-fpm-nginx-alpine&page=1&ordering=-name) |
+| 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/) |
@@ -56,11 +56,15 @@ Here's what each part means:
|--------|---------|---------------|
| `{{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`
`8.0`
`7.4`
(you can also specify the full version number, ie. `8.4.1`) |
+| `{{php-version}}`
The version of PHP to use. | Latest stable PHP minor version (ie. `8.5`) | `8.4`
`8.3`
`8.2`
(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`
`bullseye`
`bookworm`
`trixie` |
+| `{{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, 8.0, and 8.1 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.
+::
+
::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.
::
@@ -132,7 +136,9 @@ Choosing an operating system comes down to a few preferences, but ultimately you
| `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 continue to publish images on end-of-life operating system releases (like Debian Bullseye and Alpine 3.16) so legacy applications can be containerized as a first step. 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.
+We publish images on an end-of-life operating system release only for as long as that distribution still serves a signed package repository. Once it stops, we can no longer build a patched image at all, so we stop rebuilding that base and its tags freeze where they are. That is what happened to Debian Bullseye and Alpine 3.16.
+
+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.
Before picking a base, confirm it's currently supported:
@@ -150,11 +156,6 @@ We also support specific versions of operating systems. So this means that you c
- `bookworm`
- `trixie`
-- `alpine3.16`
-- `alpine3.17`
-- `alpine3.18`
-- `alpine3.19`
-- `alpine3.20`
-- `alpine3.21`
-- `alpine3.22`
+- `alpine3.23`
+- `alpine3.24`
diff --git a/docs/content/docs/1.getting-started/7.upgrade-guide.md b/docs/content/docs/1.getting-started/7.upgrade-guide.md
index 1a50e7d53..7172b4537 100644
--- a/docs/content/docs/1.getting-started/7.upgrade-guide.md
+++ b/docs/content/docs/1.getting-started/7.upgrade-guide.md
@@ -120,7 +120,7 @@ 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 `bullseye` to `bookworm`) 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.
+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
diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md
index f45da79ba..3777eb298 100644
--- a/docs/content/docs/1.getting-started/99.contributing.md
+++ b/docs/content/docs/1.getting-started/99.contributing.md
@@ -39,10 +39,10 @@ We also have beta images that are published to our Docker Hub and GitHub Package
| âī¸ 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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-cli&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-cli&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-cli-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-fpm&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-fpm&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-fpm-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-fpm-apache&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-fpm-nginx&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-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) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.1-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-8.0-fpm-nginx-alpine&page=1&ordering=-name) [{.h-5.w-auto}](https://hub.docker.com/r/serversideup/php/tags?name=beta-7.4-fpm-nginx-alpine&page=1&ordering=-name) |
+| 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) |
#### Running a test web server:
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
index 06ef3fec2..ebc18aab8 100644
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ b/docs/content/docs/5.guide/5.major-version-migrations.md
@@ -9,6 +9,20 @@ title: Major version migrations
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 stop building a PHP version once there is no longer a base image we can produce a patched build from. The tags stay pullable, but they freeze at their last successful build and receive no further security updates.
+
+| Version | Last built | Why |
+| --- | --- | --- |
+| PHP 8.1 | 2025-12-16 | Reached end of security support and upstream removed the `8.1` branch. |
+| PHP 8.0 | 2026-09-03 | Only ever shipped on Debian Bullseye and Alpine 3.16, both now EOL. 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 reached end of LTS on 2026-08-31, and its final `bullseye-security` release file expired on 2026-09-07, so `apt-get update` no longer succeeds inside a build.
+
+If you are on one of these, move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. 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).
+
## 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.
diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md
index 02263e4db..f786126ef 100644
--- a/docs/content/docs/8.reference/2.command-reference.md
+++ b/docs/content/docs/8.reference/2.command-reference.md
@@ -35,6 +35,16 @@ docker-php-serversideup-dep-install-debian git
## 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.
diff --git a/scripts/assemble-docker-tags.sh b/scripts/assemble-docker-tags.sh
index 2894724fe..9fe469bc2 100755
--- a/scripts/assemble-docker-tags.sh
+++ b/scripts/assemble-docker-tags.sh
@@ -224,8 +224,8 @@ help_menu() {
echo
echo "Options:"
echo " --variation Set the PHP variation (e.g., apache, fpm)"
- echo " --os Set the base OS (e.g., bullseye, bookworm, alpine)"
- echo " --patch-version Set the PHP patch version (e.g., 7.4.10)"
+ echo " --os Set the base OS (e.g., bookworm, trixie, alpine)"
+ echo " --patch-version Set the PHP patch version (e.g., 8.4.25)"
echo " --github-release-tag Set the GitHub release tag"
echo " --stable-release Flag the tags for a stable release"
echo
diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml
index 0f2559dcb..6d0c046a6 100644
--- a/scripts/conf/php-versions-base-config.yml
+++ b/scripts/conf/php-versions-base-config.yml
@@ -4,42 +4,16 @@ php_variations:
- name: fpm
- name: fpm-apache
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)
- - bullseye
- bookworm
- trixie
- name: fpm-nginx
- name: frankenphp
excluded_minor_versions:
- - "7.4"
- - "8.0"
- - "8.1"
- "8.2"
php_versions:
- - major: "7"
- minor_versions:
- - minor: "7.4"
- base_os:
- - name: alpine3.16
- - name: bullseye
- patch_versions:
- - 7.4.33
- major: "8"
minor_versions:
- - minor: "8.0"
- base_os:
- - name: alpine3.16
- - name: bullseye
- patch_versions:
- - 8.0.30
- - minor: "8.1"
- base_os:
- - name: alpine3.21
- - name: alpine3.22
- - name: bookworm
- - name: trixie
- patch_versions:
- # - 8.1.34 # Pull latest from Official PHP source
- minor: "8.2"
base_os:
- name: alpine3.23
@@ -72,38 +46,25 @@ 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
operating_systems:
- family: alpine
versions:
- - name: "Alpine 3.16"
- version: alpine3.16
- number: 3.16
- nginx_version: 1.26.1-r2
- - name: "Alpine 3.17"
- version: alpine3.17
- number: 3.17
- nginx_version: 1.26.2-r1
- - name: "Alpine 3.18"
- version: alpine3.18
- number: 3.18
- nginx_version: 1.28.0-r1
- - name: "Alpine 3.19"
- version: alpine3.19
- number: 3.19
- nginx_version: 1.28.0-r1
- - name: "Alpine 3.20"
- version: alpine3.20
- number: 3.20
- nginx_version: 1.28.3-r1
- - name: "Alpine 3.21"
- version: alpine3.21
- number: 3.21
- nginx_version: 1.30.4-r1
- - name: "Alpine 3.22"
- version: alpine3.22
- number: 3.22
- nginx_version: 1.30.4-r1
- name: "Alpine 3.23"
version: alpine3.23
number: 3.23
@@ -115,10 +76,6 @@ operating_systems:
- family: debian
default: true
versions:
- - name: "Debian Bullseye"
- version: bullseye
- number: 11
- nginx_version: 1.30.4-1~bullseye
- name: "Debian Bookworm"
version: bookworm
number: 12
diff --git a/scripts/dev.sh b/scripts/dev.sh
index f8b207540..dab2fa920 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -86,6 +86,14 @@ detect_platform() {
esac
}
+php_minor_version() {
+ if [[ "$1" == *-rc ]]; then
+ echo "$1"
+ else
+ echo "$1" | cut -d. -f1,2
+ fi
+}
+
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"
@@ -105,6 +113,10 @@ build_docker_image() {
build_args+=(--build-arg "NGINX_VERSION=$NGINX_VERSION")
fi
+ if [ -n "$PHP_EXTENSION_OVERRIDES" ]; then
+ build_args+=(--build-arg "PHP_EXTENSION_OVERRIDES=$PHP_EXTENSION_OVERRIDES")
+ fi
+
docker buildx build \
"${DOCKER_ADDITIONAL_BUILD_ARGS[@]}" \
--platform "$PLATFORM" \
@@ -131,8 +143,8 @@ help_menu() {
echo
echo "Options:"
echo " --variation Set the PHP variation (e.g., apache, fpm)"
- echo " --version Set the PHP version (e.g., 7.4, 8.0)"
- echo " --os Set the base OS (e.g., bullseye, bookworm, alpine)"
+ echo " --version Set the PHP version (e.g., 8.4, 8.5)"
+ echo " --os Set the base OS (e.g., bookworm, trixie, alpine)"
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)"
@@ -196,6 +208,17 @@ check_vars \
PHP_BUILD_VERSION \
PHP_BUILD_BASE_OS
+# Auto-resolve PHP extension source overrides for the minor version being built
+if [ -z "$PHP_EXTENSION_OVERRIDES" ]; then
+ PHP_EXTENSION_OVERRIDES=$(MINOR="$(php_minor_version "$PHP_BUILD_VERSION")" yq -r '
+ [.php_versions[].minor_versions[] | select(.minor == env(MINOR)) | .php_extension_overrides // [] | .[]] | join(" ")
+ ' "$BASE_PHP_VERSIONS_CONFIG_FILE")
+
+ if [ -n "$PHP_EXTENSION_OVERRIDES" ]; then
+ echo_color_message green "â
Using PHP extension overrides '$PHP_EXTENSION_OVERRIDES' for PHP '$PHP_BUILD_VERSION'"
+ fi
+fi
+
# Auto-resolve NGINX version for fpm-nginx if not provided
if [ -z "$NGINX_VERSION" ] && [ "$PHP_BUILD_VARIATION" = "fpm-nginx" ]; then
if ! command -v yq >/dev/null 2>&1; then
diff --git a/scripts/generate-matrix.sh b/scripts/generate-matrix.sh
index 20a17b4ea..3b058e7bd 100755
--- a/scripts/generate-matrix.sh
+++ b/scripts/generate-matrix.sh
@@ -27,7 +27,7 @@ yq -o=json "$PHP_VERSIONS_FILE" | jq -c '
def os_family_match($os_name; $supported):
# Allow listing "alpine" to include any alpine3.xx base_os
- # Exact matches like "bullseye", "bookworm", "trixie" must match exactly
+ # Exact matches like "bookworm", "trixie" must match exactly
($supported == $os_name) or ($supported == "alpine" and ($os_name | startswith("alpine")));
def is_supported($variation; $os):
@@ -40,10 +40,10 @@ yq -o=json "$PHP_VERSIONS_FILE" | jq -c '
| $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
+ | ($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}
+ | {patch_version: $patch, base_os: $os.name, php_variation: $variation.name, php_extension_overrides: (($minor.php_extension_overrides // []) | join(" "))}
]
| { 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
index 80ec5a76b..eeeccae31 100755
--- a/scripts/get-nginx-versions.sh
+++ b/scripts/get-nginx-versions.sh
@@ -26,7 +26,7 @@ os_config() {
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.20)
+ # 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/"
@@ -59,7 +59,7 @@ help_menu() {
echo
echo "Examples:"
echo " $0 # Show all operating systems"
- echo " $0 --os alpine3.20 # Show only Alpine 3.20"
+ echo " $0 --os alpine3.24 # Show only Alpine 3.24"
echo " $0 --os bookworm # Show only Debian Bookworm"
}
@@ -175,7 +175,7 @@ compute_nginx_version() {
}
update_config_nginx_version() {
- local version_key="$1" # e.g., alpine3.20 or bookworm
+ 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
diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh
index 35d69ff5e..2a08e72d4 100755
--- a/scripts/get-php-versions.sh
+++ b/scripts/get-php-versions.sh
@@ -41,7 +41,7 @@ check_dockerhub_php_version() {
local os="${3:-}"
local image_tag
- if [ -n "$os" ] && [ "$os" != "bullseye" ] && [ "$os" != "bookworm" ]; then
+ if [ -n "$os" ] && [ "$os" != "bookworm" ]; then
image_tag="${version}-${variant}-${os}"
else
image_tag="${version}-${variant}"
@@ -396,7 +396,8 @@ if [ "$SKIP_DOWNLOAD" = false ]; then
| map({
minor: .[0].minor,
base_os: (map(.base_os // []) | add),
- patch_versions: (map(.patch_versions // []) | flatten | unique | select(. != null))
+ patch_versions: (map(.patch_versions // []) | flatten | unique | select(. != null)),
+ php_extension_overrides: (map(.php_extension_overrides // []) | add | unique)
})
)
})
diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions b/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions
new file mode 100644
index 000000000..b27199ba2
--- /dev/null
+++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-extensions
@@ -0,0 +1,46 @@
+#!/bin/sh
+set -oe
+
+###################################################
+# Usage: docker-php-serversideup-install-php-extensions [extensions] [overrides]
+###################################################
+# This script installs PHP extensions with "install-php-extensions", optionally
+# swapping an extension for a different source. Overrides are space separated
+# "name=source" pairs, where source is any value install-php-extensions accepts
+# (for example "redis=phpredis/phpredis@develop").
+
+script_name="docker-php-serversideup-install-php-extensions"
+
+extensions="${1:-}"
+overrides="${2:-}"
+
+if [ -z "$extensions" ]; then
+ echo "âšī¸ INFO ($script_name): No extensions were passed. Continuing..."
+ exit 0
+fi
+
+############
+# Functions
+############
+resolve_extension_source() {
+ resolved_extension="$1"
+ for override in $overrides; do
+ if [ "${override%%=*}" = "$1" ]; then
+ resolved_extension="${override#*=}"
+ fi
+ done
+ echo "$resolved_extension"
+}
+
+############
+# Main
+############
+resolved_extensions=""
+for extension in $extensions; do
+ resolved_extensions="$resolved_extensions $(resolve_extension_source "$extension")"
+done
+
+echo "đ¤ Installing PHP extensions:$resolved_extensions"
+install-php-extensions $resolved_extensions
+
+echo "âĄī¸ Completed installation of PHP extensions:$resolved_extensions"
diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile
index faadcfff6..b85eb1fb4 100644
--- a/src/variations/cli/Dockerfile
+++ b/src/variations/cli/Dockerfile
@@ -10,6 +10,7 @@ FROM php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION}
ARG DEPENDENCY_PACKAGES_ALPINE='shadow'
ARG DEPENDENCY_PACKAGES_DEBIAN='procps zip'
ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip'
+ARG PHP_EXTENSION_OVERRIDES=''
ARG REPOSITORY_BUILD_VERSION='dev'
LABEL org.opencontainers.image.title="serversideup/php (cli)" \
@@ -79,7 +80,7 @@ RUN docker-php-serversideup-dep-install-alpine "${DEPENDENCY_PACKAGES_ALPINE}" &
chown -R www-data:www-data "${COMPOSER_HOME}" && \
\
# Install default PHP extensions
- install-php-extensions ${DEPENDENCY_PHP_EXTENSIONS}
+ docker-php-serversideup-install-php-extensions "${DEPENDENCY_PHP_EXTENSIONS}" "${PHP_EXTENSION_OVERRIDES}"
# install composer from Composer's official Docker image
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile
index 7c1c5e364..c84e83745 100644
--- a/src/variations/fpm-apache/Dockerfile
+++ b/src/variations/fpm-apache/Dockerfile
@@ -25,6 +25,7 @@ FROM ${BASE_IMAGE}
ARG DEPENDENCY_PACKAGES_ALPINE='fcgi apache2 shadow'
ARG DEPENDENCY_PACKAGES_DEBIAN='libfcgi-bin apache2 procps zip'
ARG DEPENDENCY_PHP_EXTENSIONS='mysqli opcache pcntl pdo_mysql pdo_pgsql redis zip'
+ARG PHP_EXTENSION_OVERRIDES=''
ARG REPOSITORY_BUILD_VERSION='dev'
LABEL org.opencontainers.image.title="serversideup/php (fpm-apache)" \
@@ -131,7 +132,7 @@ RUN docker-php-serversideup-dep-install-debian "${DEPENDENCY_PACKAGES_DEBIAN}"
chown -R www-data:www-data "${COMPOSER_HOME}" && \
\
# Install default PHP extensions
- install-php-extensions ${DEPENDENCY_PHP_EXTENSIONS} && \
+ docker-php-serversideup-install-php-extensions "${DEPENDENCY_PHP_EXTENSIONS}" "${PHP_EXTENSION_OVERRIDES}" && \
\
# redirect logs to STDOUT and STERR
ln -sf /dev/stdout /var/log/apache2/access.log && \
diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile
index 83a4d944a..3c142856b 100644
--- a/src/variations/fpm-nginx/Dockerfile
+++ b/src/variations/fpm-nginx/Dockerfile
@@ -100,6 +100,7 @@ FROM ${BASE_IMAGE}
ARG DEPENDENCY_PACKAGES_ALPINE='fcgi gettext shadow'
ARG DEPENDENCY_PACKAGES_DEBIAN='libfcgi-bin gettext-base procps zip'
ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip'
+ARG PHP_EXTENSION_OVERRIDES=''
ARG REPOSITORY_BUILD_VERSION='dev'
ARG NGINX_VERSION='1.28.0-1'
@@ -217,7 +218,7 @@ RUN docker-php-serversideup-dep-install-alpine "${DEPENDENCY_PACKAGES_ALPINE} ng
chown -R www-data:www-data /var/cache/nginx/ && \
\
# Install default PHP extensions
- install-php-extensions ${DEPENDENCY_PHP_EXTENSIONS} && \
+ docker-php-serversideup-install-php-extensions "${DEPENDENCY_PHP_EXTENSIONS}" "${PHP_EXTENSION_OVERRIDES}" && \
\
# clear all php provided fpm configurations
rm -rf /usr/local/etc/php-fpm.d/*.conf && \
diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile
index 2d39f76f6..5dc1a51dc 100644
--- a/src/variations/fpm/Dockerfile
+++ b/src/variations/fpm/Dockerfile
@@ -10,6 +10,7 @@ FROM php:${PHP_VERSION}-${PHP_VARIATION}-${BASE_OS_VERSION}
ARG DEPENDENCY_PACKAGES_ALPINE='fcgi shadow'
ARG DEPENDENCY_PACKAGES_DEBIAN='libfcgi-bin procps zip'
ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip'
+ARG PHP_EXTENSION_OVERRIDES=''
ARG REPOSITORY_BUILD_VERSION='dev'
LABEL org.opencontainers.image.title="serversideup/php (fpm)" \
@@ -94,7 +95,7 @@ RUN rm -rf /usr/local/etc/php-fpm.d/*.conf && \
chmod +x /usr/local/bin/php-fpm-healthcheck && \
\
# Install default PHP extensions
- install-php-extensions ${DEPENDENCY_PHP_EXTENSIONS}
+ docker-php-serversideup-install-php-extensions "${DEPENDENCY_PHP_EXTENSIONS}" "${PHP_EXTENSION_OVERRIDES}"
# copy our fpm configurations
COPY src/php-fpm.d/ /
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index c85285712..dd0d0f342 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -114,6 +114,7 @@ FROM common AS final
ARG DEPENDENCY_PACKAGES_ALPINE='shadow libstdc++'
ARG DEPENDENCY_PACKAGES_DEBIAN='procps libstdc++6 zip'
ARG DEPENDENCY_PHP_EXTENSIONS='opcache pcntl pdo_mysql pdo_pgsql redis zip'
+ARG PHP_EXTENSION_OVERRIDES=''
ARG REPOSITORY_BUILD_VERSION='dev'
LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
@@ -214,7 +215,7 @@ RUN \
# Install PHP Extension installer
docker-php-serversideup-install-php-ext-installer; \
# Install default PHP extensions
- install-php-extensions ${DEPENDENCY_PHP_EXTENSIONS}; \
+ docker-php-serversideup-install-php-extensions "${DEPENDENCY_PHP_EXTENSIONS}" "${PHP_EXTENSION_OVERRIDES}"; \
# Ensure permissions are set for www-data
docker-php-serversideup-set-file-permissions --owner www-data:www-data --service frankenphp
From 33352f93c10e81b3de75abaa7945f812e545cb87 Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Thu, 10 Sep 2026 10:07:38 -0500
Subject: [PATCH 26/47] Improve CI builds (#700)
* Enhance CI/CD workflows and Docker image publishing
- Update Docker workflows to improve build and publish processes for production, beta, and PR images.
- Introduce a new setup matrix workflow for better PHP version management.
- Implement auto-retry for failed image builds to handle transient errors.
- Add support for additional environment variables in the Docker build process.
- Refactor PHP version validation to ensure all necessary base images are available on DockerHub.
- Update documentation to reflect changes in CI/CD processes and workflows.
* Fix formatting of run-name in Docker Publish workflow
* Add actionlint workflow and update documentation for workflow linting
* Enhance auto-retry workflow for failed builds and update documentation on transient failures
* Increase MAX_FAILED_JOBS limit from 5 to 10 for better handling of transient failures
* Implement docker-php-serversideup-download for reliable file downloads during builds
* Refactor CI workflows: consolidate linting into service_lint.yml and update documentation for improved clarity
* Fix web images failing to start with s6-overlay 3.2.3
s6-overlay 3.2.3 moved its user bundle to /etc/s6-overlay/user-bundles.d
and no longer ships s6-rc.d/user/type. With our services still registered
under the old s6-rc.d/user/contents.d path, rc.init tried to create that
type file at startup, failed as the unprivileged www-data user, and nginx
or apache never started, leaving the container unhealthy forever.
Register php-fpm, nginx, and apache2 in user-bundles.d/user/contents.d
instead. Verified with local fpm-nginx and fpm-apache builds on bookworm:
both services start and the HEALTHCHECK reports healthy.
* Lint Dockerfiles with hadolint
Add a hadolint job to the lint group so Dockerfile mistakes are caught
before any image builds. Rules that conflict with deliberate project
decisions (unpinned apt/apk packages for weekly security rebuilds,
shell-form CMD, named USER, and so on) are ignored in .hadolint.yaml
with the reason recorded next to each one.
Fix the two findings it raised in the fpm-nginx Dockerfile: the Alpine
repository printf had five placeholders but four arguments, and egrep
is replaced with grep -E.
* Summarize every run in one table with image sizes
Replace the per-job summary cards (68 lists of pull commands) with a
single table for the whole run. Each build job now records its canonical
tag and compressed size per architecture, read from the registry
manifest, and uploads it as a small artifact. A new service_summary.yml
merges those files with scripts/build-summary.sh into one table grouped
by variation, listing images that failed to build as well.
Before building, each job looks up the same tag on serversideup/php so
the table shows how much every image grew compared to what users pull
today. Growth over 10% is flagged.
The setup card shrinks to the PHP patch versions resolved from php.net,
which is the one thing only that job knows.
* Add smoke tests for published images
Nothing ran the images after they were built, so a broken entrypoint
would only be noticed by users. scripts/smoke-test.sh runs an image and
checks what a user would hit first: PHP reports the expected version,
the container runs as an unprivileged user, and images with a
HEALTHCHECK become healthy. On failure it prints the container logs.
service_smoke-test.yml selects the newest PHP version of every variation
on one Debian and one Alpine base from the run's image details and tests
each on native amd64 and arm64 GitHub runners. Images that were not
pushed (pull requests from forks) are skipped.
The script works against any image reference, so it doubles as a local
check before opening a PR that touches startup behavior. It caught the
s6-overlay user bundle regression fixed earlier in this branch.
* Run smoke tests and the summary table on every publish
Add smoke and summary jobs to the PR, production, and beta workflows.
Both run whenever the matrix was generated, even if some builds failed,
so a red run still shows which images are missing and whether the rest
work. The PR comment now reports the smoke test result and carries the
full image table, collapsed, next to the pull commands.
* Enhance documentation on CI processes, including linting and smoke testing for Docker images
* Fix jq command in smoke test workflow to ensure proper JSON parsing
* Update PR comment formatting to include build status headline
* Refactor CI workflows for image publishing and testing
- Split the image build and publish processes into separate reusable workflows: `service_build-images.yml` and `service_publish-images.yml`.
- Enhance the smoke test workflow to pull images from the Depot Registry and validate them against expected behaviors.
- Introduce a new script `select-smoke-images.sh` to select representative images for smoke testing based on the latest PHP versions and variations.
- Update the summary generation script to reflect changes in image publishing and size reporting.
- Add tests for CI helper scripts to ensure consistent behavior across environments.
- Improve documentation to clarify the CI/CD process and the role of each workflow in the image publishing pipeline.
* Enhance CI workflows with concurrency control, improved release tagging, and refined smoke test processes
* Refactor CI workflows to unify job naming and output references for image selection and publishing
* Refactor CI workflows to replace smoke tests with image tests, enhancing reliability and clarity in the publishing process
* Refactor CI workflows to improve job naming for clarity and consistency across image publishing and testing processes
* Enhance CI/CD workflows and image testing
- Updated service_build-images.yml to handle image loading and testing for pull requests from forks, ensuring that amd64 images are tested directly on the runner.
- Modified service_publish-images.yml to improve image promotion logic, including digest verification and enhanced error handling for failed image promotions.
- Improved service_test-images.yml to group saved images by PHP version and base OS, allowing for more efficient testing and clearer reporting of results.
- Enhanced trigger_auto-retry-failed-builds.yml to better manage transient failures, including automatic issue creation for persistent failures.
- Updated AGENTS.md and contributing documentation to reflect changes in the CI/CD process and image testing.
- Refined scripts/build-summary.sh to prioritize published image records and improve error visibility for failed builds.
- Enhanced scripts/test-image.sh to check for loaded PHP extensions and validate web server responses for images that include a web server.
- Adjusted scripts/tests/run.sh to reflect changes in image publishing status and ensure accurate testing of image details.
* Refactor image testing logic to improve failure handling and remove redundant result reporting
* Refactor image publishing and testing workflows to enhance logging and error handling
---
.dockerignore | 3 +-
.github/actionlint.yaml | 11 ++
.github/dependabot.yml | 8 +
.../workflows/action_publish-images-beta.yml | 99 ++++++++--
.../action_publish-images-dev-main.yml | 14 --
.../action_publish-images-production.yml | 97 +++++++++-
.../workflows/action_publish-images-prs.yml | 178 ++++++++++++++++-
...d-publish.yml => service_build-images.yml} | 164 ++++++++--------
.github/workflows/service_lint.yml | 72 +++++++
.github/workflows/service_publish-images.yml | 182 ++++++++++++++++++
.github/workflows/service_report.yml | 54 ++++++
.github/workflows/service_setup-matrix.yml | 84 ++++++++
.github/workflows/service_test-images.yml | 99 ++++++++++
.../trigger_auto-retry-failed-builds.yml | 151 +++++++++++++++
.hadolint.yaml | 11 ++
AGENTS.md | 15 +-
depot.json | 1 +
.../docs/1.getting-started/99.contributing.md | 30 ++-
.../docs/8.reference/2.command-reference.md | 11 ++
scripts/build-summary.sh | 48 +++++
scripts/dev.sh | 3 +-
scripts/get-nginx-versions.sh | 6 +-
scripts/get-php-versions.sh | 36 +++-
scripts/test-image.sh | 122 ++++++++++++
scripts/tests/run.sh | 63 ++++++
...docker-php-serversideup-dep-install-debian | 6 +-
.../bin/docker-php-serversideup-download | 23 +++
.../bin/docker-php-serversideup-entrypoint | 1 +
...php-serversideup-install-php-ext-installer | 5 +-
.../user/contents.d/php-fpm | 0
.../bin/docker-php-serversideup-s6-install | 10 +-
.../user/contents.d/apache2 | 0
src/variations/fpm-nginx/Dockerfile | 8 +-
.../user/contents.d/nginx | 0
src/variations/fpm/Dockerfile | 2 +-
src/variations/frankenphp/Dockerfile | 15 +-
36 files changed, 1477 insertions(+), 155 deletions(-)
create mode 100644 .github/actionlint.yaml
delete mode 100644 .github/workflows/action_publish-images-dev-main.yml
rename .github/workflows/{service_docker-build-and-publish.yml => service_build-images.yml} (51%)
create mode 100644 .github/workflows/service_lint.yml
create mode 100644 .github/workflows/service_publish-images.yml
create mode 100644 .github/workflows/service_report.yml
create mode 100644 .github/workflows/service_setup-matrix.yml
create mode 100644 .github/workflows/service_test-images.yml
create mode 100644 .github/workflows/trigger_auto-retry-failed-builds.yml
create mode 100644 .hadolint.yaml
create mode 100644 depot.json
create mode 100755 scripts/build-summary.sh
create mode 100755 scripts/test-image.sh
create mode 100755 scripts/tests/run.sh
create mode 100755 src/common/usr/local/bin/docker-php-serversideup-download
rename src/s6/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/php-fpm (100%)
rename src/variations/fpm-apache/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/apache2 (100%)
rename src/variations/fpm-nginx/etc/s6-overlay/{s6-rc.d => user-bundles.d}/user/contents.d/nginx (100%)
diff --git a/.dockerignore b/.dockerignore
index 6a3e68da1..52aba3075 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1 +1,2 @@
-**/.DS_Store
\ No newline at end of file
+**/.DS_Store
+depot.json
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 000000000..48260dcbe
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,11 @@
+# Runner labels that are not GitHub-hosted, so actionlint recognizes them.
+# https://depot.dev/docs/github-actions/runner-types
+self-hosted-runner:
+ labels:
+ - depot-ubuntu-24.04
+ - depot-ubuntu-24.04-4
+ - depot-ubuntu-24.04-8
+ - depot-ubuntu-24.04-16
+ - depot-ubuntu-24.04-arm
+ - depot-ubuntu-24.04-arm-4
+ - depot-ubuntu-24.04-arm-8
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 678290ef4..eab96d2ea 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -6,3 +6,11 @@ updates:
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_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml
index 5a22af960..6b72d4db1 100644
--- a/.github/workflows/action_publish-images-beta.yml
+++ b/.github/workflows/action_publish-images-beta.yml
@@ -1,4 +1,10 @@
name: Docker Publish (Beta Images)
+run-name: >-
+ Beta images (${{
+ github.event.release.tag_name ||
+ (github.event_name == 'schedule' && 'weekly rebuild') ||
+ 'manual rebuild'
+ }})
on:
workflow_dispatch:
@@ -7,24 +13,95 @@ on:
schedule:
- cron: '25 8 * * 1'
+# A release event and the weekly rebuild must not publish the same tags at the same time.
+concurrency:
+ group: beta-images
+
+permissions: {}
+
jobs:
- get-latest-beta-release:
+ release:
+ name: resolve beta release
runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
outputs:
- release_tag: ${{ steps.get_latest_beta.outputs.release_tag }}
+ tag: ${{ steps.release.outputs.tag }}
steps:
- - name: Get Latest Beta Release
- id: get_latest_beta
+ # A prerelease event builds the prerelease that fired it. Scheduled and manual runs rebuild the latest prerelease.
+ - name: Find the beta release to build
+ id: release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
- LATEST_BETA=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases | jq -r '[.[] | select(.prerelease == true)][0].tag_name')
- echo "release_tag=${LATEST_BETA}" >> $GITHUB_OUTPUT
+ TAG="${RELEASE_TAG:-$(gh api "repos/${GH_REPO}/releases?per_page=30" --jq '[.[] | select(.prerelease)][0].tag_name')}"
+ if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
+ echo "::error title=No beta release found::Could not find a prerelease to build."
+ exit 1
+ fi
+ echo "Building beta release $TAG"
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- build-beta-images:
- needs: get-latest-beta-release
- uses: ./.github/workflows/service_docker-build-and-publish.yml
+ setup:
+ needs: release
+ uses: ./.github/workflows/service_setup-matrix.yml
+ permissions:
+ contents: read
with:
+ ref: ${{ needs.release.outputs.tag }}
+
+ build:
+ needs: [release, setup]
+ name: build ${{ matrix.variation }}
+ strategy:
+ fail-fast: false
+ matrix:
+ variation: ${{ fromJson(needs.setup.outputs.variations) }}
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/service_build-images.yml
+ with:
+ variation: ${{ matrix.variation }}
+ matrix: ${{ needs.setup.outputs.matrix }}
+ ref: ${{ needs.release.outputs.tag }}
registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php"
tag-prefix: "beta"
release-type: "testing"
- ref: ${{ needs.get-latest-beta-release.outputs.release_tag }}
- secrets: inherit
\ No newline at end of file
+ secrets: inherit
+
+ test:
+ needs: [release, setup, build]
+ if: always() && needs.setup.result == 'success'
+ uses: ./.github/workflows/service_test-images.yml
+ permissions:
+ contents: read
+ id-token: write
+ with:
+ ref: ${{ needs.release.outputs.tag }}
+
+ # Only images that passed the image tests reach a public registry.
+ publish:
+ needs: [release, build, test]
+ if: needs.build.result == 'success' && needs.test.result == 'success'
+ uses: ./.github/workflows/service_publish-images.yml
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ with:
+ ref: ${{ needs.release.outputs.tag }}
+ secrets: inherit
+
+ report:
+ needs: [release, setup, build, test, publish]
+ if: always() && needs.setup.result == 'success'
+ uses: ./.github/workflows/service_report.yml
+ permissions:
+ contents: read
+ with:
+ matrix: ${{ needs.setup.outputs.matrix }}
+ ref: ${{ needs.release.outputs.tag }}
diff --git a/.github/workflows/action_publish-images-dev-main.yml b/.github/workflows/action_publish-images-dev-main.yml
deleted file mode 100644
index 6b0a3f30e..000000000
--- a/.github/workflows/action_publish-images-dev-main.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-name: Docker Publish (Dev "Main" Images)
-
-on:
- workflow_dispatch:
-
-jobs:
- build-dev-images:
- uses: ./.github/workflows/service_docker-build-and-publish.yml
- with:
- registry-repositories: "docker.io/serversideup/php-dev" # Set to our development repository
- tag-prefix: ''
- release-type: latest
- authenticate_with_ghcr: false
- secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml
index e56a19c8c..9b99397ec 100644
--- a/.github/workflows/action_publish-images-production.yml
+++ b/.github/workflows/action_publish-images-production.yml
@@ -1,4 +1,10 @@
name: Docker Publish (Production Images)
+run-name: >-
+ Production images (${{
+ github.event.release.tag_name ||
+ (github.event_name == 'schedule' && 'weekly rebuild') ||
+ 'manual rebuild'
+ }})
on:
workflow_dispatch:
@@ -7,24 +13,95 @@ on:
schedule:
- cron: '0 8 * * 2'
+# A release event and the weekly rebuild must not publish the same tags at the same time.
+concurrency:
+ group: production-images
+
+permissions: {}
+
jobs:
- get-latest-release:
+ release:
+ name: resolve release
runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
outputs:
- release_tag: ${{ steps.get_latest_release.outputs.release_tag }}
+ tag: ${{ steps.release.outputs.tag }}
steps:
- - name: Get Latest Release
- id: get_latest_release
+ # A release event builds the release that fired it. Scheduled and manual runs rebuild the latest release.
+ - name: Find the release to build
+ id: release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
- LATEST_TAG=$(curl -s https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)
- echo "release_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT
+ TAG="${RELEASE_TAG:-$(gh release view --json tagName --jq .tagName)}"
+ if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
+ echo "::error title=No release found::Could not find a published release to build."
+ exit 1
+ fi
+ echo "Building release $TAG"
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- build-production-images:
- needs: get-latest-release
- uses: ./.github/workflows/service_docker-build-and-publish.yml
+ setup:
+ needs: release
+ uses: ./.github/workflows/service_setup-matrix.yml
+ permissions:
+ contents: read
with:
+ ref: ${{ needs.release.outputs.tag }}
+
+ build:
+ needs: [release, setup]
+ name: build ${{ matrix.variation }}
+ strategy:
+ fail-fast: false
+ matrix:
+ variation: ${{ fromJson(needs.setup.outputs.variations) }}
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/service_build-images.yml
+ with:
+ variation: ${{ matrix.variation }}
+ matrix: ${{ needs.setup.outputs.matrix }}
+ ref: ${{ needs.release.outputs.tag }}
registry-repositories: "docker.io/serversideup/php,ghcr.io/serversideup/php"
tag-prefix: ''
release-type: "latest"
- ref: ${{ needs.get-latest-release.outputs.release_tag }}
secrets: inherit
+
+ test:
+ needs: [release, setup, build]
+ if: always() && needs.setup.result == 'success'
+ uses: ./.github/workflows/service_test-images.yml
+ permissions:
+ contents: read
+ id-token: write
+ with:
+ ref: ${{ needs.release.outputs.tag }}
+
+ # Only images that passed the image tests reach a public registry.
+ publish:
+ needs: [release, build, test]
+ if: needs.build.result == 'success' && needs.test.result == 'success'
+ uses: ./.github/workflows/service_publish-images.yml
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ with:
+ ref: ${{ needs.release.outputs.tag }}
+ secrets: inherit
+
+ report:
+ needs: [release, setup, build, test, publish]
+ if: always() && needs.setup.result == 'success'
+ uses: ./.github/workflows/service_report.yml
+ permissions:
+ 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 5cfa47dea..35b5f765e 100644
--- a/.github/workflows/action_publish-images-prs.yml
+++ b/.github/workflows/action_publish-images-prs.yml
@@ -1,32 +1,190 @@
name: Docker Publish (PR Images)
+run-name: "${{ github.event.pull_request.title || format('Manual build for PR #{0}', inputs.pr_number) }}"
on:
workflow_dispatch:
inputs:
pr_number:
- description: 'PR number to build (leave empty for manual branch build)'
- required: false
+ description: 'PR number to build and publish to serversideup/php-dev (use this for pull requests from forks)'
+ required: true
type: string
pull_request:
types: [opened, synchronize, reopened]
paths:
- src/**
- .github/workflows/action_publish-images-**
- - .github/workflows/service_docker-**
+ - .github/workflows/service_**
- scripts/**
+ - depot.json
+
+# A new push to the same PR cancels the run that is still building the previous commit.
+concurrency:
+ group: pr-images-${{ github.event.pull_request.number || inputs.pr_number }}
+ cancel-in-progress: true
+
+permissions: {}
jobs:
- build-dev-images:
- uses: ./.github/workflows/service_docker-build-and-publish.yml
+ lint:
+ uses: ./.github/workflows/service_lint.yml
+ permissions:
+ contents: read
+
+ setup:
+ needs: lint
+ uses: ./.github/workflows/service_setup-matrix.yml
+ permissions:
+ contents: read
with:
+ ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
+
+ build:
+ needs: [setup]
+ name: build ${{ matrix.variation }}
+ strategy:
+ fail-fast: false
+ matrix:
+ variation: ${{ fromJson(needs.setup.outputs.variations) }}
+ permissions:
+ contents: read
+ id-token: write
+ uses: ./.github/workflows/service_build-images.yml
+ with:
+ variation: ${{ matrix.variation }}
+ matrix: ${{ needs.setup.outputs.matrix }}
+ ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
registry-repositories: "docker.io/serversideup/php-dev"
- # Use PR number from input if provided, otherwise use the PR event number
tag-prefix: ${{ inputs.pr_number || github.event.pull_request.number }}
release-type: testing
- authenticate_with_ghcr: false
- push-to-registry: >-
- ${{
+ # 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.
+ publish: >-
+ ${{
github.event_name == 'workflow_dispatch' ||
- (github.event_name == 'pull_request' && github.event.pull_request.head.repo.owner.type == 'Organization')
+ (
+ github.event.pull_request.head.repo.full_name == github.repository &&
+ github.actor != 'dependabot[bot]'
+ )
}}
secrets: inherit
+
+ test:
+ needs: [setup, build]
+ if: always() && needs.setup.result == 'success'
+ uses: ./.github/workflows/service_test-images.yml
+ permissions:
+ contents: read
+ id-token: write
+ with:
+ ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
+
+ # Only images that passed the image tests reach a public registry.
+ publish:
+ needs: [build, test]
+ if: needs.build.result == 'success' && needs.test.result == 'success'
+ uses: ./.github/workflows/service_publish-images.yml
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ with:
+ ref: ${{ inputs.pr_number && format('refs/pull/{0}/head', inputs.pr_number) || github.ref }}
+ authenticate_with_ghcr: false
+ secrets: inherit
+
+ report:
+ needs: [setup, build, test, publish]
+ if: always() && needs.setup.result == 'success'
+ uses: ./.github/workflows/service_report.yml
+ permissions:
+ 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]
+ if: >-
+ always() &&
+ (
+ github.event_name == 'workflow_dispatch' ||
+ (
+ github.event.pull_request.head.repo.full_name == github.repository &&
+ github.actor != 'dependabot[bot]'
+ )
+ )
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ pull-requests: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}
+ BUILD_RESULT: ${{ needs.build.result }}
+ TEST_RESULT: ${{ needs.test.result }}
+ PUBLISH_RESULT: ${{ needs.publish.result }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ SUMMARY_MARKDOWN: ${{ needs.report.outputs.markdown }}
+ steps:
+ - name: Post or update the image comment
+ run: |
+ IMAGES_COUNT=$(printf '%s\n' "$SUMMARY_MARKDOWN" | sed -n 's/^## Images: \([0-9]* of [0-9]*\).*/\1/p')
+ case "$BUILD_RESULT" in
+ success) BUILD_CELL="â
${IMAGES_COUNT:-All} images built" ;;
+ failure) BUILD_CELL="â Build failed${IMAGES_COUNT:+ ($IMAGES_COUNT images built)}" ;;
+ *) BUILD_CELL="â ī¸ Build $BUILD_RESULT" ;;
+ esac
+ case "$TEST_RESULT" in
+ success) TEST_CELL="â
Every image passed on amd64 and arm64" ;;
+ failure) TEST_CELL="â Failed" ;;
+ *) TEST_CELL="âī¸ Skipped" ;;
+ esac
+ case "$PUBLISH_RESULT" in
+ success) PUBLISH_CELL="â
Published to serversideup/php-dev" ;;
+ failure) PUBLISH_CELL="â Failed" ;;
+ *) PUBLISH_CELL="âī¸ Not published" ;;
+ esac
+ TABLE=$(printf '%s\n' "$SUMMARY_MARKDOWN" | sed '1{/^## /d}' | sed '1{/^$/d}')
+
+ # The example uses the newest stable PHP minor that has a published fpm-nginx image.
+ # Table columns: | Variation | PHP | Base OS | amd64 | arm64 | Image |
+ EXAMPLE_MINOR=$(printf '%s\n' "$SUMMARY_MARKDOWN" | awk -F' *\\| *' '
+ $2 == "fpm-nginx" && $3 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ && $7 ~ /^`/ { sub(/\.[0-9]+$/, "", $3); print $3; exit }')
+
+ if [ "$PUBLISH_RESULT" = "success" ] && [ -n "$EXAMPLE_MINOR" ]; then
+ PULL_SECTION=$(cat <--\`. [Browse all tags](https://hub.docker.com/r/serversideup/php-dev/tags?name=${PR_NUMBER}-) or [view the run]($RUN_URL).
+ MARKDOWN
+ )
+ else
+ PULL_SECTION="No images were published from this run. [View the run]($RUN_URL) for details."
+ fi
+
+ cat > comment.md <
+ All images with sizes
+
+ ${TABLE}
+
+
+
+ Updated on every push to this PR.
+ MARKDOWN
+
+ gh pr comment "$PR_NUMBER" --edit-last --create-if-none --body-file comment.md
diff --git a/.github/workflows/service_docker-build-and-publish.yml b/.github/workflows/service_build-images.yml
similarity index 51%
rename from .github/workflows/service_docker-build-and-publish.yml
rename to .github/workflows/service_build-images.yml
index fb8619ef0..c4683b481 100644
--- a/.github/workflows/service_docker-build-and-publish.yml
+++ b/.github/workflows/service_build-images.yml
@@ -1,12 +1,22 @@
-name: Build and Publish
+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.
on:
workflow_call:
inputs:
- authenticate_with_ghcr:
- default: true
- type: boolean
- description: 'Whether to authenticate with DockerHub.'
+ variation:
+ required: true
+ type: string
+ description: 'The PHP variation to build (example: cli, fpm, fpm-nginx).'
+ matrix:
+ required: true
+ type: string
+ description: 'Build matrix JSON keyed by variation, as produced by service_setup-matrix.yml.'
tag-prefix:
required: true
type: string
@@ -18,101 +28,51 @@ on:
registry-repositories:
type: string
required: true
- description: 'The Docker registry repositories to push the images to. Separate multiple repositories with a comma (example: docker.io/serversideup/php,ghcr.io/serversideup/php)'
+ description: 'The Docker registry repositories the images will be published to. Separate multiple repositories with a comma (example: docker.io/serversideup/php,ghcr.io/serversideup/php)'
release-type:
type: string
default: 'testing'
description: 'The type of release to create. Options: testing, latest'
- push-to-registry:
+ publish:
type: boolean
default: true
- description: 'Whether to push the images to the registry.'
+ description: 'Save the images to the Depot Registry so they can be tested and published. False for pull requests from forks.'
ref:
type: string
default: ${{ github.ref }}
description: 'The git ref to checkout (branch, tag, or commit SHA)'
jobs:
- setup-matrix:
+ images:
+ name: ${{ matrix.patch_version }}-${{ matrix.base_os }}
runs-on: depot-ubuntu-24.04
- outputs:
- php-version-map-json: ${{ steps.get-php-versions.outputs.php-version-map-json }}
- steps:
- - name: Check out code
- uses: actions/checkout@v6
- with:
- ref: ${{ inputs.ref }}
-
- - name: Prepare PHP versions for the matrix. đ
- run: |
- chmod +x ./scripts/get-php-versions.sh
- ./scripts/get-php-versions.sh
- env:
- SKIP_DOWNLOAD: false
-
- - name: Ensure our PHP Versions file exists.
- run: |
- if [ ! -f "${{ inputs.php-versions-file }}" ]; then
- echo "PHP Versions file does not exist. Exiting."
- exit 1
- else
- cat ${{ inputs.php-versions-file }}
- fi
-
- - name: Assemble PHP versions into the matrix. đ
- id: get-php-versions
- run: |
- MATRIX_JSON=$(bash ./scripts/generate-matrix.sh '${{ inputs.php-versions-file }}')
- echo "php-version-map-json=${MATRIX_JSON}" >> $GITHUB_OUTPUT
- echo "${MATRIX_JSON}" | jq '.'
-
- - name: Upload the php-versions.yml file
- uses: actions/upload-artifact@v7
- with:
- name: php-versions.yml
- path: ${{ inputs.php-versions-file }}
-
- docker-publish:
- needs: setup-matrix
- runs-on: depot-ubuntu-24.04-8
+ timeout-minutes: 45
+ permissions:
+ contents: read
+ id-token: write
strategy:
- matrix: ${{fromJson(needs.setup-matrix.outputs.php-version-map-json)}}
+ fail-fast: false
+ matrix: ${{ fromJson(inputs.matrix)[inputs.variation] }}
+ env:
+ IMAGE_NAME: ${{ matrix.php_variation }}-${{ matrix.patch_version }}-${{ matrix.base_os }}
steps:
- name: Check out code.
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
-
+
- name: Download PHP Versions file
uses: actions/download-artifact@v8
with:
name: php-versions.yml
path: ./artifacts
-
+
- name: Move PHP Versions file
run: mv ./artifacts/php-versions.yml ${{ inputs.php-versions-file }}
-
+
- name: Set up Depot CLI
uses: depot/setup-action@v1
-
- ##
- # Docker build & publish
- ##
- - name: Login to DockerHub
- uses: docker/login-action@v4
- if: ${{ inputs.push-to-registry }}
- with:
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
- password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v4
- if: ${{ inputs.push-to-registry && inputs.authenticate_with_ghcr }}
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
- name: "đĻ Assemble the Docker Tags"
run: |
@@ -140,12 +100,13 @@ jobs:
else
VERSION="${{ inputs.ref }}"
fi
- echo "REPOSITORY_BUILD_VERSION=${VERSION}-${{ github.run_id }}" >> $GITHUB_ENV
+ echo "REPOSITORY_BUILD_VERSION=${VERSION}-${{ github.run_id }}" >> "$GITHUB_ENV"
else
- # Regular git build (PR, branch push, etc)
+ # 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=$(echo ${{ github.sha }} | cut -c1-7)
- echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${{ github.run_id }}" >> $GITHUB_ENV
+ SHORT_SHA=$(git rev-parse --short=7 HEAD)
+ echo "REPOSITORY_BUILD_VERSION=git-${SHORT_SHA}-${{ github.run_id }}" >> "$GITHUB_ENV"
fi
- name: Compute NGINX build-arg (only for fpm-nginx)
@@ -161,13 +122,18 @@ jobs:
echo "Unable to determine NGINX version for OS ${{ matrix.base_os }}" 1>&2
exit 1
fi
- echo "nginx_arg=NGINX_VERSION=$VERSION" >> $GITHUB_OUTPUT
+ echo "nginx_arg=NGINX_VERSION=$VERSION" >> "$GITHUB_OUTPUT"
+ # Authentication: the Depot project ID comes from depot.json. Same-repo runs authenticate
+ # through the 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, which
+ # builds on ephemeral builders without the project cache (https://depot.dev/blog/github-actions-oss-fork-builds).
+ # Those runs have no registry secrets either, so nothing is saved; the image is loaded
+ # onto the runner for the test step below instead.
- name: Build images
+ id: build
uses: depot/build-push-action@v1
with:
- project: ${{ secrets.DEPOT_PROJECT_ID }}
- token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
file: src/variations/${{ matrix.php_variation }}/Dockerfile
build-args: |
BASE_OS_VERSION=${{ matrix.base_os }}
@@ -180,6 +146,46 @@ jobs:
linux/amd64
linux/arm64/v8
pull: true
- push: ${{ inputs.push-to-registry }}
+ push: false
+ save: ${{ inputs.publish }}
+ save-tags: ${{ github.run_id }}-${{ env.IMAGE_NAME }}
+ load: ${{ !inputs.publish }}
tags: ${{ env.DOCKER_TAGS }}
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=Supercharge your PHP experience with Docker images that are based off the official PHP images but are optimized to be run in production environments for Laravel and WordPress and more
+
+ # Images that are not saved cannot go through the test phase, so they are tested here
+ # on the runner's own architecture (amd64). arm64 is covered when a maintainer
+ # dispatches the workflow for the pull request.
+ - name: Test the image (amd64)
+ if: ${{ !inputs.publish }}
+ run: bash scripts/test-image.sh "${DOCKER_TAGS%%,*}" "${{ matrix.patch_version }}"
+
+ # The test, publish, and report workflows all read these files.
+ - name: Record image details
+ env:
+ PUBLISH: ${{ inputs.publish }}
+ BUILD_ID: ${{ steps.build.outputs.build-id }}
+ DIGEST: ${{ steps.build.outputs.digest }}
+ run: |
+ mkdir -p image-details
+ SAVED=null
+ if [ "$PUBLISH" = "true" ]; then
+ SAVED="\"registry.depot.dev/$(jq -r .id depot.json):${{ github.run_id }}-${IMAGE_NAME}\""
+ fi
+ jq -n \
+ --arg variation "${{ matrix.php_variation }}" \
+ --arg php "${{ matrix.patch_version }}" \
+ --arg os "${{ matrix.base_os }}" \
+ --arg tags "$DOCKER_TAGS" \
+ --arg build_id "$BUILD_ID" \
+ --arg digest "$DIGEST" \
+ --argjson saved "$SAVED" \
+ '{variation: $variation, php: $php, os: $os, tags: ($tags | split(",")), saved: $saved, build_id: $build_id, digest: $digest, published: false, sizes: {}}' \
+ | tee "image-details/${IMAGE_NAME}.json"
+
+ - name: Upload image details
+ uses: actions/upload-artifact@v7
+ with:
+ name: image-details-${{ env.IMAGE_NAME }}
+ path: image-details/
+ retention-days: 7
diff --git a/.github/workflows/service_lint.yml b/.github/workflows/service_lint.yml
new file mode 100644
index 000000000..536252c4e
--- /dev/null
+++ b/.github/workflows/service_lint.yml
@@ -0,0 +1,72 @@
+name: Lint
+
+# Fast checks that run before any image is built. Add new linters as jobs here.
+# The same commands work locally; see docs/content/docs/1.getting-started/99.contributing.md.
+on:
+ workflow_call:
+
+env:
+ ACTIONLINT_VERSION: 1.7.12
+ SHELLCHECK_VERSION: v0.11.0
+ HADOLINT_VERSION: v2.15.1
+
+jobs:
+ actionlint:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+
+ - name: Lint GitHub Actions workflows
+ run: docker run --rm -v "$PWD:/repo" -w /repo "rhysd/actionlint:${ACTIONLINT_VERSION}" -color
+
+ shellcheck:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+
+ - name: Lint shell scripts
+ run: |
+ # The find output is meant to expand into a list of files.
+ # shellcheck disable=SC2046
+ docker run --rm -v "$PWD:/repo" -w /repo "koalaman/shellcheck:${SHELLCHECK_VERSION}" \
+ --color=always --severity=warning \
+ scripts/*.sh \
+ scripts/tests/*.sh \
+ src/common/usr/local/bin/* \
+ src/s6/usr/local/bin/* \
+ $(find src -path '*entrypoint.d*' -name '*.sh')
+
+ hadolint:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+
+ - name: Lint Dockerfiles
+ run: docker run --rm -v "$PWD:/repo" -w /repo "hadolint/hadolint:${HADOLINT_VERSION}" hadolint src/variations/*/Dockerfile
+
+ script-tests:
+ name: scripts
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+
+ # Runs on a real runner on purpose: tool versions here (jq, yq) are what the
+ # publish workflows use, and they differ from laptops.
+ - name: Test the CI helper scripts
+ run: bash scripts/tests/run.sh
diff --git a/.github/workflows/service_publish-images.yml b/.github/workflows/service_publish-images.yml
new file mode 100644
index 000000000..97481db68
--- /dev/null
+++ b/.github/workflows/service_publish-images.yml
@@ -0,0 +1,182 @@
+name: Publish Images
+
+# Phase 3 of 3: promote the exact images that passed the image tests from the Depot
+# Registry to Docker Hub and GHCR. No rebuild happens here and no image bytes pass
+# through the runner: `depot push` copies each saved build to every tag server-side,
+# and the published digest is checked against the one the build recorded. Depot
+# sponsors this project, so its runners are used for the phases that do real work.
+on:
+ workflow_call:
+ inputs:
+ authenticate_with_ghcr:
+ default: true
+ type: boolean
+ description: 'Whether to log in to GitHub Container Registry.'
+ ref:
+ type: string
+ default: ${{ github.ref }}
+ description: 'The git ref to checkout (branch, tag, or commit SHA)'
+
+jobs:
+ list:
+ name: plan
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ matrix: ${{ steps.list.outputs.matrix }}
+ count: ${{ steps.list.outputs.count }}
+ steps:
+ - name: Download image details from every build job
+ uses: actions/download-artifact@v8
+ with:
+ pattern: image-details-*
+ path: image-details
+ merge-multiple: true
+
+ - name: Group the saved images by variation
+ id: list
+ run: |
+ MATRIX=$(find image-details -name '*.json' -print0 | xargs -0 -r jq -s -c '
+ [ .[] | select(.saved != null) ]
+ | group_by(.variation)
+ | { include: map({ variation: .[0].variation, images: (map({php, os, saved, build_id, digest, tags}) | tojson) }) }')
+ MATRIX="${MATRIX:-{\"include\":[]\}}"
+ COUNT=$(echo "$MATRIX" | jq '[.include[].images | fromjson | length] | add // 0')
+ echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
+ echo "count=$COUNT" >> "$GITHUB_OUTPUT"
+ echo "$COUNT images to publish"
+
+ images:
+ needs: list
+ if: needs.list.outputs.count > 0
+ name: ${{ matrix.variation }}
+ runs-on: depot-ubuntu-24.04
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ packages: write
+ id-token: write
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.list.outputs.matrix) }}
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Set up Depot CLI
+ uses: depot/setup-action@v1
+ with:
+ oidc: true
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ if: ${{ inputs.authenticate_with_ghcr }}
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # `depot push` handles one tag at a time, so images are pushed in parallel and each
+ # one's output is printed afterwards as its own log group. Every image is attempted
+ # even after one fails, so a run shows every image that could not be published at once.
+ - name: Promote the tested images to every tag
+ env:
+ IMAGES: ${{ matrix.images }}
+ VARIATION: ${{ matrix.variation }}
+ MAX_PARALLEL: 8
+ run: |
+ PROJECT=$(jq -r .id depot.json)
+ mkdir -p image-details logs
+
+ publish_image() {
+ local php="$1" os="$2" saved="$3" build_id="$4" digest="$5" tags="$6"
+ local image_name="${VARIATION}-${php}-${os}"
+ local tag tag_args=()
+ for tag in ${tags//,/ }; do
+ tag_args+=(--tag "$tag")
+ done
+ echo "Pushing build $build_id to $(( ${#tag_args[@]} / 2 )) tags"
+ if ! depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id"; then
+ echo "::error title=Publish failed::${image_name} could not be pushed"
+ return 1
+ fi
+
+ # What was published must be what was tested: the tested digest has to appear
+ # in the published index (as the index itself or one of its manifests).
+ local canonical_tag="${tags%%,*}" index published_digests
+ index=$(docker buildx imagetools inspect --raw "$canonical_tag")
+ published_digests="sha256:$(printf '%s' "$index" | sha256sum | cut -d' ' -f1) $(echo "$index" | jq -r '.manifests[]?.digest' | tr '\n' ' ')"
+ if [ -z "$digest" ]; then
+ echo "::warning title=Digest not recorded::${image_name} was published without a digest check because the build did not record one."
+ elif [[ " $published_digests " != *" $digest "* ]]; then
+ echo "::error title=Digest mismatch::${image_name} published as $published_digests but the tested image was $digest"
+ return 1
+ else
+ echo "Published digest matches the tested image ($digest)"
+ fi
+
+ local manifest sizes
+ manifest=$(docker manifest inspect -v "$canonical_tag") || manifest='[]'
+ sizes=$(echo "$manifest" | jq '
+ [ .[] | select(.Descriptor.platform.os == "linux")
+ | { key: (.Descriptor.platform.os + "/" + .Descriptor.platform.architecture),
+ value: ([ (.SchemaV2Manifest // .OCIManifest).layers[].size ] | add) } ]
+ | from_entries')
+ jq -n \
+ --arg variation "$VARIATION" \
+ --arg php "$php" \
+ --arg os "$os" \
+ --arg tags "$tags" \
+ --arg saved "$saved" \
+ --arg build_id "$build_id" \
+ --arg digest "$digest" \
+ --argjson sizes "$sizes" \
+ '{variation: $variation, php: $php, os: $os, tags: ($tags | split(",")), saved: $saved, build_id: $build_id, digest: $digest, published: true, sizes: $sizes}' \
+ | tee "image-details/${image_name}.published.json"
+ }
+
+ NAMES=()
+ # An empty digest is written as "-" because bash collapses consecutive tabs.
+ while IFS=$'\t' read -r PHP OS SAVED BUILD_ID DIGEST TAGS; do
+ [ "$DIGEST" != "-" ] || DIGEST=""
+ IMAGE_NAME="${VARIATION}-${PHP}-${OS}"
+ NAMES+=("$IMAGE_NAME")
+ while [ "$(jobs -rp | wc -l)" -ge "$MAX_PARALLEL" ]; do sleep 1; done
+ (
+ publish_image "$PHP" "$OS" "$SAVED" "$BUILD_ID" "$DIGEST" "$TAGS" > "logs/${IMAGE_NAME}.log" 2>&1
+ echo $? > "logs/${IMAGE_NAME}.status"
+ ) &
+ done < <(echo "$IMAGES" | jq -r '.[] | [.php, .os, .saved, .build_id, (.digest | if . == "" then "-" else . end), (.tags | join(","))] | @tsv')
+ wait
+
+ FAILED=()
+ for IMAGE_NAME in "${NAMES[@]}"; do
+ echo "::group::${IMAGE_NAME}"
+ cat "logs/${IMAGE_NAME}.log"
+ echo "::endgroup::"
+ [ "$(cat "logs/${IMAGE_NAME}.status" 2>/dev/null)" = "0" ] || FAILED+=("$IMAGE_NAME")
+ done
+
+ if [ "${#FAILED[@]}" -gt 0 ]; then
+ echo "::error title=Publish failed::${FAILED[*]} were not published"
+ exit 1
+ fi
+
+ - name: Record the published images
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: image-details-published-${{ matrix.variation }}
+ path: image-details/
+ retention-days: 7
+ if-no-files-found: ignore
diff --git a/.github/workflows/service_report.yml b/.github/workflows/service_report.yml
new file mode 100644
index 000000000..8316feca5
--- /dev/null
+++ b/.github/workflows/service_report.yml
@@ -0,0 +1,54 @@
+name: Report
+
+# Collects the image details every build job records and renders one table for the
+# whole run: each image with its compressed size per architecture and a pull command.
+on:
+ workflow_call:
+ inputs:
+ matrix:
+ type: string
+ default: '{}'
+ description: 'Build matrix JSON keyed by variation, used to list images that did not build.'
+ ref:
+ type: string
+ default: ${{ github.ref }}
+ description: 'The git ref to checkout (branch, tag, or commit SHA)'
+ outputs:
+ markdown:
+ description: 'The rendered Markdown table'
+ value: ${{ jobs.summary.outputs.markdown }}
+
+jobs:
+ summary:
+ name: images
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ markdown: ${{ steps.render.outputs.markdown }}
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Download image details from every build job
+ uses: actions/download-artifact@v8
+ with:
+ pattern: image-details-*
+ path: image-details
+ merge-multiple: true
+
+ - name: Render the summary table
+ id: render
+ env:
+ MATRIX: ${{ inputs.matrix }}
+ run: |
+ bash scripts/build-summary.sh image-details "$MATRIX" > summary.md
+ cat summary.md >> "$GITHUB_STEP_SUMMARY"
+ {
+ echo "markdown<> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/service_setup-matrix.yml b/.github/workflows/service_setup-matrix.yml
new file mode 100644
index 000000000..c346d7503
--- /dev/null
+++ b/.github/workflows/service_setup-matrix.yml
@@ -0,0 +1,84 @@
+name: Setup Build Matrix
+
+on:
+ workflow_call:
+ inputs:
+ php-versions-file:
+ type: string
+ default: 'scripts/conf/php-versions.yml'
+ description: 'The path to the PHP versions file.'
+ ref:
+ type: string
+ default: ${{ github.ref }}
+ description: 'The git ref to checkout (branch, tag, or commit SHA)'
+ outputs:
+ matrix:
+ description: 'Build matrix JSON keyed by variation, e.g. {"cli": {"include": [...]}, "fpm": {...}}'
+ value: ${{ jobs.matrix.outputs.matrix }}
+ variations:
+ description: 'JSON array of variation names, e.g. ["cli","fpm"]'
+ value: ${{ jobs.matrix.outputs.variations }}
+
+jobs:
+ matrix:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ outputs:
+ matrix: ${{ steps.matrix.outputs.matrix }}
+ variations: ${{ steps.matrix.outputs.variations }}
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Prepare PHP versions for the matrix. đ
+ run: |
+ chmod +x ./scripts/get-php-versions.sh
+ ./scripts/get-php-versions.sh
+ env:
+ SKIP_DOWNLOAD: false
+
+ - name: Ensure our PHP Versions file exists.
+ run: |
+ if [ ! -f "${{ inputs.php-versions-file }}" ]; then
+ echo "::error::PHP Versions file does not exist at ${{ inputs.php-versions-file }}"
+ exit 1
+ fi
+ cat "${{ inputs.php-versions-file }}"
+
+ - name: Assemble PHP versions into the matrix. đ
+ id: matrix
+ run: |
+ FULL_MATRIX=$(bash ./scripts/generate-matrix.sh '${{ inputs.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 '
+ .include
+ | group_by(.php_variation)
+ | map({ key: .[0].php_variation, value: { include: . } })
+ | from_entries
+ ')
+ VARIATIONS=$(echo "$MATRIX_BY_VARIATION" | jq -c 'keys')
+
+ echo "matrix=${MATRIX_BY_VARIATION}" >> "$GITHUB_OUTPUT"
+ echo "variations=${VARIATIONS}" >> "$GITHUB_OUTPUT"
+ echo "$MATRIX_BY_VARIATION" | jq '.'
+
+ {
+ echo "## Build matrix: $(echo "$FULL_MATRIX" | jq '.include | length') images across $(echo "$VARIATIONS" | jq 'length') variations"
+ echo ""
+ echo "| PHP | Resolved patch version | Base OS |"
+ echo "|---|---|---|"
+ yq -o=json '${{ inputs.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
+ 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
new file mode 100644
index 000000000..b220b0b39
--- /dev/null
+++ b/.github/workflows/service_test-images.yml
@@ -0,0 +1,99 @@
+name: Test Images
+
+# Phase 2 of 3: pull every image the build phase saved to the Depot Registry and run
+# scripts/test-image.sh against each one on native amd64 and arm64 runners.
+# Images are grouped by PHP version and base OS so one job pulls the variations that
+# share the same base layers. The jobs run on Depot runners (Depot sponsors this
+# project), which pull from the Depot Registry inside Depot's own network and have
+# no shared concurrency cap. Publishing only happens if this phase passes.
+on:
+ workflow_call:
+ inputs:
+ ref:
+ type: string
+ default: ${{ github.ref }}
+ description: 'The git ref to checkout (branch, tag, or commit SHA)'
+
+jobs:
+ list:
+ name: plan
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ outputs:
+ matrix: ${{ steps.list.outputs.matrix }}
+ count: ${{ steps.list.outputs.count }}
+ steps:
+ - name: Download image details from every build job
+ uses: actions/download-artifact@v8
+ with:
+ pattern: image-details-*
+ path: image-details
+ merge-multiple: true
+
+ - name: Group the saved images by PHP version and base OS, on both architectures
+ id: list
+ run: |
+ MATRIX=$(find image-details -name '*.json' -print0 | xargs -0 -r jq -s -c '
+ [ .[] | select(.saved != null) ]
+ | group_by([.php, .os])
+ | map({ php: .[0].php, os: .[0].os, images: (sort_by(.variation) | map({variation, saved}) | tojson) })
+ | { include: [ .[]
+ + ({ runner: "depot-ubuntu-24.04", arch: "amd64" }, { runner: "depot-ubuntu-24.04-arm", arch: "arm64" }) ] }')
+ MATRIX="${MATRIX:-{\"include\":[]\}}"
+ COUNT=$(echo "$MATRIX" | jq '[.include[].images | fromjson | length] | add // 0')
+ echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
+ echo "count=$COUNT" >> "$GITHUB_OUTPUT"
+ if [ "$COUNT" = "0" ]; then
+ echo "::notice title=No saved images::Nothing was saved to the Depot Registry, so there is nothing to test here. Pull requests from forks are tested inside their build jobs."
+ fi
+ echo "$COUNT image tests in $(echo "$MATRIX" | jq '.include | length') jobs:"
+ echo "$MATRIX" | jq -r '.include[] | " \(.php)-\(.os) on \(.arch): \(.images | fromjson | map(.variation) | join(", "))"'
+
+ images:
+ needs: list
+ if: needs.list.outputs.count > 0
+ name: ${{ matrix.php }}-${{ matrix.os }} ${{ matrix.arch }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 20
+ permissions:
+ contents: read
+ id-token: write
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.list.outputs.matrix) }}
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+
+ - name: Set up Depot CLI
+ uses: depot/setup-action@v1
+ with:
+ oidc: true
+
+ - name: Login to the Depot Registry
+ run: docker login registry.depot.dev --username x-token --password-stdin <<< "$(depot pull-token --project "$(jq -r .id depot.json)")"
+
+ # Every image in the group is tested even after one fails, so a run shows every
+ # broken image at once instead of one per attempt.
+ - name: Test every image in the group
+ env:
+ IMAGES: ${{ matrix.images }}
+ PHP: ${{ matrix.php }}
+ run: |
+ FAILED=()
+ while IFS=$'\t' read -r VARIATION SAVED; do
+ echo "::group::${VARIATION} ${PHP}-${{ matrix.os }} ${{ matrix.arch }}"
+ if ! { docker pull --quiet "$SAVED" && bash scripts/test-image.sh "$SAVED" "$PHP"; }; then
+ FAILED+=("$VARIATION")
+ fi
+ echo "::endgroup::"
+ 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 }}"
+ exit 1
+ fi
diff --git a/.github/workflows/trigger_auto-retry-failed-builds.yml b/.github/workflows/trigger_auto-retry-failed-builds.yml
new file mode 100644
index 000000000..330a7b8c6
--- /dev/null
+++ b/.github/workflows/trigger_auto-retry-failed-builds.yml
@@ -0,0 +1,151 @@
+name: Auto-retry failed image builds
+
+# Transient failures (php.net timeouts, a mirror blip, a registry 502) used to need a
+# maintainer to sign in and click "Re-run failed jobs". This does that once, as long as
+# 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.
+on:
+ workflow_run:
+ workflows:
+ - "Docker Publish (Production Images)"
+ - "Docker Publish (Beta Images)"
+ - "Docker Publish (PR Images)"
+ types: [completed]
+
+permissions: {}
+
+jobs:
+ triage:
+ if: github.event.workflow_run.conclusion == 'failure'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ actions: write
+ issues: write
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ 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 }}
+ WORKFLOW_PATH: ${{ github.event.workflow_run.path }}
+ TRIGGER: ${{ github.event.workflow_run.event }}
+ HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ ASSIGNEE: jaydrogers
+ ISSUE_LABEL: ci-failure
+ # More failures than this means the change itself is broken, not the infrastructure.
+ MAX_FAILED_JOBS: 10
+ steps:
+ - name: Decide whether a retry makes sense
+ id: check
+ run: |
+ if [ "$RUN_ATTEMPT" = "1" ]; then
+ echo "Waiting two minutes so transient upstream issues can settle."
+ sleep 120
+ fi
+
+ LATEST=$(gh run list --workflow "$WORKFLOW_NAME" --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"
+
+ if [ "$RUN_ATTEMPT" = "1" ] && [ "$FAILED" -le "$MAX_FAILED_JOBS" ]; then
+ echo "action=retry" >> "$GITHUB_OUTPUT"
+ echo "reason=" >> "$GITHUB_OUTPUT"
+ elif [ "$RUN_ATTEMPT" = "1" ]; then
+ echo "action=issue" >> "$GITHUB_OUTPUT"
+ echo "reason=$FAILED jobs failed, more than the $MAX_FAILED_JOBS that looks like a transient problem, so it was not retried." >> "$GITHUB_OUTPUT"
+ else
+ echo "action=issue" >> "$GITHUB_OUTPUT"
+ echo "reason=The failed jobs were already re-run once automatically and failed again." >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Re-run the failed jobs once
+ if: steps.check.outputs.action == 'retry'
+ run: |
+ gh run rerun "$RUN_ID" --failed
+ 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')
+ env:
+ REASON: ${{ steps.check.outputs.reason }}
+ MAX_DETAILED_JOBS: 8
+ LOG_LINES: 25
+ 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")
+ | { 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")"
+ SHORT_SHA="${HEAD_SHA:0:7}"
+
+ # The first failure annotation of a job is usually the one-line cause.
+ annotation() {
+ gh api "repos/${GH_REPO}/check-runs/$1/annotations" \
+ --jq '[.[] | select(.annotation_level == "failure")][0] | if . == null then "" elif (.title // "") != "" then "\(.title): \(.message)" else .message end' 2>/dev/null \
+ | head -c 300 | tr '\n|' ' '
+ }
+
+ # gh prefixes every log line with the job, the step, and a timestamp.
+ log_tail() {
+ gh run view "$RUN_ID" --job "$1" --log-failed 2>/dev/null \
+ | sed -E 's/^[^\t]*\t[^\t]*\t[0-9T:.Z-]+ ?//' \
+ | grep -v -E '^\s*$|^##\[(group|endgroup)\]' | tail -n "$LOG_LINES"
+ }
+
+ {
+ 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 ""
+ echo "### Failed jobs"
+ echo ""
+ echo "| Job | Step | Error |"
+ echo "|---|---|---|"
+ echo "$FAILED_JOBS" | jq -r '.[] | [.id, .name, .url, .step] | @tsv' | while IFS=$'\t' read -r ID NAME URL STEP; do
+ ERROR=$(annotation "$ID")
+ echo "| [$NAME]($URL) | $STEP | ${ERROR:-see logs} |"
+ done
+ echo ""
+ echo "### Error output"
+ echo ""
+ echo "$FAILED_JOBS" | jq -r ".[:${MAX_DETAILED_JOBS}][] | [.id, .name, .step] | @tsv" | while IFS=$'\t' read -r ID NAME STEP; do
+ echo "$NAME ¡ $STEP
"
+ echo ""
+ echo '```text'
+ log_tail "$ID" || echo "No log output captured."
+ echo '```'
+ echo " "
+ echo ""
+ done
+ if [ "$FAILED" -gt "$MAX_DETAILED_JOBS" ]; then
+ echo "Only the first $MAX_DETAILED_JOBS jobs are shown. The rest are in the [run]($RUN_URL)."
+ echo ""
+ fi
+ echo "Nothing from this run was published. Re-run the failed jobs from the run page, or with gh run rerun $RUN_ID --failed. Close this issue once the run is green."
+ } > issue.md
+
+ gh label create "$ISSUE_LABEL" --description "A publish run needs a human" --color B60205 --force
+ TITLE="$RUN_TITLE: $FAILED of $TOTAL jobs failed"
+ # shellcheck disable=SC2016 # $title is a jq variable
+ EXISTING=$(gh issue list --label "$ISSUE_LABEL" --state open --search "\"$RUN_TITLE\" in:title" --json number,title \
+ --jq --arg title "$RUN_TITLE" '[.[] | select(.title | startswith($title))][0].number')
+ if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
+ gh issue comment "$EXISTING" --body-file issue.md
+ gh issue edit "$EXISTING" --title "$TITLE"
+ echo "::notice title=Issue updated::Added the latest failure to issue #$EXISTING"
+ else
+ NEW_URL=$(gh issue create --title "$TITLE" --body-file issue.md --label "$ISSUE_LABEL" --assignee "$ASSIGNEE")
+ echo "::notice title=Issue opened::$NEW_URL"
+ fi
diff --git a/.hadolint.yaml b/.hadolint.yaml
new file mode 100644
index 000000000..2dbf9e727
--- /dev/null
+++ b/.hadolint.yaml
@@ -0,0 +1,11 @@
+# hadolint configuration for src/variations/*/Dockerfile
+# Each ignored rule is a deliberate project decision, not an oversight.
+ignored:
+ - DL3008 # apt packages are intentionally unpinned: weekly rebuilds pick up security updates
+ - DL3018 # apk packages are intentionally unpinned for the same reason
+ - DL3015 # --no-install-recommends is handled inside docker-php-serversideup-dep-install-debian
+ - DL4006 # pipefail is unavailable in the Alpine /bin/sh these RUN steps must also support
+ - DL3064 # false positive: build args named *_KEY_* hold public signing key URLs and fingerprints
+ - DL3066 # USER www-data by name is intentional; the entrypoint remaps the UID at runtime
+ - DL3059 # consecutive RUN steps are kept separate on purpose for cache granularity
+ - DL3025 # shell-form CMD is intentional so the entrypoint can expand environment variables
diff --git a/AGENTS.md b/AGENTS.md
index 117b79933..163de6ea8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,10 @@ docs/ # Nuxt 4 documentation site (see docs/AGENTS.md for docs-sp
**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.
+**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`.
+
+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.
+
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/`
@@ -90,10 +94,17 @@ When modifying the version pipeline, the base config (`php-versions-base-config.
## CI/CD
-- Builds run via GitHub Actions using **Depot** (`depot/build-push-action`) for multi-arch (`linux/amd64` + `linux/arm64/v8`).
-- The reusable workflow is `.github/workflows/service_docker-build-and-publish.yml`.
+- 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. `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.
+- Every PR run starts with `service_lint.yml` (script tests, actionlint for workflows, hadolint for `src/variations/*/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/*`, 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, 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.
## Verification
diff --git a/depot.json b/depot.json
new file mode 100644
index 000000000..f2be693b3
--- /dev/null
+++ b/depot.json
@@ -0,0 +1 @@
+{ "id": "6ndnghg893" }
diff --git a/docs/content/docs/1.getting-started/99.contributing.md b/docs/content/docs/1.getting-started/99.contributing.md
index 3777eb298..563bce47b 100644
--- a/docs/content/docs/1.getting-started/99.contributing.md
+++ b/docs/content/docs/1.getting-started/99.contributing.md
@@ -66,7 +66,35 @@ All the scripts above are designed to run locally and in GitHub Actions. Feel fr
## 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-beta-images.yml` for an example of how we publish our beta images.
+See `.github/workflows/action_publish-images-beta.yml` for an example of how we publish our beta images.
+
+### 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:
+
+```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/* $(find src -path '*entrypoint.d*' -name '*.sh')
+
+docker run --rm -v "$PWD:/repo" -w /repo hadolint/hadolint:latest hadolint src/variations/*/Dockerfile
+
+bash scripts/tests/run.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.
+
+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`.
+
+### 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.
+
+- **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, 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.
## 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.
diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md
index f786126ef..be62862cc 100644
--- a/docs/content/docs/8.reference/2.command-reference.md
+++ b/docs/content/docs/8.reference/2.command-reference.md
@@ -32,6 +32,17 @@ This command will detect the operating system and install packages if it is a De
docker-php-serversideup-dep-install-debian git
```
+## 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.
diff --git a/scripts/build-summary.sh b/scripts/build-summary.sh
new file mode 100755
index 000000000..09f184aa6
--- /dev/null
+++ b/scripts/build-summary.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Usage: build-summary.sh [expected-matrix-json]
+#
+# Renders a Markdown table of the images built in a CI run from the JSON files that
+# each build job records (see service_build-images.yml) and each publish job adds
+# (see service_publish-images.yml). When both exist for an image, the published one
+# wins because it carries the sizes. The optional matrix JSON, keyed by variation as
+# produced by service_setup-matrix.yml, is used to list images that never reported
+# back so a failed build is visible in the table.
+
+details_dir="${1:?Usage: build-summary.sh [expected-matrix-json]}"
+expected_matrix="${2:-{\}}"
+
+built=$(find "$details_dir" -name '*.json' -print0 | xargs -0 -r jq -s '
+ group_by([.variation, .php, .os]) | map((map(select(.published)) | first) // .[0])')
+built="${built:-[]}"
+
+expected=$(echo "$expected_matrix" | jq -c '[.[]? | .include[] | {variation: .php_variation, php: .patch_version, os: .base_os}]')
+
+echo "$built" | jq -r --argjson expected "$expected" '
+ def version_key: gsub("-rc"; ".999") | split(".") | map(tonumber? // 0) | map(-.);
+ def megabytes: if . == null then "" else ((. / 100000) | round) as $tenths | "\($tenths / 10 | floor).\($tenths % 10) MB" end;
+ def image_cell:
+ if has("tags") | not then "â not built"
+ elif .published then "`" + (.tags[0] | sub("^docker.io/"; "")) + "`"
+ else "built, not published"
+ end;
+
+ . as $built
+ | (if ($expected | length) > 0 then $expected else map({variation, php, os}) end) as $rows
+ | ($rows | map(. as $row
+ | (first($built[] | select(.variation == $row.variation and .php == $row.php and .os == $row.os)) // $row)
+ )) as $merged
+ | ($merged | map(select(has("tags"))) | length) as $built_count
+ | ($merged | any(.published == true)) as $published
+ | "## Images: \($built_count) of \($rows | length) built" + (if $published then "" else " (not published)" end),
+ "",
+ "Sizes are compressed, per architecture.",
+ "",
+ "| Variation | PHP | Base OS | amd64 | arm64 | Image |",
+ "|---|---|---|---|---|---|",
+ ($merged
+ | sort_by([.variation, (.php | version_key), .os])
+ | .[]
+ | "| \(.variation) | \(.php) | \(.os) | \(.sizes["linux/amd64"] | megabytes) | \(.sizes["linux/arm64"] | megabytes) | \(image_cell) |")
+'
diff --git a/scripts/dev.sh b/scripts/dev.sh
index dab2fa920..c50d2858d 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -71,7 +71,8 @@ check_vars() {
}
detect_platform() {
- local arch=$(uname -m)
+ local arch
+ arch=$(uname -m)
case $arch in
x86_64)
echo "linux/amd64"
diff --git a/scripts/get-nginx-versions.sh b/scripts/get-nginx-versions.sh
index eeeccae31..93066af19 100755
--- a/scripts/get-nginx-versions.sh
+++ b/scripts/get-nginx-versions.sh
@@ -138,7 +138,8 @@ get_alpine_version() {
local url="$1"
local pattern="$2"
- local version=$(curl -s "$url" | grep -o "$pattern" | sort -V | tail -1)
+ 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/'
@@ -150,7 +151,8 @@ get_alpine_version() {
get_debian_version() {
local url="$1"
- local version=$(curl -s "$url" \
+ 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
diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh
index 2a08e72d4..9bd8821ae 100755
--- a/scripts/get-php-versions.sh
+++ b/scripts/get-php-versions.sh
@@ -8,7 +8,10 @@
#
# đ 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. If a version is not 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.
+# 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
@@ -77,6 +80,31 @@ check_dockerhub_php_version() {
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"
@@ -124,8 +152,8 @@ validate_php_version_with_fallback() {
echo_color_message yellow "đ Checking PHP version $version on DockerHub..." >&2
- # Check if the version exists on DockerHub (using cli variant as reference)
- if check_dockerhub_php_version "$version" "cli"; then
+ # 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
@@ -138,7 +166,7 @@ validate_php_version_with_fallback() {
fallback_attempted=true
echo_color_message yellow "â ī¸ Attempting fallback to PHP $fallback_version..." >&2
- if check_dockerhub_php_version "$fallback_version" "cli"; then
+ 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
diff --git a/scripts/test-image.sh b/scripts/test-image.sh
new file mode 100755
index 000000000..25e92045f
--- /dev/null
+++ b/scripts/test-image.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+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, 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.
+
+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)"
+
+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"
+
+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
+
+# Web images expose their HTTP port and document root as environment variables.
+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
+ port=$(image_env "${pair%%:*}")
+ if [ -n "$port" ]; then
+ http_port="$port"
+ web_root=$(image_env "${pair##*:}")
+ web_root="${web_root:-/var/www/html/public}"
+ break
+ fi
+done
+
+run_args=(--detach --rm)
+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"
+ run_args+=(--publish "127.0.0.1::${http_port}" --volume "$web_dir:$web_root:ro")
+fi
+
+container=$(docker run "${run_args[@]}" "$image")
+cleanup() {
+ docker rm --force "$container" >/dev/null 2>&1 || true
+ [ -z "${web_dir:-}" ] || rm -rf "$web_dir"
+}
+trap cleanup EXIT
+
+dump_container_state() {
+ echo "--- container logs ---" >&2
+ docker logs "$container" >&2 2>&1 || true
+ echo "--- last health check ---" >&2
+ docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$container" 2>/dev/null | tail -5 >&2 || true
+}
+
+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
+ fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)"
+fi
+pass "Container became healthy"
+
+[ -n "$http_port" ] || exit 0
+
+host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://')
+body=""
+for _ in $(seq 1 "$http_timeout_seconds"); do
+ body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}/" 2>/dev/null || true)
+ [ "$body" = "serversideup-php-ok:${php_version}" ] && break
+ sleep 1
+done
+
+if [ "$body" != "serversideup-php-ok:${php_version}" ]; then
+ dump_container_state
+ fail "Web server did not serve index.php on port ${http_port}. Response: ${body:-}"
+fi
+pass "Web server serves PHP on port ${http_port}"
diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh
new file mode 100755
index 000000000..f6833cefc
--- /dev/null
+++ b/scripts/tests/run.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Usage: scripts/tests/run.sh
+#
+# Checks the CI helper scripts against a small set of images. CI runs this in the
+# lint group on a real runner, so a jq or yq difference between a laptop and the
+# runner fails here instead of in a published run.
+
+scripts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+failures=0
+
+assert_contains() {
+ local haystack="$1" needle="$2" message="$3"
+ if [[ "$haystack" == *"$needle"* ]]; then
+ echo "â
$message"
+ else
+ echo "â $message"
+ echo " expected to find: $needle"
+ failures=$((failures + 1))
+ fi
+}
+
+# One JSON file per image, the way the build jobs upload them.
+image_details=$(mktemp -d)
+trap 'rm -rf "$image_details"' EXIT
+image() { echo "$1" > "$image_details/$2.json"; }
+image '{"variation":"cli","php":"8.5.10","os":"trixie","tags":["docker.io/serversideup/php-dev:700-8.5.10-cli-trixie"],"saved":"registry.depot.dev/proj:1-x","published":false,"sizes":{}}' newest-debian
+image '{"variation":"cli","php":"8.5.10","os":"trixie","tags":["docker.io/serversideup/php-dev:700-8.5.10-cli-trixie"],"saved":"registry.depot.dev/proj:1-x","published":true,"sizes":{"linux/amd64":195090095,"linux/arm64":187352117}}' newest-debian.published
+image '{"variation":"cli","php":"8.5.10","os":"alpine3.24","tags":["docker.io/serversideup/php-dev:700-8.5.10-cli-alpine3.24"],"saved":"registry.depot.dev/proj:1-x","published":true,"sizes":{"linux/amd64":50000000,"linux/arm64":49900000}}' newest-alpine
+image '{"variation":"cli","php":"8.5.10","os":"alpine3.23","tags":["docker.io/serversideup/php-dev:700-8.5.10-cli-alpine3.23"],"saved":"registry.depot.dev/proj:1-x","published":true,"sizes":{"linux/amd64":49900000,"linux/arm64":49800000}}' older-alpine
+image '{"variation":"cli","php":"8.4.25","os":"trixie","tags":["docker.io/serversideup/php-dev:700-8.4.25-cli-trixie"],"saved":"registry.depot.dev/proj:1-x","published":true,"sizes":{"linux/amd64":191100000,"linux/arm64":183700000}}' older-php
+image '{"variation":"fpm-nginx","php":"8.5.10","os":"trixie","tags":["docker.io/serversideup/php-dev:700-8.5.10-fpm-nginx-trixie"],"saved":"registry.depot.dev/proj:1-x","published":true,"sizes":{"linux/amd64":230000000,"linux/arm64":187352117}}' other-variation
+image '{"variation":"frankenphp","php":"8.5.10","os":"bookworm","tags":["docker.io/serversideup/php-dev:700-8.5.10-frankenphp-bookworm"],"saved":null,"published":false,"sizes":{}}' not-published
+
+# What setup planned, including one image that never reported back.
+matrix='{"cli":{"include":[
+ {"php_variation":"cli","patch_version":"8.5.10","base_os":"trixie"},
+ {"php_variation":"cli","patch_version":"8.5.10","base_os":"alpine3.24"},
+ {"php_variation":"cli","patch_version":"8.5.10","base_os":"alpine3.23"},
+ {"php_variation":"cli","patch_version":"8.4.25","base_os":"trixie"},
+ {"php_variation":"cli","patch_version":"8.4.25","base_os":"bookworm"}]},
+ "fpm-nginx":{"include":[{"php_variation":"fpm-nginx","patch_version":"8.5.10","base_os":"trixie"}]},
+ "frankenphp":{"include":[{"php_variation":"frankenphp","patch_version":"8.5.10","base_os":"bookworm"}]}}'
+
+echo "jq $(jq --version)"
+echo
+echo "build-summary.sh"
+summary=$(bash "$scripts_dir/build-summary.sh" "$image_details" "$matrix")
+assert_contains "$summary" "## Images: 6 of 7 built" "counts built images against the planned matrix"
+assert_contains "$summary" "| 195.1 MB | 187.4 MB |" "formats compressed sizes in MB with one decimal"
+assert_contains "$summary" "| cli | 8.5.10 | trixie | 195.1 MB | 187.4 MB | \`serversideup/php-dev:700-8.5.10-cli-trixie\` |" "prefers the published record when the build record also exists"
+assert_contains "$summary" "| 50.0 MB | 49.9 MB |" "keeps a trailing zero so columns line up"
+assert_contains "$summary" "| cli | 8.4.25 | bookworm | | | â not built |" "lists images that never reported back"
+assert_contains "$summary" "| built, not published |" "marks images that were built but not promoted"
+assert_contains "$summary" '`serversideup/php-dev:700-8.5.10-cli-trixie`' "shows the image reference without the registry prefix"
+
+echo
+if [ "$failures" -gt 0 ]; then
+ echo "$failures check(s) failed" >&2
+ exit 1
+fi
+echo "All checks passed"
diff --git a/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian b/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian
index 309a9cf97..87fe73454 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian
+++ b/src/common/usr/local/bin/docker-php-serversideup-dep-install-debian
@@ -6,7 +6,7 @@ set -oe
###################################################
# This script installs debian packages that are passed to it
-DEBIAN_FRONTEND=noninteractive
+export DEBIAN_FRONTEND=noninteractive
script_name="docker-php-serversideup-dep-install-debian"
############
@@ -37,8 +37,8 @@ convert_comma_delimited_to_space_separated() {
############
DEP_PACKAGES=$(convert_comma_delimited_to_space_separated "$@")
echo "đ¤ Installing: $DEP_PACKAGES"
-apt-get update
-apt-get install -y $DEP_PACKAGES
+apt-get -o Acquire::Retries=3 update
+apt-get -o Acquire::Retries=3 install -y $DEP_PACKAGES
echo "đ§ŧ Cleaning up installation of: $DEP_PACKAGES"
diff --git a/src/common/usr/local/bin/docker-php-serversideup-download b/src/common/usr/local/bin/docker-php-serversideup-download
new file mode 100755
index 000000000..e587bc8f9
--- /dev/null
+++ b/src/common/usr/local/bin/docker-php-serversideup-download
@@ -0,0 +1,23 @@
+#!/bin/sh
+set -oe
+
+###################################################
+# Usage: docker-php-serversideup-download [output-file]
+###################################################
+# Downloads a file during the image build with retries. The whole build
+# matrix starts at the same moment, and GitHub, nginx.org, and other hosts
+# occasionally answer a burst of parallel requests with a 5xx or a dropped
+# connection. Writes to stdout when no output file is given.
+script_name="docker-php-serversideup-download"
+
+if [ -z "${1:-}" ]; then
+ echo "đ ERROR ($script_name): No URL was provided." >&2
+ exit 1
+fi
+
+url="$1"
+output="${2:--}"
+
+curl --fail --location --silent --show-error \
+ --retry 5 --retry-delay 3 --retry-all-errors --retry-max-time 120 \
+ --output "$output" "$url"
diff --git a/src/common/usr/local/bin/docker-php-serversideup-entrypoint b/src/common/usr/local/bin/docker-php-serversideup-entrypoint
index 8dd38045c..f263147b4 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-entrypoint
+++ b/src/common/usr/local/bin/docker-php-serversideup-entrypoint
@@ -44,6 +44,7 @@ find /etc/entrypoint.d/ -type f -name '*.sh' | sort -V | while IFS= read -r f; d
fi
# Source the script in a subshell to contain exits while preserving environment
+ # shellcheck source=/dev/null
(. "$f")
exit_code=$?
diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
index c2cee43b3..5a03ac541 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
+++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
@@ -20,8 +20,9 @@ if [ -n "$1" ]; then
PHP_EXT_INSTALLER_VERSION="$1"
fi
-curl -sSLf -o /usr/local/bin/install-php-extensions \
- "https://github.com/mlocati/docker-php-extension-installer/releases/download/$PHP_EXT_INSTALLER_VERSION/install-php-extensions"
+docker-php-serversideup-download \
+ "https://github.com/mlocati/docker-php-extension-installer/releases/download/$PHP_EXT_INSTALLER_VERSION/install-php-extensions" \
+ /usr/local/bin/install-php-extensions
chmod +x /usr/local/bin/install-php-extensions
echo "$script_name: âĄī¸ Installed $PHP_EXT_INSTALLER_VERSION of \"install-php-extensions\""
\ No newline at end of file
diff --git a/src/s6/etc/s6-overlay/s6-rc.d/user/contents.d/php-fpm b/src/s6/etc/s6-overlay/user-bundles.d/user/contents.d/php-fpm
similarity index 100%
rename from src/s6/etc/s6-overlay/s6-rc.d/user/contents.d/php-fpm
rename to src/s6/etc/s6-overlay/user-bundles.d/user/contents.d/php-fpm
diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-install b/src/s6/usr/local/bin/docker-php-serversideup-s6-install
index 57ea405b2..cc38728f8 100644
--- a/src/s6/usr/local/bin/docker-php-serversideup-s6-install
+++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-install
@@ -11,7 +11,8 @@ set -oue
S6_VERSION=v3.2.3.2
mkdir -p $S6_DIR
-export SYS_ARCH=$(uname -m)
+SYS_ARCH=$(uname -m)
+export SYS_ARCH
case "$SYS_ARCH" in
aarch64 ) export S6_ARCH='aarch64' ;;
arm64 ) export S6_ARCH='aarch64' ;;
@@ -25,7 +26,10 @@ esac
untar() {
echo "âŦ Downloading $1"
- curl -L $1 -o - | tar Jxp -C $S6_DIR
+ tmp_file=$(mktemp)
+ docker-php-serversideup-download "$1" "$tmp_file"
+ tar Jxp -C "$S6_DIR" -f "$tmp_file"
+ rm -f "$tmp_file"
}
echo "âŦī¸ Downloading s6 overlay:${S6_ARCH}-${S6_VERSION} for ${SYS_ARCH}"
@@ -34,5 +38,5 @@ untar ${S6_SRC_URL}/${S6_VERSION}/s6-overlay-${S6_ARCH}.tar.xz
# Ensure "php-fpm-healthcheck" is installed
echo "âŦī¸ Downloading php-fpm-healthcheck..."
-curl -o /usr/local/bin/php-fpm-healthcheck https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck
+docker-php-serversideup-download https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck
chmod +x /usr/local/bin/php-fpm-healthcheck
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/user/contents.d/apache2 b/src/variations/fpm-apache/etc/s6-overlay/user-bundles.d/user/contents.d/apache2
similarity index 100%
rename from src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/user/contents.d/apache2
rename to src/variations/fpm-apache/etc/s6-overlay/user-bundles.d/user/contents.d/apache2
diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile
index 3c142856b..a35cec8b2 100644
--- a/src/variations/fpm-nginx/Dockerfile
+++ b/src/variations/fpm-nginx/Dockerfile
@@ -41,7 +41,7 @@ RUN \
mkdir -p /root/.gnupg && \
\
# Import signing key
- curl "$SIGNING_KEY_URL" | gpg --dearmor | tee "$SIGNING_KEY_OUTPUT_FILE" && \
+ docker-php-serversideup-download "$SIGNING_KEY_URL" | gpg --dearmor | tee "$SIGNING_KEY_OUTPUT_FILE" && \
\
# Verify signing key
VALID_KEY=$(gpg --dry-run --quiet --no-keyring --import --import-options import-show "$SIGNING_KEY_OUTPUT_FILE" | grep "$SIGNING_FINGERPRINT") && \
@@ -64,15 +64,15 @@ RUN \
apk add --no-cache openssl curl ca-certificates && \
\
# Set up the APK repository for stable NGINX packages
- printf "%s%s%s%s%s\n" \
+ printf "%s%s%s%s\n" \
"@nginx " \
"http://nginx.org/packages/alpine/v" \
- "$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)" \
+ "$(grep -Eo '^[0-9]+\.[0-9]+' /etc/alpine-release)" \
"/main" \
| tee -a /etc/apk/repositories && \
\
# Download the NGINX APK RSA repository key
- curl -o /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub && \
+ docker-php-serversideup-download https://nginx.org/keys/nginx_signing.rsa.pub /tmp/nginx_signing.rsa.pub && \
\
# Verify the key by pinning the SHA-256 of the DER-encoded public key.
# Allow multiple hashes (comma-separated) for rotation via build args.
diff --git a/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/user/contents.d/nginx b/src/variations/fpm-nginx/etc/s6-overlay/user-bundles.d/user/contents.d/nginx
similarity index 100%
rename from src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/user/contents.d/nginx
rename to src/variations/fpm-nginx/etc/s6-overlay/user-bundles.d/user/contents.d/nginx
diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile
index 5dc1a51dc..434eed7b1 100644
--- a/src/variations/fpm/Dockerfile
+++ b/src/variations/fpm/Dockerfile
@@ -91,7 +91,7 @@ RUN rm -rf /usr/local/etc/php-fpm.d/*.conf && \
\
# Ensure "php-fpm-healthcheck" is installed
echo "âŦī¸ Downloading php-fpm-healthcheck..." && \
- curl -o /usr/local/bin/php-fpm-healthcheck https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck && \
+ docker-php-serversideup-download https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck /usr/local/bin/php-fpm-healthcheck && \
chmod +x /usr/local/bin/php-fpm-healthcheck && \
\
# Install default PHP extensions
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index dd0d0f342..3c8d03606 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -68,13 +68,14 @@ RUN docker-php-serversideup-dep-install-alpine "$PHPIZE_DEPS ${BUILD_DEPENDENCY_
# Install e-dant/watcher (necessary for file watching)
WORKDIR /usr/local/src/watcher
-RUN curl -s https://api.github.com/repos/e-dant/watcher/releases/latest | \
- grep tarball_url | \
- awk '{ print $2 }' | \
- sed 's/,$//' | \
- sed 's/"//g' | \
- xargs curl -L | \
- tar xz --strip-components 1 && \
+RUN WATCHER_TARBALL_URL=$(docker-php-serversideup-download https://api.github.com/repos/e-dant/watcher/releases/latest | \
+ grep tarball_url | \
+ awk '{ print $2 }' | \
+ sed 's/,$//' | \
+ sed 's/"//g') && \
+ docker-php-serversideup-download "$WATCHER_TARBALL_URL" /tmp/watcher.tar.gz && \
+ tar xz --strip-components 1 -f /tmp/watcher.tar.gz && \
+ rm -f /tmp/watcher.tar.gz && \
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \
cmake --build build && \
cmake --install build && \
From c628f94abc8ec3a1d0f4c6569efe93da87c33e41 Mon Sep 17 00:00:00 2001
From: Hammed Oyedele
Date: Thu, 10 Sep 2026 17:04:50 +0100
Subject: [PATCH 27/47] Security Improvement: Block PATH_INFO bypass of storage
PHP execution rule (#691)
* fix: update security rules to prevent edge cases in PHP execution for storage directories
Expanded the regex patterns in Apache, Nginx, and Caddy configurations to block PHP file execution more reliably by accounting for trailing slashes after `.php`.
* Add storage directory and enforce PHP execution restrictions
---------
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Co-authored-by: Jay Rogers
---
scripts/test-image.sh | 12 ++++++++++++
.../etc/apache2/conf-available/security.conf | 4 ++--
.../etc/nginx/site-opts.d/http.conf.template | 8 ++++----
.../etc/nginx/site-opts.d/https.conf.template | 8 ++++----
src/variations/frankenphp/etc/frankenphp/Caddyfile | 4 ++--
5 files changed, 24 insertions(+), 12 deletions(-)
diff --git a/scripts/test-image.sh b/scripts/test-image.sh
index 25e92045f..dc4c7163f 100755
--- a/scripts/test-image.sh
+++ b/scripts/test-image.sh
@@ -72,6 +72,10 @@ if [ -n "$http_port" ]; then
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"
run_args+=(--publish "127.0.0.1::${http_port}" --volume "$web_dir:$web_root:ro")
fi
@@ -120,3 +124,11 @@ if [ "$body" != "serversideup-php-ok:${php_version}" ]; then
fail "Web server did not serve index.php on port ${http_port}. Response: ${body:-}"
fi
pass "Web server serves PHP on port ${http_port}"
+
+# Uploaded PHP files under /storage must never run, including through PATH_INFO
+# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute.
+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
+pass "Web server blocks PHP execution under /storage"
diff --git a/src/variations/fpm-apache/etc/apache2/conf-available/security.conf b/src/variations/fpm-apache/etc/apache2/conf-available/security.conf
index 217b697b7..cab741f5c 100644
--- a/src/variations/fpm-apache/etc/apache2/conf-available/security.conf
+++ b/src/variations/fpm-apache/etc/apache2/conf-available/security.conf
@@ -57,7 +57,7 @@ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains
# Block PHP execution in storage directory to prevent uploaded malicious PHP files from running
# Reference: Livewire arbitrary file upload (GHSA-29cq-5w36-x7w3)
-
+
Require all denied
@@ -80,4 +80,4 @@ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains
Require all denied
# allow from xxx.xxx.xxx.xxx
-
\ No newline at end of file
+
diff --git a/src/variations/fpm-nginx/etc/nginx/site-opts.d/http.conf.template b/src/variations/fpm-nginx/etc/nginx/site-opts.d/http.conf.template
index 02d9c54fb..196bf7bfb 100644
--- a/src/variations/fpm-nginx/etc/nginx/site-opts.d/http.conf.template
+++ b/src/variations/fpm-nginx/etc/nginx/site-opts.d/http.conf.template
@@ -15,7 +15,7 @@ absolute_redirect off;
# Healthcheck: Set /healthcheck to be the static health check URL
location /healthcheck {
access_log off;
-
+
# set max 5 seconds for healthcheck
fastcgi_read_timeout 5s;
@@ -32,7 +32,7 @@ location / {
# Block PHP execution in storage directory to prevent uploaded malicious PHP files from running
# Reference: Livewire arbitrary file upload (GHSA-29cq-5w36-x7w3)
-location ~* ^/storage/.*\.php$ {
+location ~* ^/storage/.*\.php(?:/|$) {
deny all;
}
@@ -47,7 +47,7 @@ location ~ \.php$ {
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
- fastcgi_buffers $NGINX_FASTCGI_BUFFERS;
+ fastcgi_buffers $NGINX_FASTCGI_BUFFERS;
fastcgi_buffer_size $NGINX_FASTCGI_BUFFER_SIZE;
fastcgi_read_timeout $PHP_MAX_EXECUTION_TIME;
}
@@ -56,4 +56,4 @@ location ~ \.php$ {
include /etc/nginx/trusted-proxy/${TRUSTED_PROXY}.conf;
# additional config
-include /etc/nginx/server-opts.d/*.conf;
\ No newline at end of file
+include /etc/nginx/server-opts.d/*.conf;
diff --git a/src/variations/fpm-nginx/etc/nginx/site-opts.d/https.conf.template b/src/variations/fpm-nginx/etc/nginx/site-opts.d/https.conf.template
index 547bfe1b3..a1daf51ba 100644
--- a/src/variations/fpm-nginx/etc/nginx/site-opts.d/https.conf.template
+++ b/src/variations/fpm-nginx/etc/nginx/site-opts.d/https.conf.template
@@ -21,7 +21,7 @@ absolute_redirect off;
# Healthcheck: Set /healthcheck to be the static health check URL
location /healthcheck {
access_log off;
-
+
# set max 5 seconds for healthcheck
fastcgi_read_timeout 5s;
@@ -38,7 +38,7 @@ location / {
# Block PHP execution in storage directory to prevent uploaded malicious PHP files from running
# Reference: Livewire arbitrary file upload (GHSA-29cq-5w36-x7w3)
-location ~* ^/storage/.*\.php$ {
+location ~* ^/storage/.*\.php(?:/|$) {
deny all;
}
@@ -53,7 +53,7 @@ location ~ \.php$ {
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
- fastcgi_buffers $NGINX_FASTCGI_BUFFERS;
+ fastcgi_buffers $NGINX_FASTCGI_BUFFERS;
fastcgi_buffer_size $NGINX_FASTCGI_BUFFER_SIZE;
fastcgi_read_timeout $PHP_MAX_EXECUTION_TIME;
}
@@ -62,4 +62,4 @@ location ~ \.php$ {
include /etc/nginx/trusted-proxy/${TRUSTED_PROXY}.conf;
# additional config
-include /etc/nginx/server-opts.d/*.conf;
\ No newline at end of file
+include /etc/nginx/server-opts.d/*.conf;
diff --git a/src/variations/frankenphp/etc/frankenphp/Caddyfile b/src/variations/frankenphp/etc/frankenphp/Caddyfile
index 704cee3dd..a32089016 100644
--- a/src/variations/frankenphp/etc/frankenphp/Caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/Caddyfile
@@ -72,7 +72,7 @@
file_server
import performance
- import security
+ import security
{$CADDY_SERVER_EXTRA_DIRECTIVES}
}
@@ -115,7 +115,7 @@
# Block PHP execution in storage directory to prevent uploaded malicious PHP files from running
# Reference: Livewire arbitrary file upload (GHSA-29cq-5w36-x7w3)
- @storage-php path_regexp ^/storage/.*\.php$
+ @storage-php path_regexp ^/storage/.*\.php(?:/|$)
respond @storage-php 403
# Block access to files that may expose sensitive information
From 24e60f99d84df9183b84602adfd165dfc1deda38 Mon Sep 17 00:00:00 2001
From: Lorenzo Rogai
Date: Thu, 10 Sep 2026 18:48:27 +0200
Subject: [PATCH 28/47] Prevent S6 startup races: services wait for their
config oneshots and depend on base (#689)
* fix(s6): make web services wait for their config oneshots to fix root-mode startup race
When a container built on the s6 images runs as root, php-fpm and the web
server (nginx/apache2) are brought up in parallel with the entrypoint
oneshots that configure them, because the long-running services have no
dependency on those oneshots.
As root this races:
- php-fpm reads its pool before `5-fpm-pool-user` appends `user`/`group`,
failing with "ALERT: [pool www] user has not been defined" ->
"ERROR: FPM initialization failed".
- the web server starts before `10-init-webserver-config` renders its
config (e.g. nginx: open() "/etc/nginx/nginx.conf" failed).
s6 restarts the crashed services so the container eventually recovers, which
is why the failure is intermittent and hard to reproduce (see discussion
#425), but it emits alarming errors, slows startup, and leaves a brief window
with no service.
docker-php-serversideup-s6-init now adds a dependency from each web service to
the entrypoint oneshot that configures it, appending to the existing flat
`dependencies` file. The oneshots are chained in alphabetical order, so
depending on one transitively waits for all earlier ones (php-fpm ->
5-fpm-pool-user; nginx/apache2 -> 10-init-webserver-config). Entries are
de-duplicated and appended newline-safely (nginx's shipped `dependencies` has
no trailing newline). Dependencies are only added when both the service and
the oneshot exist, so cli/fpm/frankenphp images and images that remove a
script are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context)
* Migrate shipped services and generated oneshots from the deprecated flat dependencies file to dependencies.d, make every user service depend on base as s6-overlay recommends, and make php-fpm, nginx, and apache2 wait for the oneshots that write their config. Fixes root-mode FPM 'user has not been defined' race (#425, #689).
Co-authored-by: LorenzoRogai <1665768+LorenzoRogai@users.noreply.github.com>
Co-authored-by: mbrodala <5037116+mbrodala@users.noreply.github.com>
---------
Co-authored-by: Lorenzo Rogai
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Co-authored-by: Jay Rogers
Co-authored-by: LorenzoRogai <1665768+LorenzoRogai@users.noreply.github.com>
Co-authored-by: mbrodala <5037116+mbrodala@users.noreply.github.com>
---
.../3.adding-your-own-start-up-scripts.md | 8 +++++-
.../{dependencies => dependencies.d/base} | 0
.../local/bin/docker-php-serversideup-s6-init | 27 +++++++++++++++----
.../s6-overlay/s6-rc.d/apache2/dependencies | 1 -
.../s6-rc.d/apache2/dependencies.d/base | 0
.../s6-rc.d/apache2/dependencies.d/php-fpm | 0
.../etc/s6-overlay/s6-rc.d/nginx/dependencies | 1 -
.../s6-rc.d/nginx/dependencies.d/base | 0
.../s6-rc.d/nginx/dependencies.d/php-fpm | 0
9 files changed, 29 insertions(+), 8 deletions(-)
rename src/s6/etc/s6-overlay/s6-rc.d/php-fpm/{dependencies => dependencies.d/base} (100%)
delete mode 100644 src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies
create mode 100644 src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies.d/base
create mode 100644 src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies.d/php-fpm
delete mode 100644 src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies
create mode 100644 src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/base
create mode 100644 src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/php-fpm
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
index 60b6a0e4d..129d69cf8 100644
--- 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
@@ -151,6 +151,12 @@ In the above file, we're copying our "one-shot" scripts to the `/etc/entrypoint.
The magic happens when we run `docker-php-serversideup-s6-init`. This script will move all our scripts from the `/etc/entrypoint.d` directory to the `/etc/s6-overlay/scripts` directory and set the correct dependencies for our S6 services.
-You can now reference our script names as dependencies in your own S6 service.
+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.
+
+```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/src/s6/etc/s6-overlay/s6-rc.d/php-fpm/dependencies b/src/s6/etc/s6-overlay/s6-rc.d/php-fpm/dependencies.d/base
similarity index 100%
rename from src/s6/etc/s6-overlay/s6-rc.d/php-fpm/dependencies
rename to src/s6/etc/s6-overlay/s6-rc.d/php-fpm/dependencies.d/base
diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-init b/src/s6/usr/local/bin/docker-php-serversideup-s6-init
index d61307140..4dcfb7fec 100644
--- a/src/s6/usr/local/bin/docker-php-serversideup-s6-init
+++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-init
@@ -27,6 +27,17 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1
fi
+# Services are skipped when they are not part of the image (e.g. nginx on an Apache image)
+add_dependency() {
+ service="$1"
+ dependency="$2"
+
+ if [ -d "${S6_HOME}/s6-rc.d/${service}" ]; then
+ mkdir -p "${S6_HOME}/s6-rc.d/${service}/dependencies.d"
+ touch "${S6_HOME}/s6-rc.d/${service}/dependencies.d/${dependency}"
+ fi
+}
+
for file in "$ENTRYPOINT_DIR"/*.sh; do
[ -e "$file" ] || continue # Skip if no files match
@@ -47,6 +58,9 @@ for file in "$ENTRYPOINT_DIR"/*.sh; do
# Place empty file in contents.d
touch "${S6_HOME}/s6-rc.d/user/contents.d/${script_name}"
+ # S6 Overlay recommends every user service depend on "base" to prevent race conditions
+ add_dependency "$script_name" base
+
# Ensure the ${S6_HOME}/scripts/ directory exists
mkdir -p "${S6_HOME}/scripts"
@@ -65,10 +79,7 @@ for file in "$ENTRYPOINT_DIR"/*.sh; do
# Check if the previous script is not the current script and set as dependency
if [ "$previous_script_name" != "$script_name" ] && [ -n "$previous_script_name" ]; then
- dependencies_file="${S6_HOME}/s6-rc.d/${script_name}/dependencies"
- touch "$dependencies_file"
- echo "$previous_script_name" >> "$dependencies_file"
- chmod 644 "$dependencies_file"
+ add_dependency "$script_name" "$previous_script_name"
fi
# Set the previous file for the next loop
@@ -77,4 +88,10 @@ for file in "$ENTRYPOINT_DIR"/*.sh; do
echo "Skipping ${script_name} because it already exists at ${S6_HOME}/scripts/${script_name}"
fi
-done
\ No newline at end of file
+done
+
+# Long-running services must wait for the oneshots that write their config, otherwise
+# they race the oneshots when the container runs as root (see #689).
+add_dependency php-fpm 5-fpm-pool-user
+add_dependency nginx 10-init-webserver-config
+add_dependency apache2 10-init-webserver-config
diff --git a/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies b/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies
deleted file mode 100644
index 54f9367ca..000000000
--- a/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies
+++ /dev/null
@@ -1 +0,0 @@
-php-fpm
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies.d/base b/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies.d/base
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies.d/php-fpm b/src/variations/fpm-apache/etc/s6-overlay/s6-rc.d/apache2/dependencies.d/php-fpm
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies b/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies
deleted file mode 100644
index 54f9367ca..000000000
--- a/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies
+++ /dev/null
@@ -1 +0,0 @@
-php-fpm
\ No newline at end of file
diff --git a/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/base b/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/base
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/php-fpm b/src/variations/fpm-nginx/etc/s6-overlay/s6-rc.d/nginx/dependencies.d/php-fpm
new file mode 100644
index 000000000..e69de29bb
From c4e801b0beef1f4f4921f56849b6ceb3afb9571f Mon Sep 17 00:00:00 2001
From: Abdulmajeed Jamaan
<41128358+Abdulmajeed-Jamaan@users.noreply.github.com>
Date: Thu, 10 Sep 2026 20:48:21 +0300
Subject: [PATCH 29/47] Add PHP_DISABLE_FUNCTIONS, PHP_HTML_ERRORS,
PHP_REALPATH_CACHE_SIZE, and PHP_SESSION_COOKIE_HTTPONLY environment
variables (#692)
* Add new configureable variables
* Reorganized variables and added documentation
* Add validation for PHP_* environment variables in php.ini
---------
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Co-authored-by: Jay Rogers
---
.../1.environment-variable-specification.md | 6 +++++-
scripts/test-image.sh | 11 +++++++++++
.../local/etc/php/conf.d/serversideup-docker-php.ini | 8 ++++----
src/variations/cli/Dockerfile | 4 ++++
src/variations/fpm-apache/Dockerfile | 4 ++++
src/variations/fpm-nginx/Dockerfile | 4 ++++
src/variations/fpm/Dockerfile | 4 ++++
src/variations/frankenphp/Dockerfile | 4 ++++
8 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/docs/content/docs/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
index fba7426c0..da54e6911 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -77,6 +77,7 @@ Setting environment variables all depends on what method you're using to run you
`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
@@ -93,6 +94,7 @@ Setting environment variables all depends on what method you're using to run you
`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
@@ -111,8 +113,10 @@ Setting environment variables all depends on what method you're using to run you
`PHP_OPCACHE_VALIDATE_TIMESTAMPS`
*Default: "1"*|Whether OPcache checks for changes to files, or requires reload of PHP to revalidate OPcache. (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: "120"*|The duration of time, in seconds for which to cache realpath information for a given file or directory. (Official docs)|all
-`PHP_SESSION_COOKIE_SECURE`
*Default: 1 (true)*|Specifies whether cookies should only be sent over secure connections. (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_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
diff --git a/scripts/test-image.sh b/scripts/test-image.sh
index dc4c7163f..fe73ae9cd 100755
--- a/scripts/test-image.sh
+++ b/scripts/test-image.sh
@@ -46,6 +46,17 @@ 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.
+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 \
+ "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions");' | tail -n1)
+[ "$ini_values" = "512M 8M 0 shell_exec" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values"
+pass "Environment variables apply to php.ini"
+
has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image")
if [ -z "$has_healthcheck" ]; then
pass "No HEALTHCHECK defined, skipping startup check"
diff --git a/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini b/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
index 36c103def..93dffa04b 100644
--- a/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
+++ b/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
@@ -320,7 +320,7 @@ open_basedir = ${PHP_OPEN_BASEDIR}
; This directive allows you to disable certain functions.
; It receives a comma-delimited list of function names.
; https://php.net/disable-functions
-disable_functions =
+disable_functions = ${PHP_DISABLE_FUNCTIONS}
; This directive allows you to disable certain classes.
; It receives a comma-delimited list of class names.
@@ -348,7 +348,7 @@ disable_classes =
; the file operations performed.
; Note: if open_basedir is set, the cache is disabled
; https://php.net/realpath-cache-size
-;realpath_cache_size = 4096k
+realpath_cache_size = ${PHP_REALPATH_CACHE_SIZE}
; Duration of time, in seconds for which to cache realpath information for a given
; file or directory. For systems with rarely changing files, consider increasing this
@@ -563,7 +563,7 @@ report_memleaks = On
; the error message is formatted as HTML or not.
; Note: This directive is hardcoded to Off for the CLI SAPI
; https://php.net/html-errors
-;html_errors = On
+html_errors = ${PHP_HTML_ERRORS}
; If html_errors is set to On *and* docref_root is not empty, then PHP
; produces clickable error messages that direct to a page describing the error
@@ -1425,7 +1425,7 @@ session.cookie_domain =
; Whether or not to add the httpOnly flag to the cookie, which makes it
; inaccessible to browser scripting languages such as JavaScript.
; https://php.net/session.cookie-httponly
-session.cookie_httponly =
+session.cookie_httponly = ${PHP_SESSION_COOKIE_HTTPONLY}
; Add SameSite attribute to cookie to help mitigate Cross-Site Request Forgery (CSRF/XSRF)
; Current valid values are "Strict", "Lax" or "None". When using "None",
diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile
index b85eb1fb4..97358cf5e 100644
--- a/src/variations/cli/Dockerfile
+++ b/src/variations/cli/Dockerfile
@@ -30,11 +30,13 @@ ENV APP_BASE_DIR=/var/www/html \
DISABLE_DEFAULT_CONFIG=false \
LOG_OUTPUT_LEVEL=warn \
PHP_DATE_TIMEZONE="UTC" \
+ PHP_DISABLE_FUNCTIONS="" \
PHP_DISPLAY_ERRORS=Off \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
PHP_FILE_UPLOADS="On" \
+ PHP_HTML_ERRORS="On" \
PHP_MAX_EXECUTION_TIME="99" \
PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
@@ -53,7 +55,9 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
+ PHP_REALPATH_CACHE_SIZE="4096K" \
PHP_REALPATH_CACHE_TTL="120" \
+ PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
PHP_ZEND_DETECT_UNICODE="" \
diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile
index c84e83745..240c23f73 100644
--- a/src/variations/fpm-apache/Dockerfile
+++ b/src/variations/fpm-apache/Dockerfile
@@ -58,6 +58,7 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
HEALTHCHECK_PATH="/healthcheck" \
LOG_OUTPUT_LEVEL=warn \
PHP_DATE_TIMEZONE="UTC" \
+ PHP_DISABLE_FUNCTIONS="" \
PHP_DISPLAY_ERRORS=Off \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
@@ -72,6 +73,7 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
PHP_FPM_PM_STATUS_PATH="/status" \
PHP_FPM_POOL_NAME="www" \
PHP_FPM_PROCESS_CONTROL_TIMEOUT="10s" \
+ PHP_HTML_ERRORS="On" \
PHP_MAX_EXECUTION_TIME="99" \
PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
@@ -90,7 +92,9 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
+ PHP_REALPATH_CACHE_SIZE="4096K" \
PHP_REALPATH_CACHE_TTL="120" \
+ PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
PHP_ZEND_DETECT_UNICODE="" \
diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile
index a35cec8b2..fcbdb9d92 100644
--- a/src/variations/fpm-nginx/Dockerfile
+++ b/src/variations/fpm-nginx/Dockerfile
@@ -132,6 +132,7 @@ ENV APP_BASE_DIR=/var/www/html \
NGINX_WEBROOT=/var/www/html/public \
NGINX_CLIENT_MAX_BODY_SIZE="100M" \
PHP_DATE_TIMEZONE="UTC" \
+ PHP_DISABLE_FUNCTIONS="" \
PHP_DISPLAY_ERRORS=Off \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
@@ -146,6 +147,7 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_FPM_PM_STATUS_PATH="/status" \
PHP_FPM_POOL_NAME="www" \
PHP_FPM_PROCESS_CONTROL_TIMEOUT="10s" \
+ PHP_HTML_ERRORS="On" \
PHP_MAX_EXECUTION_TIME="99" \
PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
@@ -164,7 +166,9 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
+ PHP_REALPATH_CACHE_SIZE="4096K" \
PHP_REALPATH_CACHE_TTL="120" \
+ PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
PHP_ZEND_DETECT_UNICODE="" \
diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile
index 434eed7b1..bea96aa4f 100644
--- a/src/variations/fpm/Dockerfile
+++ b/src/variations/fpm/Dockerfile
@@ -30,6 +30,7 @@ ENV APP_BASE_DIR=/var/www/html \
DISABLE_DEFAULT_CONFIG=false \
LOG_OUTPUT_LEVEL=warn \
PHP_DATE_TIMEZONE="UTC" \
+ PHP_DISABLE_FUNCTIONS="" \
PHP_DISPLAY_ERRORS=Off \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
@@ -44,6 +45,7 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_FPM_PM_STATUS_PATH="/status" \
PHP_FPM_POOL_NAME="www" \
PHP_FPM_PROCESS_CONTROL_TIMEOUT="10s" \
+ PHP_HTML_ERRORS="On" \
PHP_MAX_EXECUTION_TIME="99" \
PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
@@ -62,7 +64,9 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
+ PHP_REALPATH_CACHE_SIZE="4096K" \
PHP_REALPATH_CACHE_TTL="120" \
+ PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=Off \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
PHP_ZEND_DETECT_UNICODE="" \
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index 3c8d03606..364fa30a0 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -150,11 +150,13 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
LOG_OUTPUT_LEVEL=info \
HEALTHCHECK_PATH="/healthcheck" \
PHP_DATE_TIMEZONE="UTC" \
+ PHP_DISABLE_FUNCTIONS="" \
PHP_DISPLAY_ERRORS=Off \
PHP_DISPLAY_STARTUP_ERRORS=Off \
PHP_ERROR_LOG="/dev/stderr" \
PHP_ERROR_REPORTING="22527" \
PHP_FILE_UPLOADS="On" \
+ PHP_HTML_ERRORS="On" \
PHP_MAX_EXECUTION_TIME="99" \
PHP_MAX_FILE_UPLOADS="20" \
PHP_MAX_INPUT_TIME="-1" \
@@ -173,7 +175,9 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
+ PHP_REALPATH_CACHE_SIZE="4096K" \
PHP_REALPATH_CACHE_TTL="120" \
+ PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
PHP_ZEND_DETECT_UNICODE="" \
From 06a660a3864c5c1a685d3eeb7e836768a52dfd1c Mon Sep 17 00:00:00 2001
From: Abdulmajeed Jamaan
<41128358+Abdulmajeed-Jamaan@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:28:36 +0300
Subject: [PATCH 30/47] Fix FRANKENPHP_CONFIG environment variable typo in
FrankenPHP Dockerfile (#702)
The Dockerfile declared FRANKEN_PHP_CONFIG, but the Caddyfile and the
docs reference FRANKENPHP_CONFIG. Rename the ENV so the default matches
the variable Caddy actually reads.
---
src/variations/frankenphp/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index 364fa30a0..29a63c595 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -146,7 +146,7 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
COMPOSER_HOME=/composer \
COMPOSER_MAX_PARALLEL_HTTP=24 \
DISABLE_DEFAULT_CONFIG=false \
- FRANKEN_PHP_CONFIG="" \
+ FRANKENPHP_CONFIG="" \
LOG_OUTPUT_LEVEL=info \
HEALTHCHECK_PATH="/healthcheck" \
PHP_DATE_TIMEZONE="UTC" \
From 59b05055ad391730c9cb8c6ba0827257a8ebc2ed Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Thu, 10 Sep 2026 19:58:04 +0000
Subject: [PATCH 31/47] Refactor trusted proxy configurations to use
InternalProxy for private ranges and implement strict header processing for
enhanced security. Fixes #703
---
.../5.guide/4.configuring-trusted-proxies.md | 20 +++++++++++++------
.../etc/apache2/trusted-proxy/cloudflare.conf | 11 +++++++---
.../etc/apache2/trusted-proxy/local.conf | 14 +++++++------
.../etc/apache2/trusted-proxy/sucuri.conf | 11 +++++++---
.../etc/nginx/trusted-proxy/local.conf | 4 +++-
.../etc/nginx/trusted-proxy/sucuri.conf | 4 +++-
.../frankenphp/etc/frankenphp/Caddyfile | 2 ++
.../trusted-proxy/cloudflare.caddyfile | 2 ++
.../frankenphp/trusted-proxy/local.caddyfile | 2 ++
.../frankenphp/trusted-proxy/sucuri.caddyfile | 2 ++
10 files changed, 52 insertions(+), 20 deletions(-)
diff --git a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
index e076b321c..10587917c 100644
--- a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
+++ b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
@@ -140,6 +140,8 @@ When `TRUSTED_PROXY=cloudflare`:
- 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
@@ -162,6 +164,8 @@ Incorrectly configuring trusted proxies can allow attackers to spoof their IP ad
- **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
@@ -256,6 +260,7 @@ 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;
```
::
@@ -293,12 +298,14 @@ services:
RemoteIPHeader X-Forwarded-For
# Configure docker networks
-RemoteIPTrustedProxy 10.0.0.0/8
-RemoteIPTrustedProxy 172.16.0.0/12
-RemoteIPTrustedProxy 192.168.0.0/16
-RemoteIPTrustedProxy 127.0.0.1/8
-RemoteIPTrustedProxy ::1
-RemoteIPTrustedProxy fd00::/8
+# 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.1/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)
@@ -344,6 +351,7 @@ 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/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
index 9d58bc45b..b24e21087 100644
--- a/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
@@ -32,6 +32,11 @@ RemoteIPTrustedProxy 2a06:98c0::/29
RemoteIPTrustedProxy 2c0f:f248::/32
# Configure docker networks
-RemoteIPTrustedProxy 10.0.0.0/8
-RemoteIPTrustedProxy 172.16.0.0/12
-RemoteIPTrustedProxy 192.168.0.0/16
\ No newline at end of file
+# These use InternalProxy because mod_remoteip refuses to resolve a client IP
+# through a TrustedProxy hop that is itself a private address.
+RemoteIPInternalProxy 10.0.0.0/8
+RemoteIPInternalProxy 172.16.0.0/12
+RemoteIPInternalProxy 192.168.0.0/16
+RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy ::1
+RemoteIPInternalProxy fd00::/8
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
index 417f20553..e57cf630c 100644
--- a/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
@@ -6,9 +6,11 @@
RemoteIPHeader X-Forwarded-For
# Configure docker networks
-RemoteIPTrustedProxy 10.0.0.0/8
-RemoteIPTrustedProxy 172.16.0.0/12
-RemoteIPTrustedProxy 192.168.0.0/16
-RemoteIPTrustedProxy 127.0.0.1/8
-RemoteIPTrustedProxy ::1
-RemoteIPTrustedProxy fd00::/8
\ No newline at end of file
+# These use InternalProxy because mod_remoteip refuses to resolve a client IP
+# through a TrustedProxy hop that is itself a private address.
+RemoteIPInternalProxy 10.0.0.0/8
+RemoteIPInternalProxy 172.16.0.0/12
+RemoteIPInternalProxy 192.168.0.0/16
+RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy ::1
+RemoteIPInternalProxy fd00::/8
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
index 24917082a..9611fa0f0 100644
--- a/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
@@ -9,9 +9,14 @@ RemoteIPHeader X-Forwarded-For
# IP source: https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/
# Configure docker networks
-RemoteIPTrustedProxy 10.0.0.0/8
-RemoteIPTrustedProxy 172.16.0.0/12
-RemoteIPTrustedProxy 192.168.0.0/16
+# These use InternalProxy because mod_remoteip refuses to resolve a client IP
+# through a TrustedProxy hop that is itself a private address.
+RemoteIPInternalProxy 10.0.0.0/8
+RemoteIPInternalProxy 172.16.0.0/12
+RemoteIPInternalProxy 192.168.0.0/16
+RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy ::1
+RemoteIPInternalProxy fd00::/8
# Allow Sucuri's IP addresses
RemoteIPTrustedProxy 192.88.134.0/23
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
index 86563705e..ec2a2d1d5 100644
--- a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
@@ -11,4 +11,6 @@ set_real_ip_from ::1;
set_real_ip_from fd00::/8;
# Set RealIP header
-real_ip_header X-Forwarded-For;
\ No newline at end of file
+real_ip_header X-Forwarded-For;
+# Walk the full X-Forwarded-For chain instead of trusting only the last entry
+real_ip_recursive on;
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
index 11aedd1c0..0192cd67e 100644
--- a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
@@ -19,4 +19,6 @@ set_real_ip_from 2a02:fe80::/29;
set_real_ip_from 208.109.0.0/22;
# Set RealIP header
-real_ip_header X-Forwarded-For;
\ No newline at end of file
+real_ip_header X-Forwarded-For;
+# Walk the full X-Forwarded-For chain instead of trusting only the last entry
+real_ip_recursive on;
diff --git a/src/variations/frankenphp/etc/frankenphp/Caddyfile b/src/variations/frankenphp/etc/frankenphp/Caddyfile
index a32089016..512719477 100644
--- a/src/variations/frankenphp/etc/frankenphp/Caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/Caddyfile
@@ -67,6 +67,8 @@
redir @indexphp {re.indexphp.1} 301
php_server {
+ # FrankenPHP sets REMOTE_ADDR from the TCP peer, which ignores trusted_proxies
+ env REMOTE_ADDR {client_ip}
{$CADDY_PHP_SERVER_OPTIONS}
}
file_server
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
index 55d6bcdda..5f535a1cf 100644
--- a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
@@ -31,6 +31,8 @@ fd00::/8 \
2a06:98c0::/29 \
2c0f:f248::/32
+ # Walk the header right-to-left past trusted proxies instead of taking the first (client-supplied) entry
+ trusted_proxies_strict
# Prefer Cloudflare's header; keep XFF as fallback
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
index 13a870c12..f9d191877 100644
--- a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
@@ -8,5 +8,7 @@ servers {
::1 \
fd00::/8
+ # Walk the header right-to-left past trusted proxies instead of taking the first (client-supplied) entry
+ trusted_proxies_strict
client_ip_headers X-Forwarded-For
}
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
index 5de970404..9a900e6c4 100644
--- a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
@@ -14,5 +14,7 @@ fd00::/8 \
2a02:fe80::/29 \
208.109.0.0/22
+ # Walk the header right-to-left past trusted proxies instead of taking the first (client-supplied) entry
+ trusted_proxies_strict
client_ip_headers X-Forwarded-For
}
From ed8141bb9d1445bbabaee46c769aad96e215fdcb Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Fri, 11 Sep 2026 16:51:33 -0500
Subject: [PATCH 32/47] Enhance OPcache configuration and documentation (#704)
* Enhance OPcache configuration and documentation
- Updated default OPcache settings for production mode in various documentation files, emphasizing the importance of `PHP_OPCACHE_ENABLE` and its impact on performance.
- Added a new guide on PHP OPcache tuning, detailing how to measure and adjust OPcache settings for optimal application performance.
- Clarified the behavior of `PHP_OPCACHE_VALIDATE_TIMESTAMPS` and its implications for development and production environments.
- Adjusted Dockerfiles across multiple variations to reflect new OPcache defaults, including increased memory consumption and maximum accelerated files.
- Improved scripts and entrypoint messages to provide clearer guidance on OPcache usage and best practices for deployment.
* Refactor OPcache status retrieval and update production mode notice for clarity
* Update migration and tuning documentation for OPcache improvements
* Refactor OPcache documentation: update migration guide and add production performance tuning guide
* Enhance OPcache configuration and documentation: update defaults, improve CLI handling, and clarify production settings
* Update OPcache notice link for performance tuning documentation
* Refine OPcache performance tuning guide: remove placeholder text and clarify custom ini file options
* Clarify OPcache explanation in production performance tuning guide
---
.../4.these-images-vs-others.md | 4 +-
.../6.default-configurations.md | 2 +-
.../docs/2.image-variations/frankenphp.md | 4 +-
.../4.using-wordpress-with-docker.md | 4 +
.../5.guide/5.major-version-migrations.md | 63 ++++++
.../6.production-performance-tuning.md | 189 ++++++++++++++++++
.../1.environment-variable-specification.md | 23 ++-
scripts/test-image.sh | 17 +-
.../etc/entrypoint.d/0-container-info.sh | 3 +
.../php/conf.d/serversideup-docker-php.ini | 11 +-
src/variations/cli/Dockerfile | 13 +-
src/variations/fpm-apache/Dockerfile | 13 +-
src/variations/fpm-nginx/Dockerfile | 13 +-
src/variations/fpm/Dockerfile | 13 +-
src/variations/frankenphp/Dockerfile | 13 +-
15 files changed, 337 insertions(+), 48 deletions(-)
create mode 100644 docs/content/docs/5.guide/6.production-performance-tuning.md
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 f0c70f0ce..c309271b4 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
@@ -77,11 +77,11 @@ We also include additional security hardening:
### Performance Optimized
-Every image includes production-tuned defaults based on real-world PHP applications:
+Every image ships tuned defaults for PHP, OPcache, and PHP-FPM:
**OPcache Configuration**
- Pre-configured for optimal memory usage and caching strategy
-- Easily toggle between development and production modes
+- One variable, `PHP_OPCACHE_ENABLE`, turns OPcache on with tuned defaults
- Smart defaults that work for most applications
**Process Management**
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 609ee6696..5541c2aee 100644
--- a/docs/content/docs/1.getting-started/6.default-configurations.md
+++ b/docs/content/docs/1.getting-started/6.default-configurations.md
@@ -84,7 +84,7 @@ 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 but can easily be enabled with [`PHP_OPCACHE_ENABLE=1`](/docs/reference/environment-variable-specification).|
+| [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. |
diff --git a/docs/content/docs/2.image-variations/frankenphp.md b/docs/content/docs/2.image-variations/frankenphp.md
index f432b9277..936bd7762 100644
--- a/docs/content/docs/2.image-variations/frankenphp.md
+++ b/docs/content/docs/2.image-variations/frankenphp.md
@@ -370,8 +370,8 @@ For a complete list of available environment variables, see the [Environment Var
| `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) |
-| `PHP_OPCACHE_VALIDATE_TIMESTAMPS` | `1` | Whether to validate timestamps (`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.
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 790ee30c5..3995e405b 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
@@ -156,6 +156,10 @@ git pull origin main
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.
::
+::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)
+::
+
### Which approach should you choose?
- **Volume-based**: Best for sites heavily dependent on third-party plugins or when you need maximum WordPress ecosystem compatibility
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
index ebc18aab8..3c60b6fb5 100644
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ b/docs/content/docs/5.guide/5.major-version-migrations.md
@@ -23,6 +23,69 @@ Debian Bullseye and Alpine 3.16 were dropped at the same time. Debian 11 reached
If you are on one of these, move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. 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).
+## Version 4 â Version 5 Migration
+Version 5 is about OPcache. Setting `PHP_OPCACHE_ENABLE=1` now gives you tuned defaults instead of PHP's stock values. There is one breaking change, and it only affects you if you run with OPcache enabled while your code is mounted as a volume.
+
+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)
+
+### Breaking changes in Version 5
+::caution
+The following change alters behavior for existing configurations that set `PHP_OPCACHE_ENABLE=1`.
+::
+
+#### `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.
+
+### 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` |
+
+### 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.
+
+[See the full list of environment variables â](/docs/reference/environment-variable-specification)
+
+### 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
+
+#### Dockerfile
+- Nothing is required
+- 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.
diff --git a/docs/content/docs/5.guide/6.production-performance-tuning.md b/docs/content/docs/5.guide/6.production-performance-tuning.md
new file mode 100644
index 000000000..1aae13fea
--- /dev/null
+++ b/docs/content/docs/5.guide/6.production-performance-tuning.md
@@ -0,0 +1,189 @@
+---
+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/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
index da54e6911..00abcfdf0 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -100,21 +100,24 @@ Setting environment variables all depends on what method you're using to run you
`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. â ī¸ This will set **both values** for `opcache.enable` and `opcache.enable_cli`. (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: "8"*|The amount of memory used to store interned strings, in megabytes. (Official docs)|all
-`PHP_OPCACHE_JIT`
*Default: "off"*|Enable or disable the JIT compiler. (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. (Official docs)|all
-`PHP_OPCACHE_MAX_ACCELERATED_FILES`
*Default: "10000"*|The maximum number of keys (scripts) in the OPcache hash table. (Official docs)|all
-`PHP_OPCACHE_MEMORY_CONSUMPTION`
*Default: "128"*|The amount of memory used by the OPcache engine, in megabytes. (Official docs)|all
-`PHP_OPCACHE_REVALIDATE_FREQ`
*Default: "2"*|How often the OPcache checks for updates to cached files (in seconds). (Official docs)|all
-`PHP_OPCACHE_SAVE_COMMENTS`
*Default: "1"*|Remove comments from OPcache to minify a bit further. Note: any code that depends on PHPDoc annotations can break from this. (Official docs)|all
-`PHP_OPCACHE_VALIDATE_TIMESTAMPS`
*Default: "1"*|Whether OPcache checks for changes to files, or requires reload of PHP to revalidate OPcache. (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: "120"*|The duration of time, in seconds for which to cache realpath information for a given file or directory. (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_UPLOAD_MAX_FILE_SIZE`
*Default: "100M"*|The maximum size of an uploaded file. (Official docs)|all
diff --git a/scripts/test-image.sh b/scripts/test-image.sh
index fe73ae9cd..0aab5bdf0 100755
--- a/scripts/test-image.sh
+++ b/scripts/test-image.sh
@@ -48,15 +48,24 @@ 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 \
- "$image" php -r 'echo ini_get("memory_limit"), " ", ini_get("realpath_cache_size"), " ", ini_get("session.cookie_httponly"), " ", ini_get("disable_functions");' | tail -n1)
-[ "$ini_values" = "512M 8M 0 shell_exec" ] || fail "PHP_* environment variables did not apply to php.ini. Got: $ini_values"
+ --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"
+# 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"
+
has_healthcheck=$(docker image inspect --format '{{if .Config.Healthcheck}}yes{{end}}' "$image")
if [ -z "$has_healthcheck" ]; then
pass "No HEALTHCHECK defined, skipping startup check"
@@ -76,7 +85,9 @@ for pair in NGINX_HTTP_PORT:NGINX_WEBROOT APACHE_HTTP_PORT:APACHE_DOCUMENT_ROOT
fi
done
-run_args=(--detach --rm)
+# 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 --rm --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)
diff --git a/src/common/etc/entrypoint.d/0-container-info.sh b/src/common/etc/entrypoint.d/0-container-info.sh
index 7ef26e334..bf8124eaa 100644
--- a/src/common/etc/entrypoint.d/0-container-info.sh
+++ b/src/common/etc/entrypoint.d/0-container-info.sh
@@ -9,6 +9,7 @@ fi
# Get OPcache status
PHP_OPCACHE_STATUS=$(php -r 'echo ini_get("opcache.enable");')
+PHP_OPCACHE_VALIDATE_TIMESTAMPS_STATUS=$(php -r 'echo ini_get("opcache.validate_timestamps");')
if [ "$PHP_OPCACHE_STATUS" = "1" ]; then
PHP_OPCACHE_MESSAGE="â
Enabled"
@@ -61,4 +62,6 @@ Brought to you by serversideup.net
if [ "$PHP_OPCACHE_STATUS" = "0" ]; then
echo "đ [NOTICE]: Improve PHP performance by setting PHP_OPCACHE_ENABLE=1 (recommended for production)."
+elif [ "$PHP_OPCACHE_VALIDATE_TIMESTAMPS_STATUS" = "0" ]; then
+ echo "đ [NOTICE]: OPcache is enabled and PHP_OPCACHE_VALIDATE_TIMESTAMPS=0. Code changes require a container restart. Learn more: https://serversideup.net/docker-php/performance/"
fi
\ No newline at end of file
diff --git a/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini b/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
index 93dffa04b..e8b443c36 100644
--- a/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
+++ b/src/common/usr/local/etc/php/conf.d/serversideup-docker-php.ini
@@ -1791,8 +1791,9 @@ ldap.max_links = -1
; Determines if Zend OPCache is enabled
opcache.enable=${PHP_OPCACHE_ENABLE}
-; Determines if Zend OPCache is enabled for the CLI version of PHP
-opcache.enable_cli=${PHP_OPCACHE_ENABLE}
+; Determines if Zend OPCache is enabled for the CLI version of PHP.
+; Only takes effect when opcache.enable is also on.
+opcache.enable_cli=${PHP_OPCACHE_ENABLE_CLI}
; The OPcache shared memory storage size.
opcache.memory_consumption=${PHP_OPCACHE_MEMORY_CONSUMPTION}
@@ -1867,7 +1868,7 @@ opcache.enable_file_override=${PHP_OPCACHE_ENABLE_FILE_OVERRIDE}
; How long to wait (in seconds) for a scheduled restart to begin if the cache
; is not being accessed.
-;opcache.force_restart_timeout=180
+opcache.force_restart_timeout=${PHP_OPCACHE_FORCE_RESTART_TIMEOUT}
; OPcache error_log file name. Empty string assumes "stderr".
;opcache.error_log=
@@ -1937,12 +1938,12 @@ opcache.enable_file_override=${PHP_OPCACHE_ENABLE_FILE_OVERRIDE}
; Specifies a PHP script that is going to be compiled and executed at server
; start-up.
; https://php.net/opcache.preload
-;opcache.preload=
+opcache.preload=${PHP_OPCACHE_PRELOAD}
; Preloading code as root is not allowed for security reasons. This directive
; facilitates to let the preloading to be run as another user.
; https://php.net/opcache.preload_user
-;opcache.preload_user=
+opcache.preload_user=${PHP_OPCACHE_PRELOAD_USER}
; Prevents caching files that are less than this number of seconds old. It
; protects from caching of incompletely updated files. In case all file updates
diff --git a/src/variations/cli/Dockerfile b/src/variations/cli/Dockerfile
index 97358cf5e..f29b7ab1d 100644
--- a/src/variations/cli/Dockerfile
+++ b/src/variations/cli/Dockerfile
@@ -43,20 +43,23 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
PHP_OPCACHE_ENABLE="0" \
+ PHP_OPCACHE_ENABLE_CLI="1" \
PHP_OPCACHE_ENABLE_FILE_OVERRIDE="0" \
PHP_OPCACHE_FORCE_RESTART_TIMEOUT="180" \
- PHP_OPCACHE_INTERNED_STRINGS_BUFFER="8" \
+ PHP_OPCACHE_INTERNED_STRINGS_BUFFER="32" \
PHP_OPCACHE_JIT="off" \
PHP_OPCACHE_JIT_BUFFER_SIZE="0" \
- PHP_OPCACHE_MAX_ACCELERATED_FILES="10000" \
- PHP_OPCACHE_MEMORY_CONSUMPTION="128" \
+ PHP_OPCACHE_MAX_ACCELERATED_FILES="32531" \
+ PHP_OPCACHE_MEMORY_CONSUMPTION="256" \
+ PHP_OPCACHE_PRELOAD="" \
+ PHP_OPCACHE_PRELOAD_USER="" \
PHP_OPCACHE_REVALIDATE_FREQ="2" \
PHP_OPCACHE_SAVE_COMMENTS="1" \
- PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
+ PHP_OPCACHE_VALIDATE_TIMESTAMPS="0" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
PHP_REALPATH_CACHE_SIZE="4096K" \
- PHP_REALPATH_CACHE_TTL="120" \
+ PHP_REALPATH_CACHE_TTL="600" \
PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile
index 240c23f73..04e10ce54 100644
--- a/src/variations/fpm-apache/Dockerfile
+++ b/src/variations/fpm-apache/Dockerfile
@@ -80,20 +80,23 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
PHP_OPCACHE_ENABLE="0" \
+ PHP_OPCACHE_ENABLE_CLI="1" \
PHP_OPCACHE_ENABLE_FILE_OVERRIDE="0" \
PHP_OPCACHE_FORCE_RESTART_TIMEOUT="180" \
- PHP_OPCACHE_INTERNED_STRINGS_BUFFER="8" \
+ PHP_OPCACHE_INTERNED_STRINGS_BUFFER="32" \
PHP_OPCACHE_JIT="off" \
PHP_OPCACHE_JIT_BUFFER_SIZE="0" \
- PHP_OPCACHE_MAX_ACCELERATED_FILES="10000" \
- PHP_OPCACHE_MEMORY_CONSUMPTION="128" \
+ PHP_OPCACHE_MAX_ACCELERATED_FILES="32531" \
+ PHP_OPCACHE_MEMORY_CONSUMPTION="256" \
+ PHP_OPCACHE_PRELOAD="" \
+ PHP_OPCACHE_PRELOAD_USER="" \
PHP_OPCACHE_REVALIDATE_FREQ="2" \
PHP_OPCACHE_SAVE_COMMENTS="1" \
- PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
+ PHP_OPCACHE_VALIDATE_TIMESTAMPS="0" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
PHP_REALPATH_CACHE_SIZE="4096K" \
- PHP_REALPATH_CACHE_TTL="120" \
+ PHP_REALPATH_CACHE_TTL="600" \
PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile
index fcbdb9d92..e8d12a90d 100644
--- a/src/variations/fpm-nginx/Dockerfile
+++ b/src/variations/fpm-nginx/Dockerfile
@@ -154,20 +154,23 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
PHP_OPCACHE_ENABLE="0" \
+ PHP_OPCACHE_ENABLE_CLI="1" \
PHP_OPCACHE_ENABLE_FILE_OVERRIDE="0" \
PHP_OPCACHE_FORCE_RESTART_TIMEOUT="180" \
- PHP_OPCACHE_INTERNED_STRINGS_BUFFER="8" \
+ PHP_OPCACHE_INTERNED_STRINGS_BUFFER="32" \
PHP_OPCACHE_JIT="off" \
PHP_OPCACHE_JIT_BUFFER_SIZE="0" \
- PHP_OPCACHE_MAX_ACCELERATED_FILES="10000" \
- PHP_OPCACHE_MEMORY_CONSUMPTION="128" \
+ PHP_OPCACHE_MAX_ACCELERATED_FILES="32531" \
+ PHP_OPCACHE_MEMORY_CONSUMPTION="256" \
+ PHP_OPCACHE_PRELOAD="" \
+ PHP_OPCACHE_PRELOAD_USER="" \
PHP_OPCACHE_REVALIDATE_FREQ="2" \
PHP_OPCACHE_SAVE_COMMENTS="1" \
- PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
+ PHP_OPCACHE_VALIDATE_TIMESTAMPS="0" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
PHP_REALPATH_CACHE_SIZE="4096K" \
- PHP_REALPATH_CACHE_TTL="120" \
+ PHP_REALPATH_CACHE_TTL="600" \
PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
diff --git a/src/variations/fpm/Dockerfile b/src/variations/fpm/Dockerfile
index bea96aa4f..eeead1f5e 100644
--- a/src/variations/fpm/Dockerfile
+++ b/src/variations/fpm/Dockerfile
@@ -52,20 +52,23 @@ ENV APP_BASE_DIR=/var/www/html \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
PHP_OPCACHE_ENABLE="0" \
+ PHP_OPCACHE_ENABLE_CLI="1" \
PHP_OPCACHE_ENABLE_FILE_OVERRIDE="0" \
PHP_OPCACHE_FORCE_RESTART_TIMEOUT="180" \
- PHP_OPCACHE_INTERNED_STRINGS_BUFFER="8" \
+ PHP_OPCACHE_INTERNED_STRINGS_BUFFER="32" \
PHP_OPCACHE_JIT="off" \
PHP_OPCACHE_JIT_BUFFER_SIZE="0" \
- PHP_OPCACHE_MAX_ACCELERATED_FILES="10000" \
- PHP_OPCACHE_MEMORY_CONSUMPTION="128" \
+ PHP_OPCACHE_MAX_ACCELERATED_FILES="32531" \
+ PHP_OPCACHE_MEMORY_CONSUMPTION="256" \
+ PHP_OPCACHE_PRELOAD="" \
+ PHP_OPCACHE_PRELOAD_USER="" \
PHP_OPCACHE_REVALIDATE_FREQ="2" \
PHP_OPCACHE_SAVE_COMMENTS="1" \
- PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
+ PHP_OPCACHE_VALIDATE_TIMESTAMPS="0" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
PHP_REALPATH_CACHE_SIZE="4096K" \
- PHP_REALPATH_CACHE_TTL="120" \
+ PHP_REALPATH_CACHE_TTL="600" \
PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=Off \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index 29a63c595..2ec91e048 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -163,20 +163,23 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
PHP_MAX_INPUT_VARS="1000" \
PHP_MEMORY_LIMIT="256M" \
PHP_OPCACHE_ENABLE="0" \
+ PHP_OPCACHE_ENABLE_CLI="1" \
PHP_OPCACHE_ENABLE_FILE_OVERRIDE="0" \
PHP_OPCACHE_FORCE_RESTART_TIMEOUT="180" \
- PHP_OPCACHE_INTERNED_STRINGS_BUFFER="8" \
+ PHP_OPCACHE_INTERNED_STRINGS_BUFFER="32" \
PHP_OPCACHE_JIT="off" \
PHP_OPCACHE_JIT_BUFFER_SIZE="0" \
- PHP_OPCACHE_MAX_ACCELERATED_FILES="10000" \
- PHP_OPCACHE_MEMORY_CONSUMPTION="128" \
+ PHP_OPCACHE_MAX_ACCELERATED_FILES="32531" \
+ PHP_OPCACHE_MEMORY_CONSUMPTION="256" \
+ PHP_OPCACHE_PRELOAD="" \
+ PHP_OPCACHE_PRELOAD_USER="" \
PHP_OPCACHE_REVALIDATE_FREQ="2" \
PHP_OPCACHE_SAVE_COMMENTS="1" \
- PHP_OPCACHE_VALIDATE_TIMESTAMPS="1" \
+ PHP_OPCACHE_VALIDATE_TIMESTAMPS="0" \
PHP_OPEN_BASEDIR="" \
PHP_POST_MAX_SIZE="100M" \
PHP_REALPATH_CACHE_SIZE="4096K" \
- PHP_REALPATH_CACHE_TTL="120" \
+ PHP_REALPATH_CACHE_TTL="600" \
PHP_SESSION_COOKIE_HTTPONLY="On" \
PHP_SESSION_COOKIE_SECURE=false \
PHP_UPLOAD_MAX_FILE_SIZE="100M" \
From 5379ee6ae06919fc12b60d0964b3b01e8f8e3003 Mon Sep 17 00:00:00 2001
From: Robin Lyons <39706150+aSeriousDeveloper@users.noreply.github.com>
Date: Wed, 16 Sep 2026 21:16:01 +0100
Subject: [PATCH 33/47] Provide further Octane support for Caddyfile (#604)
* Provide further Octane support for Caddyfile
Specify the `CADDY_SERVER_WORKER_DIRECTIVE` and `CADDY_SERVER_WATCH_DIRECTIVES` directives that are populated by Laravel Octane
* Enhance FrankenPHP integration with Laravel Octane
- Updated environment variable documentation to clarify the behavior of CADDY_ADMIN, CADDY_GLOBAL_OPTIONS, CADDY_LOG_FORMAT, and CADDY_LOG_OUTPUT when using Laravel Octane.
- Added a new health check command for Octane to verify server status.
- Refactored the test image script to handle multiple containers and improve health check logic.
- Modified SSL generation script to account for FrankenPHP's unique requirements.
- Adjusted Dockerfile to set default logging format and output for better compatibility with Octane.
- Enhanced Caddyfile configuration to support Octane's worker and global options.
- Introduced new log format files to handle authorization redaction in logs.
- Created README files in configuration directories to guide users on custom Caddyfile usage.
- Implemented separate Caddyfile configurations for Octane to manage worker and global settings effectively.
* Update FrankenPHP dependencies in Dockerfile to match specific plugin versions
---------
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Co-authored-by: Jay Rogers
Co-authored-by: Robin Lyons <39706150+aSeriousDeveloper@users.noreply.github.com>
Co-authored-by: kohenkatz <88755+kohenkatz@users.noreply.github.com>
---
.../6.default-configurations.md | 22 +++
.../docs/2.image-variations/frankenphp.md | 25 ++-
.../3.framework-guides/1.laravel/octane.md | 132 +++++++++++---
.../5.guide/5.major-version-migrations.md | 31 +++-
.../1.environment-variable-specification.md | 13 +-
.../docs/8.reference/2.command-reference.md | 5 +
scripts/test-image.sh | 165 ++++++++++++++----
.../etc/entrypoint.d/5-generate-ssl.sh | 9 +-
src/variations/frankenphp/Dockerfile | 15 +-
.../frankenphp/etc/frankenphp/Caddyfile | 8 +-
.../caddyfile-global.d/README.caddyfile | 7 +
.../frankenphp/caddyfile.d/README.caddyfile | 7 +
.../laravel-octane/frankenphp/0.caddyfile | 1 +
.../laravel-octane/frankenphp/1.caddyfile | 9 +
.../laravel-octane/global/0.caddyfile | 3 +
.../laravel-octane/global/1.caddyfile | 7 +
.../laravel-octane/php-server/0.caddyfile | 1 +
.../laravel-octane/php-server/1.caddyfile | 4 +
.../etc/frankenphp/log-format/auto.caddyfile | 9 +
.../frankenphp/log-format/console.caddyfile | 7 +
.../etc/frankenphp/log-format/json.caddyfile | 7 +
.../log-level/address/alert.caddyfile | 4 +-
.../log-level/address/crit.caddyfile | 4 +-
.../log-level/address/debug.caddyfile | 4 +-
.../log-level/address/emerg.caddyfile | 4 +-
.../log-level/address/error.caddyfile | 4 +-
.../log-level/address/info.caddyfile | 4 +-
.../log-level/address/notice.caddyfile | 4 +-
.../log-level/address/warn.caddyfile | 4 +-
.../log-level/global/alert.caddyfile | 4 +-
.../log-level/global/crit.caddyfile | 4 +-
.../log-level/global/debug.caddyfile | 4 +-
.../log-level/global/emerg.caddyfile | 4 +-
.../log-level/global/error.caddyfile | 4 +-
.../log-level/global/info.caddyfile | 4 +-
.../log-level/global/notice.caddyfile | 4 +-
.../log-level/global/warn.caddyfile | 4 +-
37 files changed, 442 insertions(+), 109 deletions(-)
create mode 100644 src/variations/frankenphp/etc/frankenphp/caddyfile-global.d/README.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/caddyfile.d/README.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/0.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/1.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/laravel-octane/global/0.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/laravel-octane/global/1.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/0.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/1.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/log-format/auto.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/log-format/console.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/log-format/json.caddyfile
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 5541c2aee..e9509f253 100644
--- a/docs/content/docs/1.getting-started/6.default-configurations.md
+++ b/docs/content/docs/1.getting-started/6.default-configurations.md
@@ -134,6 +134,28 @@ The `cli` variation does not have a health check because it doesn't really make
: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"}
+## 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.
+
+Which stream a log lands on depends on the web server, and in each case we follow the official Docker image for that server:
+
+| 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`. |
+
+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`.
+
+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`.
+
+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.
+
+: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"}
+
+
## 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.
diff --git a/docs/content/docs/2.image-variations/frankenphp.md b/docs/content/docs/2.image-variations/frankenphp.md
index 936bd7762..49c4fdc11 100644
--- a/docs/content/docs/2.image-variations/frankenphp.md
+++ b/docs/content/docs/2.image-variations/frankenphp.md
@@ -261,7 +261,7 @@ The FrankenPHP variation uses ports 8080 and 8443 (instead of 80 and 443) to all
::
### Laravel Octane
-Laravel Octane natively supports FrankenPHP. Use our guide below to learn more.
+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"}
@@ -331,6 +331,25 @@ Learn more about SSL modes in the [Configuring SSL](/docs/deployment-and-product
: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 subscribers pass in the URL](https://mercure.rocks/spec#authorization){target="_blank"} 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.
+
## Environment Variables
The FrankenPHP variation supports extensive customization through environment variables.
@@ -345,8 +364,8 @@ The FrankenPHP variation supports extensive customization through environment va
| `CADDY_HTTP_PORT` | `8080` | HTTP port |
| `CADDY_HTTPS_PORT` | `8443` | HTTPS port |
| `CADDY_ADMIN` | `off` | Caddy admin API endpoint |
-| `CADDY_LOG_FORMAT` | `console` | Log format (`console`/`json`) |
-| `CADDY_LOG_OUTPUT` | `stdout` | Log output destination |
+| `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` |
diff --git a/docs/content/docs/3.framework-guides/1.laravel/octane.md b/docs/content/docs/3.framework-guides/1.laravel/octane.md
index 81c150815..1cf39e93d 100644
--- a/docs/content/docs/3.framework-guides/1.laravel/octane.md
+++ b/docs/content/docs/3.framework-guides/1.laravel/octane.md
@@ -47,35 +47,34 @@ We'll expand upon this classic mode file and modify it to run Laravel Octane (wh
### Install Laravel Octane
-First, install Octane in your Laravel application:
+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
```
-When that command runs, you should see a PHP file that Laravel creates in your `/public` directory. This is required for Laravel Octane to work.
+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.
+
## 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/12.x/octane#dependency-injection-and-octane) documentation for details.
+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/12.x/octane#managing-memory-leaks) to understand what to avoid.
+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/12.x/octane)
-- [FrankenPHP Documentation](https://frankenphp.dev/docs/)
\ No newline at end of file
+- [Laravel Octane Documentation](https://laravel.com/docs/13.x/octane)
+- [FrankenPHP Documentation](https://frankenphp.dev/docs/)
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
index 3c60b6fb5..b17d2a0c5 100644
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ b/docs/content/docs/5.guide/5.major-version-migrations.md
@@ -24,7 +24,7 @@ Debian Bullseye and Alpine 3.16 were dropped at the same time. Debian 11 reached
If you are on one of these, move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. 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).
## Version 4 â Version 5 Migration
-Version 5 is about OPcache. Setting `PHP_OPCACHE_ENABLE=1` now gives you tuned defaults instead of PHP's stock values. There is one breaking change, and it only affects you if you run with OPcache enabled while your code is mounted as a volume.
+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, and FrankenPHP logs follow Caddy's defaults for stream and format. There are two breaking changes. One affects you if you run with OPcache enabled while your code is mounted as a volume. The other affects you if something in your setup reads FrankenPHP's `stdout` and `stderr` separately, or parses its `console` log lines.
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).
@@ -37,9 +37,12 @@ services:
### 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
-The following change alters behavior for existing configurations that set `PHP_OPCACHE_ENABLE=1`.
+The following changes alter behavior for existing configurations that set `PHP_OPCACHE_ENABLE=1`, that consume FrankenPHP's `stdout` and `stderr` separately, or that parse FrankenPHP's `console` log lines.
::
#### `PHP_OPCACHE_VALIDATE_TIMESTAMPS` now defaults to `0`
@@ -53,6 +56,17 @@ You are affected if you set `PHP_OPCACHE_ENABLE=1` **and** any of these apply:
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.
+
### 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.
@@ -67,6 +81,13 @@ The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is onl
| `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"}.
@@ -74,6 +95,10 @@ The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is onl
[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)
+- **FrankenPHP redacts the `authorization` query parameter** - Request logs never contain the JWT that Mercure subscribers pass in the URL, in every log format. [Read about FrankenPHP logging â](/docs/image-variations/frankenphp#logging)
+
### V5 Migration Checklist
#### Docker Compose
@@ -81,6 +106,8 @@ The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is onl
- 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 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
#### Dockerfile
- Nothing is required
diff --git a/docs/content/docs/8.reference/1.environment-variable-specification.md b/docs/content/docs/8.reference/1.environment-variable-specification.md
index 00abcfdf0..bc1579747 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -46,18 +46,18 @@ Setting environment variables all depends on what method you're using to run you
`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. (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. (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: "console"*|Set the format for the Caddy log. (Official docs)|frankenphp
-`CADDY_LOG_OUTPUT`
*Default: "stdout"*|Set the output for the Caddy log. (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. (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. (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`. See [Laravel Octane](/docs/framework-guides/laravel/octane#octane-options). (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
@@ -67,6 +67,7 @@ Setting environment variables all depends on what method you're using to run you
`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
`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
diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md
index be62862cc..b1c1660cc 100644
--- a/docs/content/docs/8.reference/2.command-reference.md
+++ b/docs/content/docs/8.reference/2.command-reference.md
@@ -94,6 +94,11 @@ This command is used to copy our entrypoint scripts into the S6 Overlay scripts
## 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, but it only works 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.
diff --git a/scripts/test-image.sh b/scripts/test-image.sh
index 0aab5bdf0..74d8f5756 100755
--- a/scripts/test-image.sh
+++ b/scripts/test-image.sh
@@ -101,56 +101,153 @@ if [ -n "$http_port" ]; then
run_args+=(--publish "127.0.0.1::${http_port}" --volume "$web_dir:$web_root:ro")
fi
-container=$(docker run "${run_args[@]}" "$image")
+containers=()
cleanup() {
- docker rm --force "$container" >/dev/null 2>&1 || true
+ for container in "${containers[@]}"; do
+ docker rm --force "$container" >/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 "$container" >&2 2>&1 || true
+ docker logs "$1" >&2 2>&1 || true
echo "--- last health check ---" >&2
- docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$container" 2>/dev/null | tail -5 >&2 || true
+ docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' "$1" 2>/dev/null | tail -5 >&2 || true
}
-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 ;;
+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)"
+ fi
+ [ -z "$http_port" ] || host_port=$(docker port "$container" "$http_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"
+}
+
+# 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
- sleep 1
-done
+ case "$stdout_logs" in
+ *"handled request"*) fail "Access log was written to stdout, which Octane discards" ;;
+ esac
+}
-if [ "$status" != "healthy" ]; then
- dump_container_state
- fail "Container did not become healthy within ${health_timeout_seconds}s (status: $status)"
-fi
+start_container
pass "Container became healthy"
[ -n "$http_port" ] || exit 0
-host_port=$(docker port "$container" "$http_port" | head -n1 | sed 's/.*://')
-body=""
-for _ in $(seq 1 "$http_timeout_seconds"); do
- body=$(curl --silent --show-error --max-time 5 "http://127.0.0.1:${host_port}/" 2>/dev/null || true)
- [ "$body" = "serversideup-php-ok:${php_version}" ] && break
- sleep 1
-done
-
-if [ "$body" != "serversideup-php-ok:${php_version}" ]; then
- dump_container_state
- fail "Web server did not serve index.php on port ${http_port}. Response: ${body:-}"
-fi
+expect_body / "serversideup-php-ok:${php_version}"
pass "Web server serves PHP on port ${http_port}"
-# Uploaded PHP files under /storage must never run, including through PATH_INFO
-# (/storage/file.php/anything), which Apache and FrankenPHP would otherwise execute.
-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
+expect_storage_blocked
pass "Web server blocks PHP execution under /storage"
+
+# 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"
diff --git a/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh b/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh
index 2b7dc5fe7..26fb92fd4 100644
--- a/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh
+++ b/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh
@@ -9,7 +9,14 @@ if [ "$DISABLE_DEFAULT_CONFIG" = "true" ]; then
exit 0
fi
-if [ "$SERVERSIDEUP_DEFAULT_COMMAND" != "true" ]; then
+# FrankenPHP may be started by another command (like Laravel Octane), so SSL_MODE
+# decides whether the key pair is needed there instead of the default command check
+frankenphp_needs_ssl="false"
+if [ -d "/etc/frankenphp/" ] && [ "${SSL_MODE:-off}" != "off" ]; then
+ frankenphp_needs_ssl="true"
+fi
+
+if [ "$SERVERSIDEUP_DEFAULT_COMMAND" != "true" ] && [ "$frankenphp_needs_ssl" != "true" ]; then
if [ "$LOG_OUTPUT_LEVEL" = "debug" ]; then
echo "đ $script_name: SERVERSIDEUP_DEFAULT_COMMAND is not true, so we won't generate a self-signed SSL key pair."
fi
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index 2ec91e048..2705193e4 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -103,10 +103,12 @@ RUN if cat /etc/os-release | grep -q 'debian'; then \
--output /usr/local/bin/frankenphp \
--with github.com/dunglas/frankenphp=./ \
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
- --with github.com/dunglas/caddy-cbrotli \
+ # Plugin versions must match caddy/go.mod at the pinned FRANKENPHP_VERSION tag,
+ # otherwise xcaddy resolves the latest release, which may need a newer Go
+ --with github.com/dunglas/caddy-cbrotli@v1.0.1 \
# Mercure and Vulcain are included in the official build, but feel free to remove them
- --with github.com/dunglas/mercure/caddy \
- --with github.com/dunglas/vulcain/caddy
+ --with github.com/dunglas/mercure/caddy@v0.24.2 \
+ --with github.com/dunglas/vulcain/caddy@v1.4.2
####################
# FrankenPHP Final
@@ -128,6 +130,9 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
org.opencontainers.image.version="${REPOSITORY_BUILD_VERSION}" \
org.opencontainers.image.licenses="GPL-3.0-or-later"
+ # LARAVEL_OCTANE is intentionally not declared. The Caddyfile imports
+ # laravel-octane/*/{$LARAVEL_OCTANE:0}.caddyfile and Caddy only applies the
+ # default when the variable is unset, so declaring it (even as "") breaks startup.
ENV APP_BASE_DIR=/var/www/html \
CADDY_ACME_PROFILE="off" \
CADDY_ADMIN="off" \
@@ -137,8 +142,8 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
CADDY_HTTPS_PORT="8443" \
CADDY_HTTP_SERVER_ADDRESS="http://" \
CADDY_HTTPS_SERVER_ADDRESS="https://" \
- CADDY_LOG_FORMAT="console" \
- CADDY_LOG_OUTPUT="stdout" \
+ CADDY_LOG_FORMAT="auto" \
+ CADDY_LOG_OUTPUT="stderr" \
CADDY_PHP_SERVER_OPTIONS="" \
CADDY_SERVER_EXTRA_DIRECTIVES="" \
CADDY_SERVER_ROOT="/var/www/html/public" \
diff --git a/src/variations/frankenphp/etc/frankenphp/Caddyfile b/src/variations/frankenphp/etc/frankenphp/Caddyfile
index 512719477..288bb9d16 100644
--- a/src/variations/frankenphp/etc/frankenphp/Caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/Caddyfile
@@ -2,8 +2,6 @@
# Global Caddy configuration
########################################################
{
- admin {$CADDY_ADMIN:off}
-
import auto-https/{$CADDY_AUTO_HTTPS:off}.caddyfile
http_port {$CADDY_HTTP_PORT:8080}
@@ -15,6 +13,7 @@
frankenphp {
{$FRANKENPHP_CONFIG}
+ import laravel-octane/frankenphp/{$LARAVEL_OCTANE:0}.caddyfile
}
import trusted-proxy/{$TRUSTED_PROXY:cloudflare}.caddyfile
@@ -25,7 +24,9 @@
# Add additional Caddy configuration files from the caddyfile-global.d directory
import caddyfile-global.d/*.caddyfile
- {$CADDY_GLOBAL_OPTIONS}
+ # Laravel Octane sets LARAVEL_OCTANE=1 when it starts FrankenPHP, which switches
+ # the admin API, worker, and index file to what Octane expects
+ import laravel-octane/global/{$LARAVEL_OCTANE:0}.caddyfile
}
########################################################
@@ -69,6 +70,7 @@
php_server {
# FrankenPHP sets REMOTE_ADDR from the TCP peer, which ignores trusted_proxies
env REMOTE_ADDR {client_ip}
+ import laravel-octane/php-server/{$LARAVEL_OCTANE:0}.caddyfile
{$CADDY_PHP_SERVER_OPTIONS}
}
file_server
diff --git a/src/variations/frankenphp/etc/frankenphp/caddyfile-global.d/README.caddyfile b/src/variations/frankenphp/etc/frankenphp/caddyfile-global.d/README.caddyfile
new file mode 100644
index 000000000..a4a8ccc99
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/caddyfile-global.d/README.caddyfile
@@ -0,0 +1,7 @@
+# Add your own .caddyfile files to this directory. They are imported inside the
+# global options block of /etc/frankenphp/Caddyfile.
+# Use caddyfile.d/ for site-level configuration instead.
+#
+# This file only exists so the import glob matches something. Caddy logs
+# "No files matching import glob pattern" on every start when a directory
+# like this one is empty.
diff --git a/src/variations/frankenphp/etc/frankenphp/caddyfile.d/README.caddyfile b/src/variations/frankenphp/etc/frankenphp/caddyfile.d/README.caddyfile
new file mode 100644
index 000000000..c92b9389b
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/caddyfile.d/README.caddyfile
@@ -0,0 +1,7 @@
+# Add your own .caddyfile files to this directory. They are imported at the top
+# level of /etc/frankenphp/Caddyfile, so they can define extra site blocks.
+# Use caddyfile-global.d/ for global options instead.
+#
+# This file only exists so the import glob matches something. Caddy logs
+# "No files matching import glob pattern" on every start when a directory
+# like this one is empty.
diff --git a/src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/0.caddyfile b/src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/0.caddyfile
new file mode 100644
index 000000000..a3015e2dd
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/0.caddyfile
@@ -0,0 +1 @@
+# Laravel Octane is not running. Use FRANKENPHP_CONFIG to define workers.
diff --git a/src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/1.caddyfile b/src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/1.caddyfile
new file mode 100644
index 000000000..e712a7958
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/laravel-octane/frankenphp/1.caddyfile
@@ -0,0 +1,9 @@
+# Same worker definition as Octane's own Caddyfile. Octane fills in the
+# directives from --workers and --watch. The worker lives in CADDY_SERVER_ROOT
+# so it always matches the "root" directive, even when Octane is not the
+# process that started FrankenPHP.
+worker {
+ file "{$CADDY_SERVER_ROOT:/var/www/html/public}/frankenphp-worker.php"
+ {$CADDY_SERVER_WORKER_DIRECTIVE}
+ {$CADDY_SERVER_WATCH_DIRECTIVES}
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/laravel-octane/global/0.caddyfile b/src/variations/frankenphp/etc/frankenphp/laravel-octane/global/0.caddyfile
new file mode 100644
index 000000000..0aec61fa7
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/laravel-octane/global/0.caddyfile
@@ -0,0 +1,3 @@
+admin {$CADDY_ADMIN:off}
+
+{$CADDY_GLOBAL_OPTIONS}
diff --git a/src/variations/frankenphp/etc/frankenphp/laravel-octane/global/1.caddyfile b/src/variations/frankenphp/etc/frankenphp/laravel-octane/global/1.caddyfile
new file mode 100644
index 000000000..c5af5e042
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/laravel-octane/global/1.caddyfile
@@ -0,0 +1,7 @@
+# octane:status, octane:reload, and octane:stop call the Caddy admin API on the
+# host and port Octane passes in, so CADDY_ADMIN does not apply here
+admin {$CADDY_SERVER_ADMIN_HOST:localhost}:{$CADDY_SERVER_ADMIN_PORT:2019}
+
+# CADDY_GLOBAL_OPTIONS is intentionally not used here. Octane sets it to
+# "auto_https disable_redirects" for its own Caddyfile, which would override
+# CADDY_AUTO_HTTPS. Use caddyfile-global.d/ for additional global options.
diff --git a/src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/0.caddyfile b/src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/0.caddyfile
new file mode 100644
index 000000000..f1ba7b30d
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/0.caddyfile
@@ -0,0 +1 @@
+# Laravel Octane is not running. Requests are routed to index.php.
diff --git a/src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/1.caddyfile b/src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/1.caddyfile
new file mode 100644
index 000000000..112c2a080
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/laravel-octane/php-server/1.caddyfile
@@ -0,0 +1,4 @@
+# Route requests to the Octane worker script instead of index.php.
+# Octane's own Caddyfile also sets resolve_root_symlink, which FrankenPHP enables by default.
+index frankenphp-worker.php
+try_files {path} frankenphp-worker.php
diff --git a/src/variations/frankenphp/etc/frankenphp/log-format/auto.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-format/auto.caddyfile
new file mode 100644
index 000000000..189542de1
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/log-format/auto.caddyfile
@@ -0,0 +1,9 @@
+# No "wrap" here on purpose: Caddy picks console on an interactive terminal and
+# json everywhere else, which is the default of the official FrankenPHP image.
+# Mercure subscribers pass their JWT as ?authorization=, so redact it. Caddy
+# nests the URI under "request", which is why the field is request>uri.
+format filter {
+ request>uri query {
+ replace authorization REDACTED
+ }
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-format/console.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-format/console.caddyfile
new file mode 100644
index 000000000..975e2f2a4
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/log-format/console.caddyfile
@@ -0,0 +1,7 @@
+# Human-readable lines with colored levels, even when the output is not a terminal
+format filter {
+ wrap console
+ request>uri query {
+ replace authorization REDACTED
+ }
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-format/json.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-format/json.caddyfile
new file mode 100644
index 000000000..59a49e41b
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/log-format/json.caddyfile
@@ -0,0 +1,7 @@
+# One JSON object per line, even when the output is a terminal
+format filter {
+ wrap json
+ request>uri query {
+ replace authorization REDACTED
+ }
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/alert.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/alert.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/alert.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/alert.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/crit.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/crit.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/crit.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/crit.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/debug.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/debug.caddyfile
index bdb49c0ea..f760680ef 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/debug.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/debug.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:DEBUG}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/emerg.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/emerg.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/emerg.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/emerg.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/error.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/error.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/error.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/error.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/info.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/info.caddyfile
index 0d972084b..3e31e2117 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/info.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/info.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:INFO}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/notice.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/notice.caddyfile
index 0d972084b..3e31e2117 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/notice.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/notice.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:INFO}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/address/warn.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/address/warn.caddyfile
index 4755f020e..5e98f9be9 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/address/warn.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/address/warn.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:WARN}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/alert.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/alert.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/alert.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/alert.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/crit.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/crit.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/crit.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/crit.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/debug.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/debug.caddyfile
index 90124e286..aee25ad03 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/debug.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/debug.caddyfile
@@ -1,6 +1,6 @@
debug
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:DEBUG}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/emerg.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/emerg.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/emerg.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/emerg.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/error.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/error.caddyfile
index 22f4e0619..94e521d99 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/error.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/error.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:ERROR}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/info.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/info.caddyfile
index 0d972084b..3e31e2117 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/info.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/info.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:INFO}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/notice.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/notice.caddyfile
index 0d972084b..3e31e2117 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/notice.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/notice.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:INFO}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/log-level/global/warn.caddyfile b/src/variations/frankenphp/etc/frankenphp/log-level/global/warn.caddyfile
index 4755f020e..5e98f9be9 100644
--- a/src/variations/frankenphp/etc/frankenphp/log-level/global/warn.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/log-level/global/warn.caddyfile
@@ -1,5 +1,5 @@
log {
- format {$CADDY_LOG_FORMAT:console}
- output {$CADDY_LOG_OUTPUT:stdout}
+ import ../../log-format/{$CADDY_LOG_FORMAT:auto}.caddyfile
+ output {$CADDY_LOG_OUTPUT:stderr}
level {$CADDY_SERVER_LOG_LEVEL:WARN}
}
From d296e99e2ee4c61b490f47044b5ffbb23949e633 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Wed, 16 Sep 2026 20:42:59 +0000
Subject: [PATCH 34/47] Update migration guide for Version 5: clarify breaking
changes, dropped PHP versions, and new environment variables
---
.../5.guide/5.major-version-migrations.md | 46 +++++++++++++++++--
1 file changed, 43 insertions(+), 3 deletions(-)
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
index b17d2a0c5..612bc74f8 100644
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ b/docs/content/docs/5.guide/5.major-version-migrations.md
@@ -24,7 +24,9 @@ Debian Bullseye and Alpine 3.16 were dropped at the same time. Debian 11 reached
If you are on one of these, move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. 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).
## 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, and FrankenPHP logs follow Caddy's defaults for stream and format. There are two breaking changes. One affects you if you run with OPcache enabled while your code is mounted as a volume. The other affects you if something in your setup reads FrankenPHP's `stdout` and `stderr` separately, or parses its `console` log lines.
+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, 8.0, and 8.1 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).
@@ -42,7 +44,7 @@ Version 4 sent FrankenPHP's logs to `stdout` to mirror the access log convention
### Breaking changes in Version 5
::caution
-The following changes alter behavior for existing configurations that set `PHP_OPCACHE_ENABLE=1`, that consume FrankenPHP's `stdout` and `stderr` separately, or that parse FrankenPHP's `console` log lines.
+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`
@@ -67,6 +69,30 @@ You are affected if:
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)
+
+#### 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)
+
### 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.
@@ -92,12 +118,25 @@ The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is onl
- `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.
+- `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)
- **FrankenPHP redacts the `authorization` query parameter** - Request logs never contain the JWT that Mercure 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
@@ -106,11 +145,12 @@ The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is onl
- 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
#### Dockerfile
-- Nothing is required
+- 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 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
From c6162e1ee522d6f75f39f581b54421f13e592fe0 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Wed, 16 Sep 2026 20:50:13 +0000
Subject: [PATCH 35/47] Update nginx version to 1.30.5 for Alpine and Debian
configurations
---
scripts/conf/php-versions-base-config.yml | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml
index 6d0c046a6..df4a3e5c0 100644
--- a/scripts/conf/php-versions-base-config.yml
+++ b/scripts/conf/php-versions-base-config.yml
@@ -68,19 +68,19 @@ operating_systems:
- name: "Alpine 3.23"
version: alpine3.23
number: 3.23
- nginx_version: 1.30.4-r1
+ nginx_version: 1.30.5-r1
- name: "Alpine 3.24"
version: alpine3.24
number: 3.24
- nginx_version: 1.30.4-r1
+ nginx_version: 1.30.5-r1
- family: debian
default: true
versions:
- name: "Debian Bookworm"
version: bookworm
number: 12
- nginx_version: 1.30.4-1~bookworm
+ nginx_version: 1.30.5-1~bookworm
- name: "Debian Trixie"
version: trixie
number: 13
- nginx_version: 1.30.4-1~trixie
+ nginx_version: 1.30.5-1~trixie
From f74a1bfa8780ee3ed8019adabf9bd9ffb452b2c7 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Wed, 16 Sep 2026 21:58:23 +0000
Subject: [PATCH 36/47] Update migration guides and S6 Overlay initialization
for Version 5: - Document changes to S6 Overlay user bundle location and
usage. - Enhance `docker-php-serversideup-s6-init` script for better service
management and execution order.
---
.../5.guide/5.major-version-migrations.md | 14 +++
.../3.adding-your-own-start-up-scripts.md | 20 +++-
.../local/bin/docker-php-serversideup-s6-init | 97 ++++++++++---------
3 files changed, 81 insertions(+), 50 deletions(-)
mode change 100644 => 100755 src/s6/usr/local/bin/docker-php-serversideup-s6-init
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
index 612bc74f8..017fbc1b8 100644
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ b/docs/content/docs/5.guide/5.major-version-migrations.md
@@ -85,6 +85,19 @@ 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.
@@ -151,6 +164,7 @@ The OPcache values apply only when `PHP_OPCACHE_ENABLE=1`, and the memory is onl
#### 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
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
index 129d69cf8..6d8f4e831 100644
--- 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
@@ -126,7 +126,7 @@ exit 0
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. 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.
+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
@@ -137,21 +137,33 @@ 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/
-# Copy our entrypoint scripts into the S6 Overlay scripts directory
+# 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.
-The magic happens when we run `docker-php-serversideup-s6-init`. This script will move all our scripts from the `/etc/entrypoint.d` directory to the `/etc/s6-overlay/scripts` directory and set the correct dependencies for our S6 services.
+::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
-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.
+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
diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-init b/src/s6/usr/local/bin/docker-php-serversideup-s6-init
old mode 100644
new mode 100755
index 4dcfb7fec..0b0d4137a
--- a/src/s6/usr/local/bin/docker-php-serversideup-s6-init
+++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-init
@@ -5,9 +5,16 @@ set -e
# Usage: docker-php-serversideup-s6-init
###############################################
# This script is used to take scripts from "/etc/entrypoint.d" and move them
-# to the S6 Overlay structure.
+# to the S6 Overlay structure. Every script becomes an s6-rc "oneshot" that runs
+# in the same order the entrypoint would have run it, and the long-running
+# services only start once the last oneshot has finished.
+#
+# Layout reference: https://github.com/just-containers/s6-overlay#writing-a-service-script
+# Service definition format: https://skarnet.org/software/s6-rc/s6-rc-compile.html#source
S6_HOME="/etc/s6-overlay"
+S6_SCRIPTS_DIR="${S6_HOME}/scripts"
+S6_USER_BUNDLE_DIR="${S6_RUNTIME_BUNDLEDIR:-${S6_HOME}/user-bundles.d}/user"
ENTRYPOINT_DIR="/etc/entrypoint.d"
# Sanity checks
@@ -21,6 +28,12 @@ if [ ! -d "$S6_HOME" ]; then
exit 1
fi
+# S6 Overlay v3.2.3.2 moved the "user" bundle from s6-rc.d to user-bundles.d
+if [ ! -d "${S6_USER_BUNDLE_DIR}/contents.d" ]; then
+ echo "Error: S6 Overlay user bundle (${S6_USER_BUNDLE_DIR}/contents.d) not found"
+ exit 1
+fi
+
# Check for root privileges
if [ "$(id -u)" -ne 0 ]; then
echo "$(basename "$0"): This script must be run as root within the container. Be sure to set \"USER root\" in your Dockerfile before running this script."
@@ -38,60 +51,52 @@ add_dependency() {
fi
}
-for file in "$ENTRYPOINT_DIR"/*.sh; do
- [ -e "$file" ] || continue # Skip if no files match
-
- # Get the base name of the file
- script_name=$(basename "$file" .sh)
+mkdir -p "$S6_SCRIPTS_DIR"
+previous_script_name=""
- # Proceed only if the script does not exist
- if [ ! -e "${S6_HOME}/scripts/${script_name}" ]; then
- # Create the service directory for that file
- mkdir -p "${S6_HOME}/s6-rc.d/${script_name}"
-
- # Set service type to "oneshot"
- echo "oneshot" > "${S6_HOME}/s6-rc.d/${script_name}/type"
+# The entrypoint runs the scripts sequentially in version-sort order, so each
+# oneshot depends on the one before it to keep that order under S6
+while IFS= read -r file; do
+ [ -n "$file" ] || continue
- # Set the "up" script
- echo "${S6_HOME}/scripts/${script_name}" > "${S6_HOME}/s6-rc.d/${script_name}/up"
+ script_name=$(basename "$file" .sh)
+ service_dir="${S6_HOME}/s6-rc.d/${script_name}"
+
+ if [ -e "${S6_SCRIPTS_DIR}/${script_name}" ]; then
+ echo "Skipping ${script_name} because it already exists at ${S6_SCRIPTS_DIR}/${script_name}"
+ else
+ mkdir -p "$service_dir"
+ echo "oneshot" > "${service_dir}/type"
+ echo "${S6_SCRIPTS_DIR}/${script_name}" > "${service_dir}/up"
- # Place empty file in contents.d
- touch "${S6_HOME}/s6-rc.d/user/contents.d/${script_name}"
+ # Add the service to the "user" bundle so S6 Overlay starts it at container boot
+ touch "${S6_USER_BUNDLE_DIR}/contents.d/${script_name}"
# S6 Overlay recommends every user service depend on "base" to prevent race conditions
add_dependency "$script_name" base
- # Ensure the ${S6_HOME}/scripts/ directory exists
- mkdir -p "${S6_HOME}/scripts"
-
- # Move the script to the S6 Overlay scripts directory
- mv "${file}" "${S6_HOME}/scripts/${script_name}"
-
- # Ensure the script has the correct file header for S6
- sed -i '1s%^#!/bin/sh$%#!/command/with-contenv sh%' "${S6_HOME}/scripts/${script_name}"
-
- # Find the script that should be the dependency based on alphabetical order
- previous_script_name=$(find "${S6_HOME}/s6-rc.d/" -maxdepth 1 -type d -name '[0-9]*' | \
- sort -V | \
- grep -B1 "${script_name}" | \
- head -n 1 | \
- xargs basename)
-
- # Check if the previous script is not the current script and set as dependency
- if [ "$previous_script_name" != "$script_name" ] && [ -n "$previous_script_name" ]; then
+ if [ -n "$previous_script_name" ]; then
add_dependency "$script_name" "$previous_script_name"
fi
- # Set the previous file for the next loop
- previous_script_name="$script_name"
- else
- echo "Skipping ${script_name} because it already exists at ${S6_HOME}/scripts/${script_name}"
- fi
+ # The entrypoint sourced the script, but s6-rc executes it, so it must be executable
+ mv "$file" "${S6_SCRIPTS_DIR}/${script_name}"
+ chmod +x "${S6_SCRIPTS_DIR}/${script_name}"
-done
+ # Ensure the script has the correct file header for S6
+ sed -i '1s%^#!/bin/sh$%#!/command/with-contenv sh%' "${S6_SCRIPTS_DIR}/${script_name}"
+ fi
-# Long-running services must wait for the oneshots that write their config, otherwise
-# they race the oneshots when the container runs as root (see #689).
-add_dependency php-fpm 5-fpm-pool-user
-add_dependency nginx 10-init-webserver-config
-add_dependency apache2 10-init-webserver-config
+ previous_script_name="$script_name"
+done <
Date: Wed, 16 Sep 2026 22:11:32 +0000
Subject: [PATCH 37/47] Add reference to AGENTS.md in CLAUDE.md documentation
---
CLAUDE.md | 1 +
docs/CLAUDE.md | 1 +
2 files changed, 2 insertions(+)
create mode 120000 CLAUDE.md
create mode 120000 docs/CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 120000
index 000000000..47dc3e3d8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
new file mode 120000
index 000000000..47dc3e3d8
--- /dev/null
+++ b/docs/CLAUDE.md
@@ -0,0 +1 @@
+AGENTS.md
\ No newline at end of file
From 22a986e34f7dabda9379f622beb5842c5435937c Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Thu, 17 Sep 2026 14:55:08 +0000
Subject: [PATCH 38/47] Update PHP_EXT_INSTALLER_VERSION to 2.11.15
---
.../local/bin/docker-php-serversideup-install-php-ext-installer | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
index 5a03ac541..d93def563 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
+++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
@@ -11,7 +11,7 @@ script_name="docker-php-serversideup-install-php-ext-installer"
############
# Environment variables
############
-PHP_EXT_INSTALLER_VERSION="2.11.12"
+PHP_EXT_INSTALLER_VERSION="2.11.15"
############
# Main
From c793a07fc0d9ce7737978e3df43bda1c0fe345f1 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Thu, 17 Sep 2026 16:10:15 +0000
Subject: [PATCH 39/47] Update documentation and scripts for PHP 8.1
end-of-life status and support
---
README.md | 2 +-
SECURITY.md | 20 +++++---
.../1.getting-started/5.choosing-an-image.md | 13 ++++--
.../5.guide/5.major-version-migrations.md | 46 ++++++++++++++++---
scripts/assemble-docker-tags.sh | 4 +-
scripts/conf/php-versions-base-config.yml | 16 +++++++
scripts/dev.sh | 4 +-
7 files changed, 82 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index bcf475266..7ddb34c88 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ serversideup/php:{{version}}-{{variation-name}}
> [!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.
+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.
[Learn More About Choosing an Image â](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image)
diff --git a/SECURITY.md b/SECURITY.md
index 336188efc..245f2761e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -49,7 +49,12 @@ Our images bundle third-party software, each with its own support window. Before
| Apache HTTP Server | [endoflife.date/apache](https://endoflife.date/apache) |
| Composer | [endoflife.date/composer](https://endoflife.date/composer) |
-We publish images for end-of-life PHP versions and operating system bases so legacy applications have a path into containers â but only for as long as the base OS still serves a signed package repository. Once a distribution stops signing its repositories, we can no longer build a patched image at all, so we stop rebuilding that base and its existing tags freeze at their last successful build. 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.
+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.
+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.
## EOL versions and the legacy-modernization path
@@ -57,13 +62,14 @@ The following images are no longer built. Their existing tags remain pullable on
| Image | Last built | Why it stopped |
| --- | --- | --- |
-| PHP 7.4 (all variations) | 2026-09-03 | Debian 11 reached end of LTS on 2026-08-31 and Alpine 3.16 is long EOL. These were the only bases the official `php:7.4` images ever shipped, and upstream stopped building them in November 2022. |
-| PHP 8.0 (all variations) | 2026-09-03 | Same as above. Upstream stopped building `php:8.0` in November 2023. |
-| PHP 8.1 (all variations) | 2025-12-16 | PHP 8.1 reached end of security support and upstream removed the `8.1` branch, so there is no base image left to rebuild from. |
-| Anything on Debian Bullseye | 2026-09-03 | Debian 11's final `bullseye-security` release file expired on 2026-09-07. `apt-get update` now fails inside the build, so a patched image cannot be produced. |
-| Anything on Alpine 3.16 | 2026-09-03 | Alpine 3.16 is past end of support and was only ever used by PHP 7.4 and 8.0. |
+| PHP 7.4 (all variations) | 2026-09-03 | Debian Bullseye and Alpine 3.16 were the only bases the official `php:7.4` images ever shipped, and upstream stopped building them in November 2022. Both bases are now past end of life (see below). |
+| PHP 8.0 (all variations) | 2026-09-03 | Same bases as 7.4. Upstream stopped building `php:8.0` in November 2023. |
+| 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.
-If you are running one of these images, treat it as a migration deadline rather than a stable base. Move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. The [Upgrade Guide](https://serversideup.net/open-source/docker-php/docs/getting-started/upgrade-guide) covers moving between our releases, and [Choosing an image](https://serversideup.net/open-source/docker-php/docs/getting-started/choosing-an-image) covers picking a supported base.
+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.
## How updates flow
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 fde82b69a..49a64f156 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
@@ -56,13 +56,15 @@ Here's what each part means:
|--------|---------|---------------|
| `{{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`
(you can also specify the full version number, ie. `8.4.1`) |
+| `{{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, 8.0, and 8.1 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.
+::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.
::
::note{title="Floating vs. version-pinned tags"}
@@ -136,9 +138,9 @@ Choosing an operating system comes down to a few preferences, but ultimately you
| `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 publish images on an end-of-life operating system release only for as long as that distribution still serves a signed package repository. Once it stops, we can no longer build a patched image at all, so we stop rebuilding that base and its tags freeze where they are. That is what happened to Debian Bullseye and Alpine 3.16.
+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.
-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.
+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.
Before picking a base, confirm it's currently supported:
@@ -158,4 +160,5 @@ We also support specific versions of operating systems. So this means that you c
- `trixie`
- `alpine3.23`
- `alpine3.24`
+- `alpine3.22` (PHP 8.1 only)
diff --git a/docs/content/docs/5.guide/5.major-version-migrations.md b/docs/content/docs/5.guide/5.major-version-migrations.md
index 017fbc1b8..f228458ef 100644
--- a/docs/content/docs/5.guide/5.major-version-migrations.md
+++ b/docs/content/docs/5.guide/5.major-version-migrations.md
@@ -11,20 +11,54 @@ When we ship a new major version of `serversideup/php`, we collect the breaking
## Dropped PHP versions
-We stop building a PHP version once there is no longer a base image we can produce a patched build from. The tags stay pullable, but they freeze at their last successful build and receive no further security updates.
+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.1 | 2025-12-16 | Reached end of security support and upstream removed the `8.1` branch. |
-| PHP 8.0 | 2026-09-03 | Only ever shipped on Debian Bullseye and Alpine 3.16, both now EOL. Upstream stopped building it in November 2023. |
+| 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 reached end of LTS on 2026-08-31, and its final `bullseye-security` release file expired on 2026-09-07, so `apt-get update` no longer succeeds inside a build.
+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.
-If you are on one of these, move to PHP 8.2 or newer on `bookworm`, `trixie`, `alpine3.23`, or `alpine3.24`. 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).
+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, 8.0, and 8.1 are no longer built. See [Dropped PHP versions](#dropped-php-versions).
+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`.
diff --git a/scripts/assemble-docker-tags.sh b/scripts/assemble-docker-tags.sh
index 9fe469bc2..6fa932420 100755
--- a/scripts/assemble-docker-tags.sh
+++ b/scripts/assemble-docker-tags.sh
@@ -224,8 +224,8 @@ help_menu() {
echo
echo "Options:"
echo " --variation Set the PHP variation (e.g., apache, fpm)"
- echo " --os Set the base OS (e.g., bookworm, trixie, alpine)"
- echo " --patch-version Set the PHP patch version (e.g., 8.4.25)"
+ echo " --os Set the base OS (e.g., bookworm, trixie, alpine3.24)"
+ echo " --patch-version Set the PHP patch version (e.g., 8.4.25, 8.1.34)"
echo " --github-release-tag Set the GitHub release tag"
echo " --stable-release Flag the tags for a stable release"
echo
diff --git a/scripts/conf/php-versions-base-config.yml b/scripts/conf/php-versions-base-config.yml
index df4a3e5c0..4ad686a0e 100644
--- a/scripts/conf/php-versions-base-config.yml
+++ b/scripts/conf/php-versions-base-config.yml
@@ -9,11 +9,23 @@ php_variations:
- name: fpm-nginx
- name: frankenphp
excluded_minor_versions:
+ - "8.1"
- "8.2"
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.
+ - minor: "8.1"
+ base_os:
+ - name: alpine3.22
+ - name: bookworm
+ - name: trixie
+ patch_versions:
+ - 8.1.34
- minor: "8.2"
base_os:
- name: alpine3.23
@@ -65,6 +77,10 @@ php_versions:
operating_systems:
- family: alpine
versions:
+ - name: "Alpine 3.22"
+ version: alpine3.22
+ number: 3.22
+ nginx_version: 1.30.5-r1
- name: "Alpine 3.23"
version: alpine3.23
number: 3.23
diff --git a/scripts/dev.sh b/scripts/dev.sh
index c50d2858d..9a2a3d400 100755
--- a/scripts/dev.sh
+++ b/scripts/dev.sh
@@ -144,8 +144,8 @@ help_menu() {
echo
echo "Options:"
echo " --variation Set the PHP variation (e.g., apache, fpm)"
- echo " --version Set the PHP version (e.g., 8.4, 8.5)"
- echo " --os Set the base OS (e.g., bookworm, trixie, alpine)"
+ echo " --version Set the PHP version (e.g., 8.4, 8.5, or a pinned patch like 8.1.34)"
+ echo " --os Set the base OS (e.g., bookworm, trixie, alpine3.24)"
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)"
From d99f95a49720da1d01a72e31371901bb694478fc Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Thu, 17 Sep 2026 17:09:47 +0000
Subject: [PATCH 40/47] Enhance S6 Overlay integration by updating script
handling and documentation for entrypoint scripts
---
.../3.adding-your-own-start-up-scripts.md | 1 +
.../docs/8.reference/2.command-reference.md | 2 +-
.../local/bin/docker-php-serversideup-s6-init | 43 +++++++++++++------
3 files changed, 32 insertions(+), 14 deletions(-)
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
index 6d8f4e831..80dc1c1d4 100644
--- 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
@@ -162,6 +162,7 @@ The magic happens when we run `docker-php-serversideup-s6-init`. This script:
- 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"}).
diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md
index b1c1660cc..ad908a2ba 100644
--- a/docs/content/docs/8.reference/2.command-reference.md
+++ b/docs/content/docs/8.reference/2.command-reference.md
@@ -87,7 +87,7 @@ docker-php-serversideup-set-id www-data 1000:1000
```
## docker-php-serversideup-s6-init
-This command is used to copy our entrypoint scripts into the S6 Overlay scripts directory. This is useful if you're using S6 Overlay and want to ensure your scripts are executed in the correct order.
+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)
diff --git a/src/s6/usr/local/bin/docker-php-serversideup-s6-init b/src/s6/usr/local/bin/docker-php-serversideup-s6-init
index 0b0d4137a..9133737e9 100755
--- a/src/s6/usr/local/bin/docker-php-serversideup-s6-init
+++ b/src/s6/usr/local/bin/docker-php-serversideup-s6-init
@@ -9,6 +9,10 @@ set -e
# in the same order the entrypoint would have run it, and the long-running
# services only start once the last oneshot has finished.
#
+# The script can run again in an image built on one that already ran it. New
+# scripts are ordered with the ones converted earlier, and a script with the
+# same name replaces the earlier one, the same way a COPY would in the entrypoint.d flow.
+#
# Layout reference: https://github.com/just-containers/s6-overlay#writing-a-service-script
# Service definition format: https://skarnet.org/software/s6-rc/s6-rc-compile.html#source
@@ -52,10 +56,8 @@ add_dependency() {
}
mkdir -p "$S6_SCRIPTS_DIR"
-previous_script_name=""
-# The entrypoint runs the scripts sequentially in version-sort order, so each
-# oneshot depends on the one before it to keep that order under S6
+# Register every entrypoint script as a oneshot and move it into place
while IFS= read -r file; do
[ -n "$file" ] || continue
@@ -63,7 +65,7 @@ while IFS= read -r file; do
service_dir="${S6_HOME}/s6-rc.d/${script_name}"
if [ -e "${S6_SCRIPTS_DIR}/${script_name}" ]; then
- echo "Skipping ${script_name} because it already exists at ${S6_SCRIPTS_DIR}/${script_name}"
+ echo "Replacing ${script_name} with ${file}"
else
mkdir -p "$service_dir"
echo "oneshot" > "${service_dir}/type"
@@ -74,22 +76,37 @@ while IFS= read -r file; do
# S6 Overlay recommends every user service depend on "base" to prevent race conditions
add_dependency "$script_name" base
+ fi
- if [ -n "$previous_script_name" ]; then
- add_dependency "$script_name" "$previous_script_name"
- fi
+ # The entrypoint sourced the script, but s6-rc executes it, so it must be executable
+ mv "$file" "${S6_SCRIPTS_DIR}/${script_name}"
+ chmod +x "${S6_SCRIPTS_DIR}/${script_name}"
- # The entrypoint sourced the script, but s6-rc executes it, so it must be executable
- mv "$file" "${S6_SCRIPTS_DIR}/${script_name}"
- chmod +x "${S6_SCRIPTS_DIR}/${script_name}"
+ # Ensure the script has the correct file header for S6
+ sed -i '1s%^#!/bin/sh$%#!/command/with-contenv sh%' "${S6_SCRIPTS_DIR}/${script_name}"
+done <
Date: Thu, 17 Sep 2026 18:58:41 +0000
Subject: [PATCH 41/47] Fixed error in NGINX:
nginx: [warn] low address bits of 127.0.0.1/8 are meaningless in /etc/nginx/trusted-proxy/cloudflare.conf:9
---
docs/content/docs/5.guide/4.configuring-trusted-proxies.md | 6 +++---
.../fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf | 2 +-
.../fpm-apache/etc/apache2/trusted-proxy/local.conf | 2 +-
.../fpm-apache/etc/apache2/trusted-proxy/sucuri.conf | 2 +-
.../fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf | 2 +-
src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf | 2 +-
.../fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf | 2 +-
.../frankenphp/etc/frankenphp/ssl-mode/full.caddyfile | 2 +-
.../etc/frankenphp/trusted-proxy/cloudflare.caddyfile | 2 +-
.../frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile | 2 +-
.../etc/frankenphp/trusted-proxy/sucuri.caddyfile | 2 +-
11 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
index 10587917c..62075d36f 100644
--- a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
+++ b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
@@ -250,7 +250,7 @@ services:
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.1/8;
+set_real_ip_from 127.0.0.0/8;
set_real_ip_from ::1;
set_real_ip_from fd00::/8;
@@ -303,7 +303,7 @@ RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
-RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy 127.0.0.0/8
RemoteIPInternalProxy ::1
RemoteIPInternalProxy fd00::/8
@@ -345,7 +345,7 @@ servers {
10.0.0.0/8 \
172.16.0.0/12 \
192.168.0.0/16 \
-127.0.0.1/8 \
+127.0.0.0/8 \
::1 \
fd00::/8 \
3.0.0.0/8
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
index b24e21087..d7351577d 100644
--- a/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
@@ -37,6 +37,6 @@ RemoteIPTrustedProxy 2c0f:f248::/32
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
-RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy 127.0.0.0/8
RemoteIPInternalProxy ::1
RemoteIPInternalProxy fd00::/8
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
index e57cf630c..c01525a95 100644
--- a/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
@@ -11,6 +11,6 @@ RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
-RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy 127.0.0.0/8
RemoteIPInternalProxy ::1
RemoteIPInternalProxy fd00::/8
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
index 9611fa0f0..42ad6a8c3 100644
--- a/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
@@ -14,7 +14,7 @@ RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 10.0.0.0/8
RemoteIPInternalProxy 172.16.0.0/12
RemoteIPInternalProxy 192.168.0.0/16
-RemoteIPInternalProxy 127.0.0.1/8
+RemoteIPInternalProxy 127.0.0.0/8
RemoteIPInternalProxy ::1
RemoteIPInternalProxy fd00::/8
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf
index c99d3aea1..1010ba277 100644
--- a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf
@@ -6,7 +6,7 @@
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.1/8;
+set_real_ip_from 127.0.0.0/8;
set_real_ip_from ::1;
set_real_ip_from fd00::/8;
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
index ec2a2d1d5..e16ecb2a9 100644
--- a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
@@ -6,7 +6,7 @@
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.1/8;
+set_real_ip_from 127.0.0.0/8;
set_real_ip_from ::1;
set_real_ip_from fd00::/8;
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
index 0192cd67e..0b52f3ae4 100644
--- a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
@@ -6,7 +6,7 @@
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.1/8;
+set_real_ip_from 127.0.0.0/8;
set_real_ip_from ::1;
set_real_ip_from fd00::/8;
diff --git a/src/variations/frankenphp/etc/frankenphp/ssl-mode/full.caddyfile b/src/variations/frankenphp/etc/frankenphp/ssl-mode/full.caddyfile
index fd4b15b66..40b3c6c89 100644
--- a/src/variations/frankenphp/etc/frankenphp/ssl-mode/full.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/ssl-mode/full.caddyfile
@@ -1,7 +1,7 @@
# Healthcheck - HTTP: Redirect to HTTPS
http://localhost:{$CADDY_HTTP_PORT:8080} {
@healthcheck {
- remote_ip 127.0.0.1/8 ::1
+ remote_ip 127.0.0.0/8 ::1
path /healthcheck # Caddy healthcheck endpoint
path {$HEALTHCHECK_PATH:/healthcheck} # Custom healthcheck endpoint
}
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
index 5f535a1cf..17eca6fbf 100644
--- a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
@@ -5,7 +5,7 @@ servers {
10.0.0.0/8 \
172.16.0.0/12 \
192.168.0.0/16 \
-127.0.0.1/8 \
+127.0.0.0/8 \
::1 \
fd00::/8 \
173.245.48.0/20 \
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
index f9d191877..e9da5601c 100644
--- a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
@@ -4,7 +4,7 @@ servers {
10.0.0.0/8 \
172.16.0.0/12 \
192.168.0.0/16 \
-127.0.0.1/8 \
+127.0.0.0/8 \
::1 \
fd00::/8
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
index 9a900e6c4..8536c2189 100644
--- a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
@@ -5,7 +5,7 @@ servers {
10.0.0.0/8 \
172.16.0.0/12 \
192.168.0.0/16 \
-127.0.0.1/8 \
+127.0.0.0/8 \
::1 \
fd00::/8 \
192.88.134.0/23 \
From d79a17ab1c692a1ddc72dde8285531b5dd0d05b0 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Fri, 18 Sep 2026 13:42:18 +0000
Subject: [PATCH 42/47] Update PHP_EXT_INSTALLER_VERSION to 2.11.17
---
.../local/bin/docker-php-serversideup-install-php-ext-installer | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
index d93def563..d6f0c31e7 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
+++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
@@ -11,7 +11,7 @@ script_name="docker-php-serversideup-install-php-ext-installer"
############
# Environment variables
############
-PHP_EXT_INSTALLER_VERSION="2.11.15"
+PHP_EXT_INSTALLER_VERSION="2.11.17"
############
# Main
From bb633b348013ed210b4b8f14b6fea5779dc133b9 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Fri, 18 Sep 2026 15:16:28 +0000
Subject: [PATCH 43/47] Update documentation for CLI and Octane variations,
enhancing usage details and health check command description
---
docs/content/docs/2.image-variations/cli.md | 3 +
.../docs/2.image-variations/frankenphp.md | 4 +-
.../3.framework-guides/1.laravel/octane.md | 255 +++++++++++++++++-
.../docs/8.reference/2.command-reference.md | 2 +-
4 files changed, 259 insertions(+), 5 deletions(-)
diff --git a/docs/content/docs/2.image-variations/cli.md b/docs/content/docs/2.image-variations/cli.md
index 85d525189..e5bd4a3bd 100644
--- a/docs/content/docs/2.image-variations/cli.md
+++ b/docs/content/docs/2.image-variations/cli.md
@@ -14,10 +14,13 @@ Use the CLI variation when you need to:
- 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
#### 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).
#### What's Inside
diff --git a/docs/content/docs/2.image-variations/frankenphp.md b/docs/content/docs/2.image-variations/frankenphp.md
index 49c4fdc11..8d4f8286c 100644
--- a/docs/content/docs/2.image-variations/frankenphp.md
+++ b/docs/content/docs/2.image-variations/frankenphp.md
@@ -37,10 +37,12 @@ Use the FrankenPHP variation when you need to:
| Worker Mode | â
Yes | â No | â No |
| Automatic HTTPS | â
Yes | â No | â No |
| HTTP/3 Support | â
Yes | â No | â No |
-| Laravel Octane | â
Native support | â ī¸ Use Swoole | â ī¸ Use Swoole |
+| Laravel Octane | â
Native support | â Not supported | â Not supported |
| .htaccess Support | â No | â No | â
Yes |
| Maturity | â ī¸ New | â
Mature | â
Mature |
+Octane replaces PHP-FPM, so it cannot run inside the FPM variations at all. The NGINX and Apache in those images are PHP-FPM front ends, not reverse proxies. If you want Octane without FrankenPHP, run Octane's Swoole or RoadRunner server from our [CLI](/docs/image-variations/cli) image instead. See [Running Octane Without FrankenPHP](/docs/framework-guides/laravel/octane#running-octane-without-frankenphp).
+
::tip
FrankenPHP is the newest variation and represents the future of PHP application servers. If you're starting a new project and can commit to modern practices, this is the variation to choose.
::
diff --git a/docs/content/docs/3.framework-guides/1.laravel/octane.md b/docs/content/docs/3.framework-guides/1.laravel/octane.md
index 1cf39e93d..f8c12c243 100644
--- a/docs/content/docs/3.framework-guides/1.laravel/octane.md
+++ b/docs/content/docs/3.framework-guides/1.laravel/octane.md
@@ -6,7 +6,7 @@ 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.
+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?
@@ -19,11 +19,35 @@ 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
-FrankenPHP is Laravel's recommended application server for Octane and is included natively in our images. No additional installation required.
+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
+## Quick Start with FrankenPHP
Let's use this example project to get started.
::warning{to="https://serversideup.net/open-source/spin/docs" target="_blank"}
@@ -187,6 +211,231 @@ See the [Octane 2.14.0 release notes](https://github.com/laravel/octane/releases
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.
+## 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.
diff --git a/docs/content/docs/8.reference/2.command-reference.md b/docs/content/docs/8.reference/2.command-reference.md
index ad908a2ba..b7d23ba17 100644
--- a/docs/content/docs/8.reference/2.command-reference.md
+++ b/docs/content/docs/8.reference/2.command-reference.md
@@ -95,7 +95,7 @@ This command converts every script in `/etc/entrypoint.d` into an S6 Overlay one
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, but it only works in a container where Octane started the server.
+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)
From 7e3079a5a0053ca1bc98d946c5784ff3d19e6b41 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Mon, 21 Sep 2026 15:37:48 +0000
Subject: [PATCH 44/47] Add conditional checks for cron schedule in workflow
jobs to prevent failures in forks
---
.github/workflows/action_publish-images-beta.yml | 2 ++
.github/workflows/action_publish-images-production.yml | 2 ++
.github/workflows/scheduled-task_update-sponsors.yml | 2 ++
3 files changed, 6 insertions(+)
diff --git a/.github/workflows/action_publish-images-beta.yml b/.github/workflows/action_publish-images-beta.yml
index 6b72d4db1..79fb16ff7 100644
--- a/.github/workflows/action_publish-images-beta.yml
+++ b/.github/workflows/action_publish-images-beta.yml
@@ -21,6 +21,8 @@ permissions: {}
jobs:
release:
+ # 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'
name: resolve beta release
runs-on: ubuntu-24.04
timeout-minutes: 5
diff --git a/.github/workflows/action_publish-images-production.yml b/.github/workflows/action_publish-images-production.yml
index 9b99397ec..e411c5353 100644
--- a/.github/workflows/action_publish-images-production.yml
+++ b/.github/workflows/action_publish-images-production.yml
@@ -21,6 +21,8 @@ permissions: {}
jobs:
release:
+ # 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'
name: resolve release
runs-on: ubuntu-24.04
timeout-minutes: 5
diff --git a/.github/workflows/scheduled-task_update-sponsors.yml b/.github/workflows/scheduled-task_update-sponsors.yml
index c309277e9..9d4477b06 100644
--- a/.github/workflows/scheduled-task_update-sponsors.yml
+++ b/.github/workflows/scheduled-task_update-sponsors.yml
@@ -5,6 +5,8 @@ on:
- cron: 30 15 * * 0
jobs:
deploy:
+ # 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
steps:
- name: Checkout đī¸
From fe64a94d37b4bf802996ead99fd776875c2d8142 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Mon, 21 Sep 2026 16:02:12 +0000
Subject: [PATCH 45/47] Update PHP_EXT_INSTALLER_VERSION to 2.11.27
---
.../local/bin/docker-php-serversideup-install-php-ext-installer | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
index d6f0c31e7..0414a1358 100644
--- a/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
+++ b/src/common/usr/local/bin/docker-php-serversideup-install-php-ext-installer
@@ -11,7 +11,7 @@ script_name="docker-php-serversideup-install-php-ext-installer"
############
# Environment variables
############
-PHP_EXT_INSTALLER_VERSION="2.11.17"
+PHP_EXT_INSTALLER_VERSION="2.11.27"
############
# Main
From 2bfc188fbb5878261a2ae28acadd2d1fa4b5787e Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Mon, 21 Sep 2026 16:53:11 +0000
Subject: [PATCH 46/47] Update supported Alpine versions in FrankenPHP
documentation
---
docs/content/docs/2.image-variations/frankenphp.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/content/docs/2.image-variations/frankenphp.md b/docs/content/docs/2.image-variations/frankenphp.md
index 8d4f8286c..66040a2c3 100644
--- a/docs/content/docs/2.image-variations/frankenphp.md
+++ b/docs/content/docs/2.image-variations/frankenphp.md
@@ -147,8 +147,8 @@ We compile FrankenPHP from source, which allows us to support multiple operating
**Available platforms:**
- Debian Bookworm (12)
- Debian Trixie (13)
-- Alpine 3.21
-- Alpine 3.22
+- Alpine 3.23
+- Alpine 3.24
This gives you the freedom to choose the base OS that best fits your infrastructure and security requirements.
From 29ee39dc7a0fccbe9df266614252f52f8f24af27 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Mon, 21 Sep 2026 17:28:03 +0000
Subject: [PATCH 47/47] Increase timeout for image publishing job and implement
retry logic for failed pushes
---
.github/workflows/service_publish-images.yml | 25 +++++++++++++++-----
AGENTS.md | 2 +-
2 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/service_publish-images.yml b/.github/workflows/service_publish-images.yml
index 97481db68..d00b94902 100644
--- a/.github/workflows/service_publish-images.yml
+++ b/.github/workflows/service_publish-images.yml
@@ -53,7 +53,7 @@ jobs:
if: needs.list.outputs.count > 0
name: ${{ matrix.variation }}
runs-on: depot-ubuntu-24.04
- timeout-minutes: 30
+ timeout-minutes: 60
permissions:
contents: read
packages: write
@@ -89,11 +89,17 @@ jobs:
# `depot push` handles one tag at a time, so images are pushed in parallel and each
# one's output is printed afterwards as its own log group. Every image is attempted
# even after one fails, so a run shows every image that could not be published at once.
+ #
+ # Every variation's job starts pushing in the same second, and GHCR allows 2,000
+ # requests a minute per token. Eight pushes per job were enough to get every job rate
+ # limited (v5.0.0-beta1), and `depot push` does not retry a 429, so the parallelism
+ # is kept low and a failed push is retried with a growing pause.
- name: Promote the tested images to every tag
env:
IMAGES: ${{ matrix.images }}
VARIATION: ${{ matrix.variation }}
- MAX_PARALLEL: 8
+ MAX_PARALLEL: 2
+ PUSH_ATTEMPTS: 5
run: |
PROJECT=$(jq -r .id depot.json)
mkdir -p image-details logs
@@ -106,10 +112,17 @@ jobs:
tag_args+=(--tag "$tag")
done
echo "Pushing build $build_id to $(( ${#tag_args[@]} / 2 )) tags"
- if ! depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id"; then
- echo "::error title=Publish failed::${image_name} could not be pushed"
- return 1
- fi
+ local attempt=1
+ until depot push --project "$PROJECT" --progress plain "${tag_args[@]}" "$build_id"; do
+ if [ "$attempt" -ge "$PUSH_ATTEMPTS" ]; then
+ echo "::error title=Publish failed::${image_name} could not be pushed after ${PUSH_ATTEMPTS} attempts"
+ return 1
+ fi
+ local pause=$(( 15 * attempt + RANDOM % 15 ))
+ echo "Push attempt ${attempt} of ${PUSH_ATTEMPTS} failed, retrying in ${pause}s"
+ sleep "$pause"
+ attempt=$(( attempt + 1 ))
+ done
# What was published must be what was tested: the tested digest has to appear
# in the published index (as the index itself or one of its manifests).
diff --git a/AGENTS.md b/AGENTS.md
index 163de6ea8..3bb54e3e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,7 +95,7 @@ When modifying the version pipeline, the base config (`php-versions-base-config.
## CI/CD
- 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. `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`.
+- 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).