From 5b27e893915e7cddf3e04187334a997df5716207 Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Tue, 27 Jan 2026 09:59:12 -0600
Subject: [PATCH 01/13] Add TRUSTED_PROXY support for all variations & Caddy
global imports (#643)
* Enhance trusted proxy support across configurations
Updated documentation and configuration files to improve trusted proxy handling. Introduced customizable trusted proxy settings for Cloudflare, Sucuri, and local proxies, ensuring accurate IP logging. Removed hardcoded Cloudflare IPs from NGINX and Apache configurations, replacing them with a dynamic inclusion based on the TRUSTED_PROXY environment variable.
* Clarify trusted proxy documentation for Cloudflare and Sucuri
Updated the documentation to specify that both Cloudflare and Sucuri configurations now automatically include local Docker networks. Added a tip to inform users that they can use the `cloudflare` setting while also trusting local proxies, enhancing clarity on trusted proxy usage.
* Add global Caddy configuration support in FrankenPHP
Updated the Dockerfile to create a directory for global Caddy configurations and modified the Caddyfile to import additional configuration files from the new caddyfile-global.d directory, enhancing flexibility in Caddy setup.
* Fix Dockerfile syntax by correcting line continuation for Caddy configuration paths
---
.../4.these-images-vs-others.md | 2 +-
.../5.guide/4.configuring-trusted-proxies.md | 350 ++++++++++++++++++
.../1.environment-variable-specification.md | 1 +
docs/content/index.md | 4 +-
.../entrypoint.d/10-init-webserver-config.sh | 4 +-
src/variations/fpm-apache/Dockerfile | 3 +-
.../etc/apache2/conf-available/remoteip.conf | 31 +-
.../etc/apache2/sites-available/ssl-full.conf | 3 -
.../etc/apache2/trusted-proxy/cloudflare.conf | 37 ++
.../etc/apache2/trusted-proxy/local.conf | 14 +
.../etc/apache2/trusted-proxy/off.conf | 1 +
.../etc/apache2/trusted-proxy/sucuri.conf | 21 ++
.../etc/apache2/vhost-templates/http.conf | 3 -
.../etc/apache2/vhost-templates/https.conf | 3 -
src/variations/fpm-nginx/Dockerfile | 3 +-
.../etc/nginx/site-opts.d/http.conf.template | 3 +
.../etc/nginx/site-opts.d/https.conf.template | 3 +
.../cloudflare.conf} | 10 +-
.../etc/nginx/trusted-proxy/local.conf | 14 +
.../etc/nginx/trusted-proxy/off.conf | 1 +
.../etc/nginx/trusted-proxy/sucuri.conf | 22 ++
src/variations/frankenphp/Dockerfile | 4 +-
.../frankenphp/etc/frankenphp/Caddyfile | 40 +-
.../trusted-proxy/cloudflare.caddyfile | 36 ++
.../frankenphp/trusted-proxy/local.caddyfile | 12 +
.../frankenphp/trusted-proxy/off.caddyfile | 1 +
.../frankenphp/trusted-proxy/sucuri.caddyfile | 18 +
27 files changed, 562 insertions(+), 82 deletions(-)
create mode 100644 docs/content/docs/5.guide/4.configuring-trusted-proxies.md
create mode 100644 src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
create mode 100644 src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
create mode 100644 src/variations/fpm-apache/etc/apache2/trusted-proxy/off.conf
create mode 100644 src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
rename src/variations/fpm-nginx/etc/nginx/{server-opts.d/remoteip.conf => trusted-proxy/cloudflare.conf} (79%)
create mode 100644 src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
create mode 100644 src/variations/fpm-nginx/etc/nginx/trusted-proxy/off.conf
create mode 100644 src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
create mode 100644 src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/trusted-proxy/off.caddyfile
create mode 100644 src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
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 0d6f7941b..f0c70f0ce 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
@@ -72,7 +72,7 @@ Our images run as the `www-data` user by default, following the principle of lea
We also include additional security hardening:
- Disabled dangerous PHP functions by default (but you control them)
- Proper file permissions out of the box
-- CloudFlare trusted proxy support for accurate IP logging
+- Customizable trusted proxy configuration (Cloudflare, Sucuri, local, or off) for accurate IP logging
- Regular security updates from official PHP base images
### Performance Optimized
diff --git a/docs/content/docs/5.guide/4.configuring-trusted-proxies.md b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
new file mode 100644
index 000000000..e076b321c
--- /dev/null
+++ b/docs/content/docs/5.guide/4.configuring-trusted-proxies.md
@@ -0,0 +1,350 @@
+---
+head.title: 'Configuring Trusted Proxies - Docker PHP - Server Side Up'
+description: 'Learn how to configure trusted proxies to get accurate client IP addresses when running behind load balancers, CDNs, or reverse proxies.'
+layout: docs
+---
+
+::lead-p
+When your application runs behind a load balancer, CDN, or reverse proxy, the web server sees the proxy's IP address instead of the actual visitor's IP. Trusted proxy configuration tells your web server which proxies to trust for forwarding the real client IP.
+::
+
+## Why Trusted Proxies Matter
+
+Without proper trusted proxy configuration, your application will:
+- Log the proxy's IP address instead of the visitor's real IP
+- Have incorrect geolocation data
+- Potentially break rate limiting or IP-based security rules
+- Show incorrect information in Laravel's `request()->ip()` or similar methods
+
+::warning
+Never trust all IP addresses as proxies. Only trust specific proxy IPs that you control or know are legitimate (like your CDN provider).
+::
+
+## Available Options
+
+Set the `TRUSTED_PROXY` environment variable to one of the following values:
+
+| Value | Description |
+|-------|-------------|
+| `cloudflare` (default) | Trusts [Cloudflare's IP ranges](https://www.cloudflare.com/ips/){target="_blank"} **+ local Docker networks** using the `CF-Connecting-IP` header |
+| `sucuri` | Trusts [Sucuri's IP ranges](https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/){target="_blank"} **+ local Docker networks** using the `X-Forwarded-For` header |
+| `local` | Trusts only private/local network ranges (Docker networks, localhost) using the `X-Forwarded-For` header |
+| `off` | Disables trusted proxy configuration entirely |
+
+::tip
+**You don't need to choose between your CDN and local proxies.** All options (except `off`) automatically include Docker's internal network ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`). This means if you're behind Cloudflare *and* running a reverse proxy like Traefik or Caddy in Docker, just use `cloudflare` â it already trusts both.
+::
+
+## Quick Start
+
+### Using Cloudflare (Default)
+
+If you're using Cloudflare as your CDN/proxy, you don't need to change anything. The default configuration already trusts Cloudflare's IP ranges:
+
+```yml [compose.yml]
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ # TRUSTED_PROXY defaults to "cloudflare"
+```
+
+### Using Sucuri
+
+If you're using Sucuri as your web application firewall:
+
+```yml [compose.yml]{8}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ environment:
+ TRUSTED_PROXY: "sucuri"
+```
+
+### Local/Docker Networks Only
+
+If you're running behind your own reverse proxy (like Traefik or Caddy) on the same Docker network:
+
+```yml [compose.yml]{8}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ environment:
+ TRUSTED_PROXY: "local"
+```
+
+### Disabling Trusted Proxies
+
+If you want to handle proxy configuration yourself or your application is directly exposed to the internet:
+
+```yml [compose.yml]{8}
+services:
+ php:
+ image: serversideup/php:8.5-fpm-nginx
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ environment:
+ TRUSTED_PROXY: "off"
+```
+
+## Laravel Configuration
+
+While the Docker images handle the web server's trusted proxy configuration, Laravel also needs to know about trusted proxies at the application level.
+
+Laravel includes a `TrustProxies` middleware that you should configure in your application. For most setups using our images, you can trust all proxies since the web server has already validated them:
+
+```php [bootstrap/app.php]
+->withMiddleware(function (Middleware $middleware) {
+ $middleware->trustProxies(at: '*');
+})
+```
+
+Or if you prefer more control, configure specific headers:
+
+```php [bootstrap/app.php]
+->withMiddleware(function (Middleware $middleware) {
+ $middleware->trustProxies(
+ at: '*',
+ headers: Request::HEADER_X_FORWARDED_FOR |
+ Request::HEADER_X_FORWARDED_HOST |
+ Request::HEADER_X_FORWARDED_PORT |
+ Request::HEADER_X_FORWARDED_PROTO
+ );
+})
+```
+
+::tip
+For Cloudflare specifically, Laravel can use the `CF-Connecting-IP` header. The `HEADER_X_FORWARDED_FOR` setting works because Cloudflare also sets this header.
+::
+
+:u-button{to="https://laravel.com/docs/requests#trusting-proxies" target="_blank" label="Learn more about Laravel's TrustProxies middleware" aria-label="Learn more about Laravel's TrustProxies middleware" size="md" color="primary" variant="outline" trailing-icon="i-lucide-arrow-right" class="font-bold ring ring-inset ring-blue-600 text-blue-600 hover:ring-blue-500 hover:text-blue-500"}
+
+## How It Works
+
+### Cloudflare
+When `TRUSTED_PROXY=cloudflare`:
+- Trusts Cloudflare's published IPv4 and IPv6 ranges
+- Uses the `CF-Connecting-IP` header to get the real client IP
+- Includes Docker network ranges for container-to-container communication
+
+### Sucuri
+When `TRUSTED_PROXY=sucuri`:
+- Trusts Sucuri's WAF IP ranges
+- Uses the `X-Forwarded-For` header to get the real client IP
+- Includes Docker network ranges for container-to-container communication
+
+### Local
+When `TRUSTED_PROXY=local`:
+- Trusts only private network ranges (RFC 1918)
+- Trusts localhost and IPv6 loopback addresses
+- Uses the `X-Forwarded-For` header
+- Perfect for setups with your own reverse proxy on the same network
+
+## Security Considerations
+
+::caution
+Incorrectly configuring trusted proxies can allow attackers to spoof their IP address by sending fake headers. Only trust IP ranges that you know are legitimate proxies.
+::
+
+- **Don't trust all IPs**: Never set your web server to trust `X-Forwarded-For` from any IP address
+- **Keep configurations updated**: CDN IP ranges can change over time. Our images are updated regularly, but if security is critical, verify the ranges match your provider's published list
+- **Use the right option**: If you're not using Cloudflare or Sucuri, don't use those options. Use `local` if you have your own reverse proxy, or `off` if you don't need proxy trust
+
+## Troubleshooting
+
+### Still seeing proxy IP instead of real IP
+
+1. **Check your CDN/proxy is in the trusted list**: Verify your proxy's IP is within the trusted ranges for your chosen option
+2. **Check the header being used**: Different proxies use different headers. Cloudflare uses `CF-Connecting-IP`, while most others use `X-Forwarded-For`
+3. **Check Laravel configuration**: Remember to also configure Laravel's `TrustProxies` middleware
+
+### Application works but logs show wrong IP
+
+Your web server might be configured correctly, but your application framework might need separate configuration. See the [Laravel Configuration](#laravel-configuration) section above.
+
+### Need to trust a different proxy provider
+
+If your proxy provider isn't listed, you can create a custom configuration. See [Custom Trusted Proxy Configuration](#custom-trusted-proxy-configuration) below.
+
+## Custom Trusted Proxy Configuration
+
+If you're using a proxy provider that isn't included (like AWS ALB, Fastly, or a custom load balancer), you can create your own trusted proxy configuration.
+
+### Two Ways to Add Custom Configs
+
+| Method | Best For | How It Works |
+|--------|----------|--------------|
+| **Dockerfile (recommended)** | Production deployments | Bakes the config into your image |
+| **Volume mount** | Local development | Mount the file at runtime |
+
+The examples below use the Dockerfile approach. To use a volume mount instead, simply replace the `COPY` instruction with a volume mount in your `compose.yml`:
+
+```yml [compose.yml]
+volumes:
+ # Mount syntax: ./local/path:/container/path:ro
+ - ./docker/trusted-proxy/aws-alb.conf:/etc/nginx/trusted-proxy/aws-alb.conf:ro
+```
+
+| Variation | Config Path |
+|-----------|-------------|
+| `fpm-nginx` | `/etc/nginx/trusted-proxy/{name}.conf` |
+| `fpm-apache` | `/etc/apache2/trusted-proxy/{name}.conf` |
+| `frankenphp` | `/etc/frankenphp/trusted-proxy/{name}.caddyfile` |
+
+::tip
+Always include Docker's internal network ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) so container-to-container communication works correctly.
+::
+
+::warning
+Keep your custom IP ranges up to date. Cloud providers periodically update their IP ranges.
+::
+
+### FPM-NGINX Custom Configuration
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-nginx
+
+# Copy our custom trusted proxy configuration
+COPY --chmod=644 docker/trusted-proxy/aws-alb.conf /etc/nginx/trusted-proxy/aws-alb.conf
+```
+
+```yml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ environment:
+ TRUSTED_PROXY: "aws-alb"
+```
+
+```conf [docker/trusted-proxy/aws-alb.conf]
+##
+# AWS ALB - Trusted Proxy (example)
+##
+
+# Configure docker networks and loopback addresses
+set_real_ip_from 10.0.0.0/8;
+set_real_ip_from 172.16.0.0/12;
+set_real_ip_from 192.168.0.0/16;
+set_real_ip_from 127.0.0.1/8;
+set_real_ip_from ::1;
+set_real_ip_from fd00::/8;
+
+# Add your proxy provider's IP ranges
+# Example: AWS us-east-1 ranges (check AWS docs for current IPs)
+set_real_ip_from 3.0.0.0/8;
+
+# Set the header your proxy uses
+real_ip_header X-Forwarded-For;
+```
+::
+
+### FPM-Apache Custom Configuration
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-fpm-apache
+
+# Copy our custom trusted proxy configuration
+COPY --chmod=644 docker/trusted-proxy/aws-alb.conf /etc/apache2/trusted-proxy/aws-alb.conf
+```
+
+```yml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ environment:
+ TRUSTED_PROXY: "aws-alb"
+```
+
+```conf [docker/trusted-proxy/aws-alb.conf]
+##
+# AWS ALB - Trusted Proxy (example)
+##
+
+# Set the header your proxy uses
+RemoteIPHeader X-Forwarded-For
+
+# Configure docker networks
+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
+
+# Add your proxy provider's IP ranges
+# Example: AWS us-east-1 ranges (check AWS docs for current IPs)
+RemoteIPTrustedProxy 3.0.0.0/8
+```
+::
+
+### FrankenPHP Custom Configuration
+
+::code-tree{defaultValue="Dockerfile"}
+
+```dockerfile [Dockerfile]
+FROM serversideup/php:8.5-frankenphp
+
+# Copy our custom trusted proxy configuration
+COPY --chmod=644 docker/trusted-proxy/aws-alb.caddyfile /etc/frankenphp/trusted-proxy/aws-alb.caddyfile
+```
+
+```yml [compose.yml]
+services:
+ php:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "80:8080"
+ volumes:
+ - .:/var/www/html
+ environment:
+ TRUSTED_PROXY: "aws-alb"
+```
+
+```caddyfile [docker/trusted-proxy/aws-alb.caddyfile]
+servers {
+ # Trust Docker/private networks + loopback
+ trusted_proxies static \
+10.0.0.0/8 \
+172.16.0.0/12 \
+192.168.0.0/16 \
+127.0.0.1/8 \
+::1 \
+fd00::/8 \
+3.0.0.0/8
+
+ # Set the header your proxy uses
+ client_ip_headers X-Forwarded-For
+}
+```
+::
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 0b6d28f4d..e839b3ec7 100644
--- a/docs/content/docs/8.reference/1.environment-variable-specification.md
+++ b/docs/content/docs/8.reference/1.environment-variable-specification.md
@@ -119,3 +119,4 @@ Setting environment variables all depends on what method you're using to run you
`SSL_CERTIFICATE_FILE`
*Default: "/etc/ssl/private/self-signed-web.crt"*|Path to public certificate file for HTTPS. You must provide this file otherwise a self-signed key pair will be generated for you.|fpm-nginx,
fpm-apache
`SSL_MODE`
*Default: "off"*|Configure how you would like to handle SSL. This can be "off" (HTTP only), "mixed" (HTTP + HTTPS), or "full" (HTTPS only). If you use HTTP, you may need to also change `PHP_SESSION_COOKIE_SECURE`.|fpm-nginx,
fpm-apache,
frankenphp
`SSL_PRIVATE_KEY_FILE`
*Default: "/etc/ssl/private/self-signed-web.key"*|Path to private key file for HTTPS. You must provide this file otherwise a self-signed key pair will be generated for you.|fpm-nginx,
fpm-apache,
frankenphp
+`TRUSTED_PROXY`
*Default: "cloudflare"*|Configure which proxy IPs are trusted to pass the real client IP address. Valid options: `cloudflare`, `sucuri`, `local`, or `off`. See [Configuring Trusted Proxies](/docs/guide/configuring-trusted-proxies) for details.|fpm-nginx,
fpm-apache,
frankenphp
diff --git a/docs/content/index.md b/docs/content/index.md
index a6cbf94d9..a9e76dda7 100644
--- a/docs/content/index.md
+++ b/docs/content/index.md
@@ -105,10 +105,10 @@ These images [give a lot more]{.text-pink-500} than other PHP Docker Images.
orientation: vertical
---
#title
- Native CloudFlare Support
+ Trusted Proxy Support
#description
- Get real IP addresses from visitors from trusted proxies.
+ Get real IP addresses from Cloudflare, Sucuri, or local proxies.
:::
:::u-page-card
diff --git a/src/s6/etc/entrypoint.d/10-init-webserver-config.sh b/src/s6/etc/entrypoint.d/10-init-webserver-config.sh
index a34608d75..4a06a093b 100644
--- a/src/s6/etc/entrypoint.d/10-init-webserver-config.sh
+++ b/src/s6/etc/entrypoint.d/10-init-webserver-config.sh
@@ -42,8 +42,8 @@ process_template() {
return 1
fi
- # Get all environment variables starting with 'NGINX_', 'SSL_', `LOG_`, 'APACHE_', and 'HEALTHCHECK_PATH'
- subst_vars=$(env | grep -E '^(PHP_|NGINX_|SSL_|LOG_|APACHE_|HEALTHCHECK_PATH)' | cut -d= -f1 | awk '{printf "${%s},",$1}' | sed 's/,$//')
+ # Get all environment variables starting with certain prefixes
+ subst_vars=$(env | grep -E '^(PHP_|NGINX_|SSL_|LOG_|APACHE_|HEALTHCHECK_PATH|TRUSTED_PROXY)' | cut -d= -f1 | awk '{printf "${%s},",$1}' | sed 's/,$//')
# Validate that all required variables are set
for var_name in $(echo "$subst_vars" | tr ',' ' '); do
diff --git a/src/variations/fpm-apache/Dockerfile b/src/variations/fpm-apache/Dockerfile
index 3d4e52f55..0fa0d3fc1 100644
--- a/src/variations/fpm-apache/Dockerfile
+++ b/src/variations/fpm-apache/Dockerfile
@@ -99,7 +99,8 @@ ENV APACHE_DOCUMENT_ROOT=/var/www/html/public \
SHOW_WELCOME_MESSAGE=true \
SSL_MODE=off \
SSL_CERTIFICATE_FILE=/etc/ssl/private/self-signed-web.crt \
- SSL_PRIVATE_KEY_FILE=/etc/ssl/private/self-signed-web.key
+ SSL_PRIVATE_KEY_FILE=/etc/ssl/private/self-signed-web.key \
+ TRUSTED_PROXY=cloudflare
# copy our scripts
COPY --chmod=755 src/common/ /
diff --git a/src/variations/fpm-apache/etc/apache2/conf-available/remoteip.conf b/src/variations/fpm-apache/etc/apache2/conf-available/remoteip.conf
index 59724b0aa..b6e3a544e 100644
--- a/src/variations/fpm-apache/etc/apache2/conf-available/remoteip.conf
+++ b/src/variations/fpm-apache/etc/apache2/conf-available/remoteip.conf
@@ -1,26 +1,5 @@
-RemoteIPHeader CF-Connecting-IP
-RemoteIPTrustedProxy 173.245.48.0/20
-RemoteIPTrustedProxy 103.21.244.0/22
-RemoteIPTrustedProxy 103.22.200.0/22
-RemoteIPTrustedProxy 103.31.4.0/22
-RemoteIPTrustedProxy 141.101.64.0/18
-RemoteIPTrustedProxy 108.162.192.0/18
-RemoteIPTrustedProxy 190.93.240.0/20
-RemoteIPTrustedProxy 188.114.96.0/20
-RemoteIPTrustedProxy 197.234.240.0/22
-RemoteIPTrustedProxy 198.41.128.0/17
-RemoteIPTrustedProxy 162.158.0.0/15
-RemoteIPTrustedProxy 172.64.0.0/13
-RemoteIPTrustedProxy 131.0.72.0/22
-RemoteIPTrustedProxy 104.16.0.0/13
-RemoteIPTrustedProxy 104.24.0.0/14
-RemoteIPTrustedProxy 2400:cb00::/32
-RemoteIPTrustedProxy 2606:4700::/32
-RemoteIPTrustedProxy 2803:f800::/32
-RemoteIPTrustedProxy 2405:b500::/32
-RemoteIPTrustedProxy 2405:8100::/32
-RemoteIPTrustedProxy 2a06:98c0::/29
-RemoteIPTrustedProxy 2c0f:f248::/32
-RemoteIPTrustedProxy 10.0.0.0/8
-RemoteIPTrustedProxy 172.16.0.0/12
-RemoteIPTrustedProxy 192.168.0.0/16
\ No newline at end of file
+#
+# Trusted Proxy Configuration
+# Available options: cloudflare, sucuri, local, off
+#
+Include /etc/apache2/trusted-proxy/${TRUSTED_PROXY}.conf
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/apache2/sites-available/ssl-full.conf b/src/variations/fpm-apache/etc/apache2/sites-available/ssl-full.conf
index 056ddde91..c6b8406ac 100644
--- a/src/variations/fpm-apache/etc/apache2/sites-available/ssl-full.conf
+++ b/src/variations/fpm-apache/etc/apache2/sites-available/ssl-full.conf
@@ -3,9 +3,6 @@
ServerName localhost
ServerAdmin webmaster@localhost
- # Set CloudFlare Real IP
- RemoteIPHeader CF-Connecting-IP
-
# Turn on rewrite engine
RewriteEngine On
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
new file mode 100644
index 000000000..9d58bc45b
--- /dev/null
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/cloudflare.conf
@@ -0,0 +1,37 @@
+##
+## Cloudflare - Trusted Proxy
+##
+
+# Set RealIP header
+RemoteIPHeader CF-Connecting-IP
+
+# Allow Cloudflare's IP addresses
+# IP source: https://www.cloudflare.com/ips/
+
+RemoteIPTrustedProxy 173.245.48.0/20
+RemoteIPTrustedProxy 103.21.244.0/22
+RemoteIPTrustedProxy 103.22.200.0/22
+RemoteIPTrustedProxy 103.31.4.0/22
+RemoteIPTrustedProxy 141.101.64.0/18
+RemoteIPTrustedProxy 108.162.192.0/18
+RemoteIPTrustedProxy 190.93.240.0/20
+RemoteIPTrustedProxy 188.114.96.0/20
+RemoteIPTrustedProxy 197.234.240.0/22
+RemoteIPTrustedProxy 198.41.128.0/17
+RemoteIPTrustedProxy 162.158.0.0/15
+RemoteIPTrustedProxy 172.64.0.0/13
+RemoteIPTrustedProxy 131.0.72.0/22
+RemoteIPTrustedProxy 104.16.0.0/13
+RemoteIPTrustedProxy 104.24.0.0/14
+RemoteIPTrustedProxy 2400:cb00::/32
+RemoteIPTrustedProxy 2606:4700::/32
+RemoteIPTrustedProxy 2803:f800::/32
+RemoteIPTrustedProxy 2405:b500::/32
+RemoteIPTrustedProxy 2405:8100::/32
+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
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
new file mode 100644
index 000000000..417f20553
--- /dev/null
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/local.conf
@@ -0,0 +1,14 @@
+##
+## Local - Trusted Proxy
+##
+
+# Set RealIP header
+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
diff --git a/src/variations/fpm-apache/etc/apache2/trusted-proxy/off.conf b/src/variations/fpm-apache/etc/apache2/trusted-proxy/off.conf
new file mode 100644
index 000000000..8dc31e72c
--- /dev/null
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/off.conf
@@ -0,0 +1 @@
+# Include nothing when TRUSTED_PROXY is set to off
\ 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
new file mode 100644
index 000000000..24917082a
--- /dev/null
+++ b/src/variations/fpm-apache/etc/apache2/trusted-proxy/sucuri.conf
@@ -0,0 +1,21 @@
+##
+## Sucuri - Trusted Proxy
+##
+
+# Set RealIP header
+RemoteIPHeader X-Forwarded-For
+
+# Allow Sucuri's IP addresses
+# 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
+
+# Allow Sucuri's IP addresses
+RemoteIPTrustedProxy 192.88.134.0/23
+RemoteIPTrustedProxy 185.93.228.0/22
+RemoteIPTrustedProxy 66.248.200.0/22
+RemoteIPTrustedProxy 2a02:fe80::/29
+RemoteIPTrustedProxy 208.109.0.0/22
\ No newline at end of file
diff --git a/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf b/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf
index 5684f34da..8284272f7 100644
--- a/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf
+++ b/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf
@@ -2,9 +2,6 @@
ServerName localhost
ServerAdmin webmaster@localhost
-# Set CloudFlare Real IP
-RemoteIPHeader CF-Connecting-IP
-
# Configure main document root
DocumentRoot ${APACHE_DOCUMENT_ROOT}
diff --git a/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf b/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf
index f2831f45f..c721ece56 100644
--- a/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf
+++ b/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf
@@ -7,9 +7,6 @@ Protocols h2 http/1.1
SSLProtocol -all +TLSv1.2 +TLSv1.3
-# Set CloudFlare Real IP
-RemoteIPHeader CF-Connecting-IP
-
# Configure main document root
DocumentRoot ${APACHE_DOCUMENT_ROOT}
diff --git a/src/variations/fpm-nginx/Dockerfile b/src/variations/fpm-nginx/Dockerfile
index 83518d33d..e945a3d57 100644
--- a/src/variations/fpm-nginx/Dockerfile
+++ b/src/variations/fpm-nginx/Dockerfile
@@ -173,7 +173,8 @@ ENV APP_BASE_DIR=/var/www/html \
SHOW_WELCOME_MESSAGE=true \
SSL_MODE=off \
SSL_CERTIFICATE_FILE=/etc/ssl/private/self-signed-web.crt \
- SSL_PRIVATE_KEY_FILE=/etc/ssl/private/self-signed-web.key
+ SSL_PRIVATE_KEY_FILE=/etc/ssl/private/self-signed-web.key \
+ TRUSTED_PROXY=cloudflare
# copy our scripts
COPY --chmod=755 src/common/ /
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 08a90ff96..3b2cd4b7b 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
@@ -47,5 +47,8 @@ location ~ \.php$ {
fastcgi_read_timeout $PHP_MAX_EXECUTION_TIME;
}
+# trusted proxy configuration
+include /etc/nginx/trusted-proxy/${TRUSTED_PROXY}.conf;
+
# additional config
include /etc/nginx/server-opts.d/*.conf;
\ No newline at end of file
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 810ff0747..a796f5d7f 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
@@ -53,5 +53,8 @@ location ~ \.php$ {
fastcgi_read_timeout $PHP_MAX_EXECUTION_TIME;
}
+# trusted proxy configuration
+include /etc/nginx/trusted-proxy/${TRUSTED_PROXY}.conf;
+
# additional config
include /etc/nginx/server-opts.d/*.conf;
\ No newline at end of file
diff --git a/src/variations/fpm-nginx/etc/nginx/server-opts.d/remoteip.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf
similarity index 79%
rename from src/variations/fpm-nginx/etc/nginx/server-opts.d/remoteip.conf
rename to src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf
index 22c36bf65..c99d3aea1 100644
--- a/src/variations/fpm-nginx/etc/nginx/server-opts.d/remoteip.conf
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/cloudflare.conf
@@ -1,13 +1,17 @@
##
-# Real IP Addresses
+# Cloudflare - Trusted Proxy
##
-# Configure docker networks
+# Configure docker networks and loopback addresses
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
+set_real_ip_from 127.0.0.1/8;
+set_real_ip_from ::1;
+set_real_ip_from fd00::/8;
-# CloudFlare
+# Allow Cloudflare's IP addresses
+# IP source: https://www.cloudflare.com/ips/
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
new file mode 100644
index 000000000..86563705e
--- /dev/null
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/local.conf
@@ -0,0 +1,14 @@
+##
+# Local - Trusted Proxy
+##
+
+# Configure docker networks and loopback addresses
+set_real_ip_from 10.0.0.0/8;
+set_real_ip_from 172.16.0.0/12;
+set_real_ip_from 192.168.0.0/16;
+set_real_ip_from 127.0.0.1/8;
+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
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/off.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/off.conf
new file mode 100644
index 000000000..8dc31e72c
--- /dev/null
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/off.conf
@@ -0,0 +1 @@
+# Include nothing when TRUSTED_PROXY is set to off
\ No newline at end of file
diff --git a/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
new file mode 100644
index 000000000..11aedd1c0
--- /dev/null
+++ b/src/variations/fpm-nginx/etc/nginx/trusted-proxy/sucuri.conf
@@ -0,0 +1,22 @@
+##
+# Sucuri - Trusted Proxy
+##
+
+# Configure docker networks and loopback addresses
+set_real_ip_from 10.0.0.0/8;
+set_real_ip_from 172.16.0.0/12;
+set_real_ip_from 192.168.0.0/16;
+set_real_ip_from 127.0.0.1/8;
+set_real_ip_from ::1;
+set_real_ip_from fd00::/8;
+
+# Allow Sucuri's IP addresses
+# IP source: https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/
+set_real_ip_from 192.88.134.0/23;
+set_real_ip_from 185.93.228.0/22;
+set_real_ip_from 66.248.200.0/22;
+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
diff --git a/src/variations/frankenphp/Dockerfile b/src/variations/frankenphp/Dockerfile
index f9c1ed677..2034e6f6d 100644
--- a/src/variations/frankenphp/Dockerfile
+++ b/src/variations/frankenphp/Dockerfile
@@ -27,7 +27,8 @@ RUN set -eux; \
/etc/frankenphp/ssl-mode \
/etc/frankenphp/log-level \
/etc/frankenphp/auto-https \
- /etc/frankenphp/caddyfile.d; \
+ /etc/frankenphp/caddyfile.d \
+ /etc/frankenphp/caddyfile-global.d; \
# Create default index.php
echo ' /var/www/html/public/index.php; \
# Create symbolic links
@@ -176,6 +177,7 @@ LABEL org.opencontainers.image.title="serversideup/php (frankenphp)" \
SSL_MODE=off \
SSL_CERTIFICATE_FILE="/etc/ssl/private/self-signed-web.crt" \
SSL_PRIVATE_KEY_FILE="/etc/ssl/private/self-signed-web.key" \
+ TRUSTED_PROXY=cloudflare \
XDG_CONFIG_HOME=/config \
XDG_DATA_HOME=/data
diff --git a/src/variations/frankenphp/etc/frankenphp/Caddyfile b/src/variations/frankenphp/etc/frankenphp/Caddyfile
index 50b2158be..756479417 100644
--- a/src/variations/frankenphp/etc/frankenphp/Caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/Caddyfile
@@ -11,48 +11,16 @@
skip_install_trust
- # Match serversideup/php log levels to Caddy global log levels
import log-level/global/{$LOG_OUTPUT_LEVEL:info}.caddyfile
frankenphp {
{$FRANKENPHP_CONFIG}
}
- servers {
- # Trust Docker/private networks + loopback + Cloudflare ranges
- trusted_proxies static \
-10.0.0.0/8 \
-172.16.0.0/12 \
-192.168.0.0/16 \
-127.0.0.1/8 \
-::1 \
-fd00::/8 \
-173.245.48.0/20 \
-103.21.244.0/22 \
-103.22.200.0/22 \
-103.31.4.0/22 \
-141.101.64.0/18 \
-108.162.192.0/18 \
-190.93.240.0/20 \
-188.114.96.0/20 \
-197.234.240.0/22 \
-198.41.128.0/17 \
-162.158.0.0/15 \
-104.16.0.0/13 \
-104.24.0.0/14 \
-172.64.0.0/13 \
-131.0.72.0/22 \
-2400:cb00::/32 \
-2606:4700::/32 \
-2803:f800::/32 \
-2405:b500::/32 \
-2405:8100::/32 \
-2a06:98c0::/29 \
-2c0f:f248::/32
-
- # Prefer Cloudflare's header; keep XFF as fallback
- client_ip_headers CF-Connecting-IP X-Forwarded-For
- }
+ import trusted-proxy/{$TRUSTED_PROXY:cloudflare}.caddyfile
+
+ # Add additional Caddy configuration files from the caddyfile-global.d directory
+ import caddyfile-global.d/*.caddyfile
{$CADDY_GLOBAL_OPTIONS}
}
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
new file mode 100644
index 000000000..55d6bcdda
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/cloudflare.caddyfile
@@ -0,0 +1,36 @@
+servers {
+ # Trust Docker/private networks + loopback + Cloudflare ranges
+ # IP source: https://www.cloudflare.com/ips/
+ trusted_proxies static \
+10.0.0.0/8 \
+172.16.0.0/12 \
+192.168.0.0/16 \
+127.0.0.1/8 \
+::1 \
+fd00::/8 \
+173.245.48.0/20 \
+103.21.244.0/22 \
+103.22.200.0/22 \
+103.31.4.0/22 \
+141.101.64.0/18 \
+108.162.192.0/18 \
+190.93.240.0/20 \
+188.114.96.0/20 \
+197.234.240.0/22 \
+198.41.128.0/17 \
+162.158.0.0/15 \
+104.16.0.0/13 \
+104.24.0.0/14 \
+172.64.0.0/13 \
+131.0.72.0/22 \
+2400:cb00::/32 \
+2606:4700::/32 \
+2803:f800::/32 \
+2405:b500::/32 \
+2405:8100::/32 \
+2a06:98c0::/29 \
+2c0f:f248::/32
+
+ # 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
new file mode 100644
index 000000000..13a870c12
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/local.caddyfile
@@ -0,0 +1,12 @@
+servers {
+ # Trust Docker/private networks + loopback
+ trusted_proxies static \
+10.0.0.0/8 \
+172.16.0.0/12 \
+192.168.0.0/16 \
+127.0.0.1/8 \
+::1 \
+fd00::/8
+
+ client_ip_headers X-Forwarded-For
+}
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/off.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/off.caddyfile
new file mode 100644
index 000000000..e41cd9acc
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/off.caddyfile
@@ -0,0 +1 @@
+# Include nothing when TRUSTED_PROXY is set to off
diff --git a/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
new file mode 100644
index 000000000..5de970404
--- /dev/null
+++ b/src/variations/frankenphp/etc/frankenphp/trusted-proxy/sucuri.caddyfile
@@ -0,0 +1,18 @@
+servers {
+ # Trust Docker/private networks + loopback + Sucuri ranges
+ # IP source: https://docs.sucuri.net/website-firewall/sucuri-firewall-troubleshooting-guide/
+ trusted_proxies static \
+10.0.0.0/8 \
+172.16.0.0/12 \
+192.168.0.0/16 \
+127.0.0.1/8 \
+::1 \
+fd00::/8 \
+192.88.134.0/23 \
+185.93.228.0/22 \
+66.248.200.0/22 \
+2a02:fe80::/29 \
+208.109.0.0/22
+
+ client_ip_headers X-Forwarded-For
+}
From 9ad2270c858f3092eea4a15c277487d60ce5cf42 Mon Sep 17 00:00:00 2001
From: Bugeac Sergiu
Date: Tue, 27 Jan 2026 19:01:08 +0200
Subject: [PATCH 02/13] Do not generate SSL if DISABLE_DEFAULT_CONFIG is set
(#644)
* Config checks for SSL certificate generation
Add checks for DISABLE_DEFAULT_CONFIG and set default SSL file paths.
* Removed comment since the code is readable
---------
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
---
.../etc/entrypoint.d/5-generate-ssl.sh | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh b/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh
index 86f030d18..82c28d374 100644
--- a/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh
+++ b/src/utilities-webservers/etc/entrypoint.d/5-generate-ssl.sh
@@ -3,7 +3,11 @@
# Usage: 5-generate-ssl.sh
###################################################
# This script generates a self-signed SSL certificate and key for the container.
-script_name="generate-ssl"
+script_name="generate-ssl"
+
+if [ "$DISABLE_DEFAULT_CONFIG" = "true" ]; then
+ exit 0
+fi
SSL_CERTIFICATE_FILE=${SSL_CERTIFICATE_FILE:-"/etc/ssl/private/self-signed-web.crt"}
SSL_PRIVATE_KEY_FILE=${SSL_PRIVATE_KEY_FILE:-"/etc/ssl/private/self-signed-web.key"}
@@ -52,4 +56,4 @@ openssl req -x509 \
-keyout "$SSL_PRIVATE_KEY_FILE" \
-out "$SSL_CERTIFICATE_FILE" \
-days 365 >/dev/null 2>&1
-exit 0
\ No newline at end of file
+exit 0
From 595c3b7fc1f51f91e4e2a349cf770457bfbce427 Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Tue, 27 Jan 2026 11:30:17 -0600
Subject: [PATCH 03/13] Refactor Laravel migration script to improve isolation
handling and database connection checks (#628)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Refactor Laravel migration script to improve isolation handling and database connection checks
- Removed migration isolation argument from test-db-connection script.
- Introduced a helper function for running migrations per database, enhancing clarity and reusability.
- Updated migration flag assembly logic to better manage isolation settings.
- Improved error handling and logging for database connection issues.
* Remove debug log from Laravel migration script to streamline output during migrations
* Update Laravel migration script to change error message from error to warning for version check
* Update PHP base opertating systems. Add Alpine 3.23 support (#638)
PHP repo no longer has images available for alpine3.21 for latest minor versions, this removes 3.21 if its no longer available and adds 3.23 if available
* Update PHP extension installer version to 2.9.27
* Update NGINX version to 1.28.1
* Refactor and improve security headers, file blocks, etc (#631)
* Fix blocking .well-known path for FrankenPHP
* Remove specific endpoint checks because the whole folder should be allowed
* Enhance security configurations across Apache, NGINX, and Caddy by implementing best practices for HTTP headers and file access restrictions. Added protections against clickjacking, MIME type sniffing, and sensitive file exposure while allowing necessary access to well-known URIs as per RFC 8615.
---------
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Co-authored-by: Jay Rogers
* Update GitHub Actions to use actions/checkout@v6 in multiple workflows
* Update GitHub Actions to use actions/upload-artifact@v6 for improved artifact handling
* Update GitHub Actions to use actions/download-artifact@v7 for improved artifact handling
* Upgrade FrankenPHP to v1.11.1
* Increase size of GitHub Actions Runners because of memory segmentation fault issues (Thanks Depot! đ
)
* Add security measures to block PHP execution in storage directory (#641)
Implemented restrictions across Apache, NGINX, and FrankenPHP configurations to prevent the execution of PHP files in the /storage directory, addressing potential vulnerabilities related to arbitrary file uploads (GHSA-29cq-5w36-x7w3).
* Refactor Laravel version check and isolation mode handling in automation script
- Updated the logic for enabling isolation mode based on Laravel version, ensuring it only activates for versions 9.38.0 and above.
- Improved error handling and logging for Laravel version detection, providing clearer output when version determination fails.
- Simplified version comparison logic to enhance readability and maintainability.
* Update container info script to include automation status
---------
Co-authored-by: alloylab
Co-authored-by: Marcel Arns <38068686+marns93@users.noreply.github.com>
---
.../etc/entrypoint.d/0-container-info.sh | 3 +-
.../entrypoint.d/50-laravel-automations.sh | 145 ++++++++++++------
.../lib/laravel/test-db-connection.php | 33 +---
3 files changed, 105 insertions(+), 76 deletions(-)
diff --git a/src/common/etc/entrypoint.d/0-container-info.sh b/src/common/etc/entrypoint.d/0-container-info.sh
index a71de57ca..b92ee2bdb 100644
--- a/src/common/etc/entrypoint.d/0-container-info.sh
+++ b/src/common/etc/entrypoint.d/0-container-info.sh
@@ -55,7 +55,8 @@ Brought to you by serversideup.net
âĸ Upload Limit: '"$UPLOAD_LIMIT"'
đ Runtime
-âĸ Docker CMD: '"$DOCKER_CMD"'
+âĸ Automations: '"$AUTORUN_ENABLED"'
+âĸ Docker CMD: '"$DOCKER_CMD"'
'
if [ "$PHP_OPCACHE_STATUS" = "0" ]; then
diff --git a/src/common/etc/entrypoint.d/50-laravel-automations.sh b/src/common/etc/entrypoint.d/50-laravel-automations.sh
index 1e86e0348..3634c0a12 100644
--- a/src/common/etc/entrypoint.d/50-laravel-automations.sh
+++ b/src/common/etc/entrypoint.d/50-laravel-automations.sh
@@ -53,10 +53,6 @@ fi
############################################################################
artisan_migrate() {
- migrate_flags=""
-
- debug_log "Starting migrations (isolation: $AUTORUN_LARAVEL_MIGRATION_ISOLATION)"
-
echo "đ Clearing Laravel cache before attempting migrations..."
php "$APP_BASE_DIR/artisan" config:clear
@@ -73,7 +69,8 @@ artisan_migrate() {
;;
esac
- # Build migration flags (used for all databases)
+ # Determine if isolation is intended to be used
+ isolation_enabled="false"
if [ "$AUTORUN_LARAVEL_MIGRATION_ISOLATION" = "true" ]; then
# Isolation only works in default mode
if [ "$AUTORUN_LARAVEL_MIGRATION_MODE" != "default" ]; then
@@ -82,14 +79,18 @@ artisan_migrate() {
fi
# Isolation requires Laravel 9.38.0+
- if ! laravel_version_is_at_least "9.38.0"; then
- echo "â $script_name: Isolated migrations require Laravel v9.38.0 or above. Detected version: $(get_laravel_version)"
- return 1
+ if laravel_version_is_at_least "9.38.0"; then
+ isolation_enabled="true"
+ debug_log "Isolation mode enabled (Laravel version check passed)"
+ else
+ echo "â ī¸ $script_name: Isolated migrations require Laravel v9.38.0 or above. Detected version: $(get_laravel_version)"
+ echo " Continuing without isolation mode..."
fi
-
- migrate_flags="$migrate_flags --isolated"
fi
+ # Start assembling migration flags
+ migrate_flags=""
+
if [ "$AUTORUN_LARAVEL_MIGRATION_FORCE" = "true" ]; then
migrate_flags="$migrate_flags --force"
fi
@@ -98,30 +99,55 @@ artisan_migrate() {
migrate_flags="$migrate_flags --seed"
fi
- # Determine if multiple databases are specified
+ # Helper function to run migrations for a specific database
+ run_migration_for_db() {
+ db_name="${1:-}"
+
+ # Build display name and database flag for messages/commands
+ if [ -n "$db_name" ]; then
+ db_display_name="'$db_name'"
+ db_flag="--database=$db_name"
+ else
+ db_display_name="default database"
+ db_flag=""
+ fi
+
+ # Wait for database connection
+ if ! wait_for_database_connection $db_name; then
+ echo "â $script_name: Failed to connect to $db_display_name"
+ return 1
+ fi
+
+ # Determine if --isolated can be used for this database
+ db_migrate_flags="$migrate_flags"
+ if [ "$isolation_enabled" = "true" ]; then
+ if db_has_migrations_table $db_name; then
+ db_migrate_flags="$db_migrate_flags --isolated"
+ debug_log "Using --isolated flag for $db_display_name"
+ else
+ echo "âšī¸ Skipping --isolated flag for $db_display_name: migrations table not ready (normal for first deployment)"
+ echo " The --isolated flag will be used on subsequent deployments."
+ fi
+ fi
+
+ echo "đ Running migrations for $db_display_name"
+ php "$APP_BASE_DIR/artisan" $migration_command $db_flag $db_migrate_flags
+ }
+
+ # Run migrations for specified database(s)
if [ -n "$AUTORUN_LARAVEL_MIGRATION_DATABASE" ]; then
databases=$(convert_comma_delimited_to_space_separated "$AUTORUN_LARAVEL_MIGRATION_DATABASE")
database_list=$(echo "$databases" | tr ',' ' ')
for db in $database_list; do
- # Wait for this specific database to be ready
- if ! wait_for_database_connection "$db"; then
- echo "â $script_name: Failed to connect to database: $db"
+ if ! run_migration_for_db "$db"; then
return 1
fi
-
- echo "đ Running migrations for database: $db"
- php "$APP_BASE_DIR/artisan" $migration_command --database=$db $migrate_flags
done
else
- # Wait for default database connection
- if ! wait_for_database_connection; then
- echo "â $script_name: Failed to connect to default database"
+ if ! run_migration_for_db; then
return 1
fi
-
- # Run migration with default database connection
- php "$APP_BASE_DIR/artisan" $migration_command $migrate_flags
fi
}
@@ -241,17 +267,16 @@ get_laravel_version() {
fi
debug_log "Detecting Laravel version..."
- # Use 2>/dev/null to handle potential PHP warnings
- artisan_version_output=$(php "$APP_BASE_DIR/artisan" --version 2>/dev/null)
- # Check if command was successful
- if [ $? -ne 0 ]; then
+ # Capture artisan output
+ if ! artisan_version_output=$(php "$APP_BASE_DIR/artisan" --version 2>/dev/null); then
echo "â $script_name: Failed to execute artisan command" >&2
return 1
fi
+ debug_log "Raw artisan output: $artisan_version_output"
+
# Extract version number using sed (POSIX compliant)
- # Using a more strict pattern that matches "Laravel Framework X.Y.Z"
laravel_version=$(echo "$artisan_version_output" | sed -e 's/^Laravel Framework \([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*$/\1/')
# Validate that we got a version number (POSIX compliant regex)
@@ -261,7 +286,7 @@ get_laravel_version() {
echo "$laravel_version"
return 0
else
- echo "â $script_name: Failed to determine Laravel version" >&2
+ echo "â $script_name: Failed to determine Laravel version from: $artisan_version_output" >&2
return 1
fi
}
@@ -286,33 +311,55 @@ laravel_version_is_at_least() {
return 1
fi
- # Validate required version format
- if ! echo "$required_version" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
- echo "â $script_name - Invalid version requirement format: $required_version" >&2
- return 1
- fi
-
current_version=$(get_laravel_version)
if [ $? -ne 0 ]; then
echo "â $script_name: Failed to get Laravel version" >&2
return 1
fi
- # normalize_version() takes a version string and ensures it has 3 parts
- normalize_version() {
- echo "$1" | awk -F. '{ print $1"."$2"."(NF>2?$3:0) }'
- }
+ # Extract version components using cut (POSIX compliant)
+ cur_major=$(echo "$current_version" | cut -d. -f1)
+ cur_minor=$(echo "$current_version" | cut -d. -f2)
+ cur_patch=$(echo "$current_version" | cut -d. -f3)
+
+ req_major=$(echo "$required_version" | cut -d. -f1)
+ req_minor=$(echo "$required_version" | cut -d. -f2)
+ req_patch=$(echo "$required_version" | cut -d. -f3)
- normalized_current=$(normalize_version "$current_version")
- normalized_required=$(normalize_version "$required_version")
+ # Default patch to 0 if not specified
+ : "${cur_patch:=0}"
+ : "${req_patch:=0}"
- # Use sort -V to get the lower version, then compare it with required version
- # This works in BusyBox because we only need to check the first line of output
- lowest_version=$(printf '%s\n%s\n' "$normalized_required" "$normalized_current" | sort -V | head -n1)
- if [ "$lowest_version" = "$normalized_required" ]; then
- return 0 # Success: current version is >= required version
+ # Numeric comparison (POSIX arithmetic expansion handles this correctly)
+ # Compare major version
+ if [ "$cur_major" -gt "$req_major" ]; then
+ return 0
+ elif [ "$cur_major" -lt "$req_major" ]; then
+ return 1
+ fi
+
+ # Major versions equal, compare minor
+ if [ "$cur_minor" -gt "$req_minor" ]; then
+ return 0
+ elif [ "$cur_minor" -lt "$req_minor" ]; then
+ return 1
+ fi
+
+ # Minor versions equal, compare patch
+ if [ "$cur_patch" -ge "$req_patch" ]; then
+ return 0
+ fi
+
+ return 1
+}
+
+db_has_migrations_table() {
+ database_arg="${1:-}"
+
+ if [ -n "$database_arg" ]; then
+ php "$APP_BASE_DIR/artisan" migrate:status --database="$database_arg" > /dev/null 2>&1
else
- return 1 # Failure: current version is < required version
+ php "$APP_BASE_DIR/artisan" migrate:status > /dev/null 2>&1
fi
}
@@ -324,9 +371,9 @@ test_db_connection() {
# Pass database connection name only if specified (not empty)
database_arg="${1:-}"
if [ -n "$database_arg" ]; then
- php "$AUTORUN_LIB_DIR/laravel/test-db-connection.php" "$APP_BASE_DIR" "$AUTORUN_LARAVEL_MIGRATION_MODE" "$AUTORUN_LARAVEL_MIGRATION_ISOLATION" "$database_arg"
+ php "$AUTORUN_LIB_DIR/laravel/test-db-connection.php" "$APP_BASE_DIR" "$AUTORUN_LARAVEL_MIGRATION_MODE" "$database_arg"
else
- php "$AUTORUN_LIB_DIR/laravel/test-db-connection.php" "$APP_BASE_DIR" "$AUTORUN_LARAVEL_MIGRATION_MODE" "$AUTORUN_LARAVEL_MIGRATION_ISOLATION"
+ php "$AUTORUN_LIB_DIR/laravel/test-db-connection.php" "$APP_BASE_DIR" "$AUTORUN_LARAVEL_MIGRATION_MODE"
fi
}
diff --git a/src/common/etc/entrypoint.d/lib/laravel/test-db-connection.php b/src/common/etc/entrypoint.d/lib/laravel/test-db-connection.php
index e4817fd3b..0c0b52259 100644
--- a/src/common/etc/entrypoint.d/lib/laravel/test-db-connection.php
+++ b/src/common/etc/entrypoint.d/lib/laravel/test-db-connection.php
@@ -5,12 +5,11 @@
* This script tests if the Laravel application can connect to its configured database.
* It's designed to be called from shell scripts during container initialization.
*
- * Usage: php test-db-connection.php /path/to/app/base/dir [migration_mode] [migration_isolation] [database_connection]
+ * Usage: php test-db-connection.php /path/to/app/base/dir [migration_mode] [database_connection]
*
* Arguments:
- * app_base_dir - Path to Laravel application root
- * migration_mode - Migration mode: 'default', 'fresh', or 'refresh' (optional, defaults to 'default')
- * migration_isolation - Whether to run migrations in isolation (optional, defaults to 'false')
+ * app_base_dir - Path to Laravel application root
+ * migration_mode - Migration mode: 'default', 'fresh', or 'refresh' (optional, defaults to 'default')
* database_connection - Name of the database connection to test (optional, defaults to 'default')
*
* Exit codes:
@@ -21,15 +20,14 @@
*/
// Validate arguments
-if ($argc < 2 || $argc > 5) {
- fwrite(STDERR, "Usage: php test-db-connection.php /path/to/app/base/dir [migration_mode] [migration_isolation] [database_connection]\n");
+if ($argc < 2 || $argc > 4) {
+ fwrite(STDERR, "Usage: php test-db-connection.php /path/to/app/base/dir [migration_mode] [database_connection]\n");
exit(1);
}
$appBaseDir = $argv[1];
$migrationMode = $argc >= 3 ? $argv[2] : 'default';
-$migrationIsolation = $argc >= 4 ? $argv[3] : 'false';
-$databaseConnection = $argc >= 5 ? $argv[4] : null;
+$databaseConnection = $argc >= 4 ? $argv[3] : null;
// Validate migration mode
$validModes = ['default', 'fresh', 'refresh'];
@@ -38,13 +36,6 @@
exit(1);
}
-// Validate migration isolation
-$validIsolations = ['true', 'false'];
-if (!in_array($migrationIsolation, $validIsolations)) {
- fwrite(STDERR, "Error: Invalid migration isolation '{$migrationIsolation}'. Must be one of: " . implode(', ', $validIsolations) . "\n");
- exit(1);
-}
-
// Validate that the app base directory exists
if (!is_dir($appBaseDir)) {
fwrite(STDERR, "Error: App base directory does not exist: {$appBaseDir}\n");
@@ -126,17 +117,7 @@
exit(1);
}
- // For isolated migrations, the database file must exist (even in default mode)
- if ($migrationIsolation === 'true') {
- fwrite(STDERR, "SQLite database file does not exist: {$dbPath}\n");
- fwrite(STDERR, "Isolated migrations require the database file to exist before running.\n");
- fwrite(STDERR, "Either:\n");
- fwrite(STDERR, " 1. Create the database (ensure it has read and write permissions for your user): touch {$dbPath}\n");
- fwrite(STDERR, " 2. Set AUTORUN_LARAVEL_MIGRATION_ISOLATION=false to let migrations create it\n");
- exit(1);
- }
-
- // Directory exists and is writable - migrations can create the database file (default mode only)
+ // Directory exists and is writable - migrations can create the database file
fwrite(STDOUT, "SQLite database directory is ready - migrations will create database\n");
exit(0);
}
From 096b1ba80983b0f8e863a7ce672728dfa4b8c8f7 Mon Sep 17 00:00:00 2001
From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com>
Date: Tue, 27 Jan 2026 19:04:36 +0100
Subject: [PATCH 04/13] Add Nightwatch health check script (#572)
* Add Nightwatch health check script
* Add documentation for Laravel Nightwatch
---------
Co-authored-by: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Co-authored-by: Jay Rogers
---
.../1.laravel/5.nightwatch.md | 51 +++++++++++++++++++
.../usr/local/bin/healthcheck-nightwatch | 2 +
2 files changed, 53 insertions(+)
create mode 100644 docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md
create mode 100755 src/common/usr/local/bin/healthcheck-nightwatch
diff --git a/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md b/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md
new file mode 100644
index 000000000..2d5e82e54
--- /dev/null
+++ b/docs/content/docs/3.framework-guides/1.laravel/5.nightwatch.md
@@ -0,0 +1,51 @@
+---
+head.title: 'Laravel Nightwatch with Docker - Docker PHP - Server Side Up'
+description: 'Learn how to configure Laravel Nightwatch with Docker.'
+layout: docs
+title: Nightwatch
+---
+
+## Laravel Nightwatch with Docker
+Run Laravel Nightwatch by passing the Artisan command as the container's command. Nightwatch is Laravel's observability platform for monitoring application performance, errors, and queries in real-time.
+
+::note
+Before using Nightwatch with Docker, follow the [Laravel Nightwatch setup instructions](https://nightwatch.laravel.com/docs/start-guide) to install and configure the Nightwatch package in your Laravel application.
+::
+
+## Docker Compose example
+
+This example runs Nightwatch as a separate container using the same image as your web service.
+
+**Key points:**
+- Use the same image for both your web and Nightwatch services
+- Set `SIGTERM` as the stop signal for graceful shutdown (especially for `fpm-apache` and `fpm-nginx`)
+- Include a health check to monitor Nightwatch status
+
+```yml [compose.yml]
+services:
+ php:
+ image: my/laravel-app
+
+ nightwatch:
+ image: my/laravel-app
+ command: ["php", "/var/www/html/artisan", "nightwatch:agent"]
+ stop_signal: SIGTERM
+ healthcheck:
+ test: ["CMD", "healthcheck-nightwatch"]
+ start_period: 10s
+```
+
+## How the health check works
+
+The `healthcheck-nightwatch` command runs `php artisan nightwatch:status` to verify that the Nightwatch agent is running and connected. Docker uses this to determine container health and can automatically restart unhealthy containers.
+
+## Advanced configuration
+
+**Graceful shutdown:** The `SIGTERM` signal ensures the Nightwatch agent finishes processing current events before stopping. This is especially important for `fpm-apache` and `fpm-nginx` images.
+
+::tip
+**Multiple processes in one container:** If you're running `fpm-nginx` or `fpm-apache` and you'd like to have everything in a single container, you can [write your own S6 Overlay service script](https://github.com/just-containers/s6-overlay/tree/master#writing-a-service-script){target="_blank"} to properly manage multiple processes in a single container. Learn more about about this in our [Using S6 Overlay guide](/docs/guide/using-s6-overlay).
+::
+
+## Learn More
+- [Laravel Nightwatch Documentation](https://nightwatch.laravel.com/docs/start-guide)
diff --git a/src/common/usr/local/bin/healthcheck-nightwatch b/src/common/usr/local/bin/healthcheck-nightwatch
new file mode 100755
index 000000000..7c21a462c
--- /dev/null
+++ b/src/common/usr/local/bin/healthcheck-nightwatch
@@ -0,0 +1,2 @@
+#!/bin/sh
+php "${APP_BASE_DIR}/artisan" nightwatch:status
\ No newline at end of file
From d8dc11a884f18bdc8652c5d83a0271ba13984102 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 27 Jan 2026 12:11:02 -0600
Subject: [PATCH 05/13] Update S6 version to v3.2.2.0
---
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 417d3f509..a07f0a1a9 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.1.0
+S6_VERSION=v3.2.2.0
mkdir -p $S6_DIR
export SYS_ARCH=$(uname -m)
case "$SYS_ARCH" in
From 03dc54154b6553bf7efc757b516b5f7c697a1a93 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 3 Mar 2026 15:43:25 -0600
Subject: [PATCH 06/13] Implement retry logic and JSON validation for fetching
PHP versions in get-php-versions.sh
- Added a retry mechanism with a maximum of 3 attempts for fetching PHP versions from the specified JSON feed.
- Included validation to ensure the response is valid JSON, with error handling for invalid responses.
- Enhanced error messages to provide more context in case of failures.
---
scripts/get-php-versions.sh | 50 +++++++++++++++++++++++++++++++++++--
1 file changed, 48 insertions(+), 2 deletions(-)
diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh
index fb4d588b1..002083cd0 100755
--- a/scripts/get-php-versions.sh
+++ b/scripts/get-php-versions.sh
@@ -237,8 +237,54 @@ function echo_color_message (){
if [ "$SKIP_DOWNLOAD" = false ]; then
echo_color_message yellow "âĄī¸ Getting PHP Versions from $PHP_VERSIONS_ACTIVE_JSON_FEED"
- # Fetch the JSON from the PHP website
- php_net_version_json=$(curl -s $PHP_VERSIONS_ACTIVE_JSON_FEED)
+
+ # Fetch the JSON from the PHP website with retry logic and validation
+ max_retries=3
+ retry_count=0
+ php_net_version_json=""
+
+ while [ $retry_count -lt $max_retries ]; do
+ http_code=$(curl -s -o /tmp/php_versions_response.json -w "%{http_code}" --max-time 30 --connect-timeout 10 "$PHP_VERSIONS_ACTIVE_JSON_FEED")
+
+ if [ "$http_code" = "200" ]; then
+ php_net_version_json=$(cat /tmp/php_versions_response.json)
+ rm -f /tmp/php_versions_response.json
+
+ # Validate that the response is actually JSON
+ if echo "$php_net_version_json" | jq empty 2>/dev/null; then
+ break
+ else
+ echo_color_message red "â Response from php.net returned HTTP $http_code but body is not valid JSON."
+ echo_color_message red "--- Response Body (first 500 chars) ---"
+ echo "$php_net_version_json" | head -c 500
+ echo ""
+ echo_color_message red "--- End Response Body ---"
+ php_net_version_json=""
+ fi
+ else
+ echo_color_message red "â Failed to fetch PHP versions from php.net (HTTP $http_code)"
+ if [ -f /tmp/php_versions_response.json ]; then
+ echo_color_message red "--- Response Body (first 500 chars) ---"
+ head -c 500 /tmp/php_versions_response.json
+ echo ""
+ echo_color_message red "--- End Response Body ---"
+ rm -f /tmp/php_versions_response.json
+ fi
+ fi
+
+ retry_count=$((retry_count + 1))
+ if [ $retry_count -lt $max_retries ]; then
+ wait_time=$((retry_count * 5))
+ echo_color_message yellow "â ī¸ Retrying in ${wait_time}s... (attempt $((retry_count + 1))/$max_retries)"
+ sleep "$wait_time"
+ fi
+ done
+
+ if [ -z "$php_net_version_json" ]; then
+ echo_color_message red "â Failed to fetch valid JSON from $PHP_VERSIONS_ACTIVE_JSON_FEED after $max_retries attempts"
+ echo "::error title=PHP Version Fetch Failed::Failed to get valid JSON from php.net after $max_retries attempts. The server may be returning a Cloudflare challenge, rate limiting, or experiencing an outage. Check the response body logged above for details."
+ exit 1
+ fi
# Parse the fetched JSON data and optionally validate PHP versions on DockerHub
if [ "$SKIP_DOCKERHUB_VALIDATION" = true ]; then
From f51ee326423e9a231f23a087c02fe6a71d03d97a Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 3 Mar 2026 15:47:11 -0600
Subject: [PATCH 07/13] Update user agent in PHP versions fetch script for
improved request handling
- Modified the curl command in get-php-versions.sh to include a custom user agent string for better identification of requests.
- This change aims to enhance compatibility with the PHP versions JSON feed.
---
scripts/get-php-versions.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/get-php-versions.sh b/scripts/get-php-versions.sh
index 002083cd0..35d69ff5e 100755
--- a/scripts/get-php-versions.sh
+++ b/scripts/get-php-versions.sh
@@ -244,7 +244,7 @@ if [ "$SKIP_DOWNLOAD" = false ]; then
php_net_version_json=""
while [ $retry_count -lt $max_retries ]; do
- http_code=$(curl -s -o /tmp/php_versions_response.json -w "%{http_code}" --max-time 30 --connect-timeout 10 "$PHP_VERSIONS_ACTIVE_JSON_FEED")
+ http_code=$(curl -s -o /tmp/php_versions_response.json -w "%{http_code}" --max-time 30 --connect-timeout 10 -A "serversideup-docker-php/1.0 (https://github.com/serversideup/docker-php)" "$PHP_VERSIONS_ACTIVE_JSON_FEED")
if [ "$http_code" = "200" ]; then
php_net_version_json=$(cat /tmp/php_versions_response.json)
From ba1734f3292dfa67e8c1c2734e606e4ed0da6cf1 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 3 Mar 2026 15:48:16 -0600
Subject: [PATCH 08/13] Update GitHub Actions workflow to use 'ubuntu-24.04'
for the build environment
---
.github/workflows/service_docker-build-and-publish.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/service_docker-build-and-publish.yml b/.github/workflows/service_docker-build-and-publish.yml
index 190f5a163..c288236fe 100644
--- a/.github/workflows/service_docker-build-and-publish.yml
+++ b/.github/workflows/service_docker-build-and-publish.yml
@@ -34,7 +34,7 @@ on:
jobs:
setup-matrix:
- runs-on: depot-ubuntu-24.04
+ runs-on: ubuntu-24.04
outputs:
php-version-map-json: ${{ steps.get-php-versions.outputs.php-version-map-json }}
steps:
From 53ed0f06a811a3222331557d1d4a724189ef32fd Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Thu, 5 Mar 2026 12:48:30 -0600
Subject: [PATCH 09/13] Update GitHub Actions workflow to use
'depot-ubuntu-24.04' for the build environment
---
.github/workflows/service_docker-build-and-publish.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/service_docker-build-and-publish.yml b/.github/workflows/service_docker-build-and-publish.yml
index c288236fe..190f5a163 100644
--- a/.github/workflows/service_docker-build-and-publish.yml
+++ b/.github/workflows/service_docker-build-and-publish.yml
@@ -34,7 +34,7 @@ on:
jobs:
setup-matrix:
- runs-on: ubuntu-24.04
+ runs-on: depot-ubuntu-24.04
outputs:
php-version-map-json: ${{ steps.get-php-versions.outputs.php-version-map-json }}
steps:
From 8203769d49487d1f09669690b565b0133f62ddf3 Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Tue, 14 Apr 2026 09:42:41 -0500
Subject: [PATCH 10/13] Add redirect rules to prevent SEO duplicate content
across web server configurations (#646)
---
.../fpm-apache/etc/apache2/vhost-templates/http.conf | 5 +++++
.../fpm-apache/etc/apache2/vhost-templates/https.conf | 5 +++++
.../fpm-nginx/etc/nginx/site-opts.d/http.conf.template | 5 +++++
.../fpm-nginx/etc/nginx/site-opts.d/https.conf.template | 5 +++++
src/variations/frankenphp/etc/frankenphp/Caddyfile | 4 ++++
5 files changed, 24 insertions(+)
diff --git a/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf b/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf
index 8284272f7..585121668 100644
--- a/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf
+++ b/src/variations/fpm-apache/etc/apache2/vhost-templates/http.conf
@@ -13,6 +13,11 @@ DocumentRoot ${APACHE_DOCUMENT_ROOT}
DirectoryIndex index.php index.html index.htm
+# Redirect /index.php/... to /... to prevent SEO duplicate content
+RewriteEngine On
+RewriteCond %{THE_REQUEST} \s/index\.php/
+RewriteRule ^/index\.php(/.+)$ $1 [R=301,L,QSA]
+
# Healthchecks: Set /healthcheck to be the healthcheck URL
ProxyPass "/healthcheck" "fcgi://localhost:9000"
ProxyPassReverse "/healthcheck" "fcgi://localhost:9000"
diff --git a/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf b/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf
index c721ece56..dd23e7e9f 100644
--- a/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf
+++ b/src/variations/fpm-apache/etc/apache2/vhost-templates/https.conf
@@ -18,6 +18,11 @@ DocumentRoot ${APACHE_DOCUMENT_ROOT}
DirectoryIndex index.php index.html index.htm
+# Redirect /index.php/... to /... to prevent SEO duplicate content
+RewriteEngine On
+RewriteCond %{THE_REQUEST} \s/index\.php/
+RewriteRule ^/index\.php(/.+)$ $1 [R=301,L,QSA]
+
# Healthchecks: Set /healthcheck to be the healthcheck URL
ProxyPass "/healthcheck" "fcgi://localhost:9000"
ProxyPassReverse "/healthcheck" "fcgi://localhost:9000"
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 3b2cd4b7b..02d9c54fb 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
@@ -36,6 +36,11 @@ location ~* ^/storage/.*\.php$ {
deny all;
}
+# Redirect /index.php/... to /... to prevent SEO duplicate content
+location ~ ^/index\.php(/.+)$ {
+ return 301 $1$is_args$args;
+}
+
# Pass "*.php" files to PHP-FPM
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
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 a796f5d7f..547bfe1b3 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
@@ -42,6 +42,11 @@ location ~* ^/storage/.*\.php$ {
deny all;
}
+# Redirect /index.php/... to /... to prevent SEO duplicate content
+location ~ ^/index\.php(/.+)$ {
+ return 301 $1$is_args$args;
+}
+
# Pass "*.php" files to PHP-FPM
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
diff --git a/src/variations/frankenphp/etc/frankenphp/Caddyfile b/src/variations/frankenphp/etc/frankenphp/Caddyfile
index 756479417..cc883e8e1 100644
--- a/src/variations/frankenphp/etc/frankenphp/Caddyfile
+++ b/src/variations/frankenphp/etc/frankenphp/Caddyfile
@@ -59,6 +59,10 @@
}
log_skip @healthcheckpath
+ # Redirect /index.php/... to /... to prevent SEO duplicate content
+ @indexphp path_regexp indexphp ^/index\.php(/.+)$
+ redir @indexphp {re.indexphp.1} 301
+
php_server {
{$CADDY_PHP_SERVER_OPTIONS}
}
From 7e9a7f89f3201de238374cfdc455a79947e066d7 Mon Sep 17 00:00:00 2001
From: Jay Rogers <3174134+jaydrogers@users.noreply.github.com>
Date: Tue, 14 Apr 2026 09:55:23 -0500
Subject: [PATCH 11/13] Apache: Change DirectoryMatch to LocationMatch in
Apache security configuration (#652)
---
.../fpm-apache/etc/apache2/conf-available/security.conf | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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 f572f2501..217b697b7 100644
--- a/src/variations/fpm-apache/etc/apache2/conf-available/security.conf
+++ b/src/variations/fpm-apache/etc/apache2/conf-available/security.conf
@@ -66,9 +66,9 @@ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains
# for ACME challenges, security.txt, and other standardized endpoints.
# https://www.rfc-editor.org/rfc/rfc8615
# https://github.com/h5bp/server-configs-apache
-
+
Require all denied
-
+
# Block access to files that may expose sensitive information
# Based on H5BP server configs: https://github.com/h5bp/server-configs-apache
From 464a0368675fe739521af945fe04eecdbab98a41 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 14 Apr 2026 10:07:48 -0500
Subject: [PATCH 12/13] Update PHP extension installer version from 2.9.27 to
2.10.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 a3c3cfc04..785bc3a03 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.9.27"
+PHP_EXT_INSTALLER_VERSION="2.10.12"
############
# Main
From a64e1bfb6654a84cd85b48d3d799912cf448ee00 Mon Sep 17 00:00:00 2001
From: Jay Rogers
Date: Tue, 14 Apr 2026 12:12:03 -0500
Subject: [PATCH 13/13] Improved AI agent context
---
AGENTS.md | 150 +++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 127 insertions(+), 23 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index af40e7fa1..117b79933 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,23 +1,127 @@
-You are a highly skilled PHP system administrator tasked with maintaining open source PHP Docker images for Laravel applications. Your goal is to assist in creating production-ready Docker images that follow best practices for security, performance, and developer experience using the guidelines below.
-
-1. Skills you posses deep knowledge and best practices of:
- - Docker
- - PHP
- - Laravel
- - GitHub Actions
- - Shell scripting
- - S6 Overlay
- - Nginx
- - Apache
- - PHP-FPM
-
-2. Development Guidelines:
-
- - Follow the best practices for security, performance, and developer experience.
- - Write clean, maintainable and technically accurate code.
- - All entrypoint scripts for the Docker images must be POSIX compliant and able to be executed with /bin/sh.
- - Any /bin/sh scripts must be compatible with Debian and Alpine Linux.
- - For any /bin/bash scripts, these should work with MacOS, Linux, and WSL2.
- - Never use an approach you're not confident about. If you're unsure about something, ask for clarity.
-
-This project is open source and the code is available on GitHub, so be sure to follow best practices to make it easy for others to understand, modify, and contribute to the project.
\ No newline at end of file
+# AI Agent Guidelines
+
+This project maintains open source PHP Docker images (`serversideup/php`) for Laravel and other PHP applications. Images are published to Docker Hub and GitHub Packages. These images build on top of the official PHP Docker images with production-grade defaults, security hardening, and a superior developer experience through environment-variable-driven configuration. See `docs/content/docs/1.getting-started/4.these-images-vs-others.md` for the full philosophy.
+
+## Project Structure
+
+```
+src/
+ variations/ # One Dockerfile per image variation (cli, fpm, fpm-apache, fpm-nginx, frankenphp)
+ common/ # Shared scripts and configs copied into ALL variations
+ usr/local/bin/ # Entrypoint and helper scripts (POSIX /bin/sh)
+ etc/entrypoint.d/# Numbered priority entrypoint scripts (00-*, 50-*, etc.)
+ s6/ # Shared S6 Overlay service definitions and install script
+ php-fpm.d/ # Shared PHP-FPM pool configuration templates
+ utilities-webservers/ # Shared web server entrypoint utilities (SSL, etc.)
+scripts/ # Build tooling (Bash, not POSIX)
+ dev.sh # Local image builds
+ conf/ # PHP version matrix and base config (YAML)
+ generate-matrix.sh # Generates CI build matrix from YAML config
+ assemble-docker-tags.sh
+docs/ # Nuxt 4 documentation site (see docs/AGENTS.md for docs-specific guidelines)
+.github/workflows/ # CI/CD with GitHub Actions + Depot for multi-arch builds
+```
+
+## Shell Script Rules
+
+**IMPORTANT:** There are two distinct shell environments in this project. Getting this wrong breaks images.
+
+- **`src/` scripts** (entrypoint, healthcheck, helper scripts): MUST be POSIX-compliant `/bin/sh`. These run inside Docker containers on both **Debian** and **Alpine** Linux. No bashisms (`[[ ]]`, arrays, `local -n`, process substitution, etc.).
+- **`scripts/` directory** (build tooling): Bash (`/bin/bash`). Must work on macOS, Linux, and WSL2.
+
+### Common gotchas
+- Alpine uses BusyBox `sh`, not `bash`. Commands like `readlink -f`, `sed -i` (without backup extension), and `which` behave differently.
+- Use `command -v` instead of `which` in POSIX scripts.
+- Use `$(...)` not backticks for command substitution.
+- Test OS detection with `[ -f /etc/alpine-release ]` (Alpine) or `[ -f /etc/debian_version ]` (Debian).
+
+## Naming Conventions
+
+- Container scripts in `src/common/usr/local/bin/` follow the prefix pattern: `docker-php-serversideup-*` (e.g., `docker-php-serversideup-entrypoint`, `docker-php-serversideup-set-file-permissions`).
+- Healthcheck scripts use: `healthcheck-*` (e.g., `healthcheck-horizon`, `healthcheck-queue`).
+- Entrypoint.d scripts use numbered prefixes for execution order: `0-container-info.sh`, `1-log-output-level.sh`, `50-laravel-automations.sh`.
+
+## Image Architecture
+
+**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.
+
+Each variation Dockerfile uses multi-stage builds:
+1. Shared assets are `COPY`ed from `src/common/`, `src/s6/`, `src/php-fpm.d/`, and `src/utilities-webservers/`
+2. Variation-specific configs live in `src/variations//etc/`
+3. The build context is the **project root** (not `src/`), so COPY paths are relative to root: `COPY --chmod=755 src/common/ /`
+
+### Build args used across Dockerfiles
+- `PHP_VERSION`, `BASE_OS_VERSION`, `PHP_VARIATION` -- set by CI matrix or `scripts/dev.sh`
+- `NGINX_VERSION` -- resolved per-OS from `scripts/conf/php-versions-base-config.yml`
+- `REPOSITORY_BUILD_VERSION` -- image version label
+
+### Variations
+| Variation | Web Server | Process Manager | S6 Overlay |
+|-----------|-----------|----------------|------------|
+| cli | None | None | No |
+| fpm | None | PHP-FPM | No |
+| fpm-apache | Apache | PHP-FPM | Yes |
+| fpm-nginx | NGINX | PHP-FPM | Yes |
+| frankenphp | Caddy (FrankenPHP) | Built-in | No |
+
+## Building Locally
+There is a helper script in the `scripts/` directory that will build the image locally. If you attempt to build the image and Docker is not running, tell the user to start Docker Desktop or ensure that the Docker daemon is running before trying again.
+
+```sh
+# Requires: Docker with buildx, yq (for fpm-nginx NGINX version resolution)
+scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm
+
+# Other examples
+scripts/dev.sh --variation cli --version 8.5 --os alpine3.22
+scripts/dev.sh --variation fpm-nginx --version 8.4 --os bookworm --no-cache
+scripts/dev.sh --variation frankenphp --version 8.5 --os bookworm --push
+```
+
+## PHP Version Pipeline
+
+PHP versions are NOT hardcoded. The pipeline works like this:
+1. `scripts/get-php-versions.sh` fetches the latest active PHP releases from `https://www.php.net/releases/active.php`
+2. It validates each version actually exists on DockerHub (with automatic fallback to previous patch if not yet published)
+3. The fetched versions are merged with the base config (`scripts/conf/php-versions-base-config.yml`) which defines OS bases, variations, and NGINX versions
+4. The merged result is written to `scripts/conf/php-versions.yml` -- this is the source of truth for CI builds
+5. `scripts/generate-matrix.sh` reads the final YAML and produces the GitHub Actions matrix JSON
+
+When modifying the version pipeline, the base config (`php-versions-base-config.yml`) is the file you edit. Never edit `php-versions.yml` directly -- it's generated.
+
+## 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`.
+- 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).
+
+## Verification
+
+There is no automated test suite for image logic. To verify changes:
+1. Build the affected variation locally with `scripts/dev.sh`
+2. Run the built image and confirm the change works: `docker run --rm -it serversideup/php:8.4-fpm-nginx-bookworm sh`
+3. For entrypoint script changes, test on both Debian and Alpine builds
+4. Run `shellcheck` on any modified shell scripts when available
+
+## Key Design Decisions
+
+- **Unprivileged by default**: Images run as `www-data`, not root. Web servers listen on `8080`/`8443` (unprivileged ports).
+- **Lightweight images**: Only install dependencies that are truly necessary. See `docs/content/docs/1.getting-started/6.default-configurations.md` for what's included and why. When adding packages or extensions, justify the inclusion and keep image size minimal.
+- **Environment-variable-driven**: All PHP/FPM/web server configuration is controlled via env vars -- no config file editing at runtime.
+- **S6 Overlay** manages multiple processes in web server variations (FPM + web server).
+- **Laravel automations** (migrations, caching, etc.) are opt-in via `AUTORUN_ENABLED`.
+- **SSL support** is built-in with `SSL_MODE` (off/full) and self-signed cert generation.
+- **One Dockerfile per variation**: OS-specific logic belongs in helper scripts, not Dockerfile conditionals or duplicate files.
+
+## Documentation
+
+The documentation and marketing site lives in `docs/` and has its own `docs/AGENTS.md` with guidelines specific to the Nuxt 4 content site. When working in `docs/`, follow that file instead of this one.
+
+## Reference
+
+When you need details beyond what's in this file, read these local sources rather than guessing:
+
+- **Environment variables** (the canonical reference for ALL env vars, their defaults, and which variations they apply to): `docs/content/docs/8.reference/1.environment-variable-specification.md`
+- **Default configurations** (what packages, extensions, and settings ship with each image): `docs/content/docs/1.getting-started/6.default-configurations.md`
+- **All documentation**: `docs/content/docs/` contains the full docs in markdown. Browse this directory for guides on image variations, framework integrations, deployment, customization, and troubleshooting. There is also a dedicated `docs/AGENTS.md` file for the documentation site itself
+- **LLM-optimized docs** (for AI tools that can fetch URLs): https://serversideup.net/open-source/docker-php/llms.txt and https://serversideup.net/open-source/docker-php/llms-full.txt to view the latest stable versions of the documentation.