diff --git a/.github/workflows/container-validation.yml b/.github/workflows/container-validation.yml
index 094b290..e9a195f 100644
--- a/.github/workflows/container-validation.yml
+++ b/.github/workflows/container-validation.yml
@@ -104,6 +104,9 @@ jobs:
- name: simplerisk (full-stack) generator
run: ./simplerisk/test_generate_dockerfile.sh
+ - name: simplerisk-minimal Apache worker setting
+ run: ./simplerisk-minimal/test_set_apache_workers.sh
+
- name: Fail if a generator left the committed Dockerfile modified
run: |
set -euo pipefail
diff --git a/CLAUDE.md b/CLAUDE.md
index df74b7e..47eb9d6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -101,6 +101,7 @@ The entrypoint script handles:
| `SIMPLERISK_DB_USERNAME/PASSWORD/DATABASE` | DB credentials |
| `SIMPLERISK_CRON_SETUP` | Enable/disable PHP cron (default: enabled) |
| `SIMPLERISK_CSRF_SECRET` | Override auto-generated CSRF secret |
+| `APACHE_MAX_REQUEST_WORKERS` | Optional cap (1-2000) on simultaneous Apache workers, written by the entrypoint's `set_apache_workers`; unset keeps Apache's default of 150. Each request holds a database connection, so size it against the database's `max_connections`, the replica count, the cron job and the session connection (`SIMPLERISK_DB_FOR_SESSIONS`), and the container's memory. Invalid values are ignored with a warning; a failed write logs an error and keeps the default |
| `ADMIN_USERNAME` | Username for initial admin user (only during `DB_SETUP`; idempotent) |
| `ADMIN_PASSWORD` | Password for initial admin user |
| `ADMIN_EMAIL` | Email for initial admin user |
diff --git a/simplerisk-minimal/README.md b/simplerisk-minimal/README.md
index e8c6931..0be21f2 100644
--- a/simplerisk-minimal/README.md
+++ b/simplerisk-minimal/README.md
@@ -75,6 +75,7 @@ docker run -d --name simplerisk -e SIMPLERISK_DB_PASSWORD=pass -e SIMPLERISK_DB_
| `SIMPLERISK_DB_SSL_CERT_PATH` | Empty string (`''`) | Path where SSL certificates, to contact the database, are located |
| `SIMPLERISK_CRON_SETUP` | `enabled` | Install the cron job to run in this Docker container |
| `SIMPLERISK_CSRF_SECRET` | Auto-generated | Override the auto-generated CSRF secret |
+| `APACHE_MAX_REQUEST_WORKERS` | Apache default (150) | Maximum number of requests Apache serves at once (a whole number from 1 to 2000). Each request holds a database connection, so set this so that `replicas x APACHE_MAX_REQUEST_WORKERS` stays under the database's `max_connections`, leaving room for the cron jobs and for the second connection used when `SIMPLERISK_DB_FOR_SESSIONS` is `true`. Each worker is a full PHP process, so also keep it within the container's memory limit. It applies to the Apache prefork MPM the image ships with. An invalid value is ignored with a warning in the container log |
| `ADMIN_USERNAME` | — | Username for an initial admin user created once during `DB_SETUP`. Requires `ADMIN_PASSWORD` and `ADMIN_EMAIL`. Skipped if the username already exists |
| `ADMIN_PASSWORD` | — | Password for the initial admin user |
| `ADMIN_EMAIL` | — | Email address for the initial admin user |
diff --git a/simplerisk-minimal/common/entrypoint.sh b/simplerisk-minimal/common/entrypoint.sh
index cf43d40..afbddbc 100644
--- a/simplerisk-minimal/common/entrypoint.sh
+++ b/simplerisk-minimal/common/entrypoint.sh
@@ -229,6 +229,56 @@ set_ssl_certificate(){
exec_cmd "chmod 600 $key" "Failed to set permissions on TLS private key. Exiting."
}
+set_apache_workers(){
+ # Optional cap on how many requests Apache serves at once. Every request
+ # holds a database connection, so the default of 150 workers can exceed a
+ # small database's max_connections (a db.t3.micro allows about 60), and
+ # several containers sharing one database multiply it. The right number
+ # depends on the database and the replica count, which the image cannot
+ # know, so it is left to the operator. Unset keeps Apache's default.
+ local workers="${APACHE_MAX_REQUEST_WORKERS:-}"
+ local conf="${APACHE_MPM_CONF_PATH:-/etc/apache2/conf-enabled/simplerisk-mpm.conf}"
+
+ # Remove what a previous start wrote, so unsetting the variable on a
+ # restarted container goes back to Apache's default
+ rm -f "$conf"
+
+ if [ -z "$workers" ]; then
+ return 0
+ fi
+
+ # Strip leading zeros and bound the length before any arithmetic: bash
+ # arithmetic wraps at 64 bits, so a very long digit string could otherwise
+ # wrap into the valid range
+ local digits="${workers#"${workers%%[!0]*}"}"
+
+ if ! [[ "$workers" =~ ^[0-9]+$ ]] || [ -z "$digits" ] || [ "${#digits}" -gt 4 ] || [ "$digits" -gt 2000 ]; then
+ print_log "apache_setup:warn" "APACHE_MAX_REQUEST_WORKERS must be a whole number between 1 and 2000 -- leaving Apache's default."
+ return 0
+ fi
+
+ workers="$digits"
+
+ local content="# Written by the container entrypoint from APACHE_MAX_REQUEST_WORKERS"$'\n'""$'\n'
+ # ServerLimit defaults to 256 and silently caps MaxRequestWorkers
+ if [ "$workers" -gt 256 ]; then
+ content+=$'\t'"ServerLimit ${workers}"$'\n'
+ fi
+ content+=$'\t'"MaxRequestWorkers ${workers}"$'\n'""
+
+ # Do not claim a cap that was not applied: if the file cannot be written
+ # (for example a read-only root filesystem), say so and keep the default.
+ # A single printf is used because a failed redirect on a { } group does not
+ # reliably report failure.
+ if ! printf '%s\n' "$content" > "$conf"; then
+ print_log "apache_setup:error" "Could not write ${conf} -- leaving Apache's default."
+ rm -f "$conf" || true
+ return 0
+ fi
+
+ print_log "apache_setup:info" "Apache will serve at most ${workers} requests at once."
+}
+
set_cron(){
# If SIMPLERISK_CRON_SETUP was passed and it is set to disabled
if [[ -n "${SIMPLERISK_CRON_SETUP:-}" && "${SIMPLERISK_CRON_SETUP:-}" = disabled* ]]; then
@@ -495,6 +545,8 @@ _main() {
print_log "initial_setup:info" "No DB env vars provided; config.php will not be written. The SimpleRisk web installer will run at first request."
fi
+ set_apache_workers
+
set_cron
if [[ -n ${DB_SETUP:-} ]]; then
diff --git a/simplerisk-minimal/test_set_apache_workers.sh b/simplerisk-minimal/test_set_apache_workers.sh
new file mode 100755
index 0000000..7efe100
--- /dev/null
+++ b/simplerisk-minimal/test_set_apache_workers.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+# Exercises set_apache_workers() from common/entrypoint.sh without starting
+# Apache: the function is extracted and run against a temporary conf path.
+# Usage: ./test_set_apache_workers.sh
+
+set -eo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+# print_log is what the entrypoint defines; capture its output so the tests
+# can assert on what an operator would see
+print_log(){ echo "[$1] $2"; }
+
+# Pull just the function out of the entrypoint (from its header to the first
+# closing brace at column 0). Fails loudly if the function moves or is renamed.
+func="$(awk '/^set_apache_workers\(\) *\{/{p=1} p{print} p&&/^}/{exit}' "$here/common/entrypoint.sh")"
+[ -n "$func" ] || { echo "FAIL: set_apache_workers not found in entrypoint.sh"; exit 1; }
+eval "$func"
+
+export APACHE_MPM_CONF_PATH="$tmp/simplerisk-mpm.conf"
+failures=0
+log=""
+
+check(){
+ local name="$1"; shift
+ if "$@"; then echo "ok $name"; else echo "FAIL $name"; failures=$((failures+1)); fi
+}
+
+# run : run the function in a subshell with the variable set to
+# (or unset for an empty argument) and keep what it logged in $log
+run(){ log="$( unset APACHE_MAX_REQUEST_WORKERS; [ -z "${1:-}" ] || export APACHE_MAX_REQUEST_WORKERS="$1"; set_apache_workers 2>&1 )"; }
+
+has(){ grep -q -- "$1" "$APACHE_MPM_CONF_PATH"; }
+hasnt(){ ! grep -q -- "$1" "$APACHE_MPM_CONF_PATH"; }
+absent(){ [ ! -e "$APACHE_MPM_CONF_PATH" ]; }
+logged(){ [[ "$log" == *"$1"* ]]; }
+
+expected_300=$'# Written by the container entrypoint from APACHE_MAX_REQUEST_WORKERS\n\n\tServerLimit 300\n\tMaxRequestWorkers 300\n'
+
+run "" ; check "unset writes nothing" absent
+run 40 ; check "40 sets MaxRequestWorkers" has "MaxRequestWorkers 40"
+run 40 ; check "40 leaves ServerLimit alone" hasnt ServerLimit
+run 40 ; check "40 logs the cap" logged "at most 40"
+run 1 ; check "lower bound 1 is accepted" has "MaxRequestWorkers 1$"
+run 256 ; check "256 does not raise ServerLimit" hasnt ServerLimit
+run 257 ; check "257 raises ServerLimit" has "ServerLimit 257"
+run 2000 ; check "upper bound 2000 is accepted" has "MaxRequestWorkers 2000"
+run 300 ; check "300 writes the exact expected file" test "$(cat "$APACHE_MPM_CONF_PATH")" = "$expected_300"
+run 0040 ; check "leading zeros are normalised" has "MaxRequestWorkers 40$"
+run 000000000000000000000040 ; check "long zero padding is normalised" has "MaxRequestWorkers 40$"
+run "" ; check "unsetting removes the earlier file" absent
+
+# Rejected values: no file, a warning, and never a claim that a cap was applied
+for bad in abc 0 00 2001 -5 1.5 " 40" "40 " "4 0" 18446744073709553616 99999999999999999999 "40; rm -rf /" '$(touch x)' '`id`' $'40\n41' '٣'; do
+ run "$bad"
+ check "rejects '${bad//$'\n'/\\n}'" bash -c '[ ! -e "$1" ]' _ "$APACHE_MPM_CONF_PATH"
+ check " and warns instead of claiming a cap" bash -c '[[ "$1" == *"[apache_setup:warn]"* && "$1" != *"at most"* ]]' _ "$log"
+done
+
+# A conf path that cannot be written: say so, write nothing, and do not claim the cap
+saved="$APACHE_MPM_CONF_PATH"
+export APACHE_MPM_CONF_PATH="$tmp/no-such-dir/simplerisk-mpm.conf"
+run 40
+check "unwritable path logs an error" logged "[apache_setup:error]"
+check "unwritable path does not claim a cap" bash -c '[[ "$1" != *"at most"* ]]' _ "$log"
+check "unwritable path leaves no file" absent
+export APACHE_MPM_CONF_PATH="$saved"
+
+# The entrypoint must actually call the function, before it hands off to Apache
+call_line="$(grep -n '^[[:space:]]*set_apache_workers$' "$here/common/entrypoint.sh" | head -1 | cut -d: -f1)"
+exec_line="$(grep -n '^[[:space:]]*exec "\$@"' "$here/common/entrypoint.sh" | tail -1 | cut -d: -f1)"
+check "entrypoint calls set_apache_workers before exec" bash -c '[ -n "$1" ] && [ -n "$2" ] && [ "$1" -lt "$2" ]' _ "$call_line" "$exec_line"
+
+[ "$failures" -eq 0 ] || { echo "$failures check(s) failed"; exit 1; }
+echo "all checks passed"