diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml
index 1141d69..297f894 100644
--- a/.gitea/workflows/build-push.yaml
+++ b/.gitea/workflows/build-push.yaml
@@ -38,3 +38,65 @@ jobs:
tags: |
repo.anhonesthost.net/cloud-hosting-platform/cac:php${{ matrix.phpver }}
${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac:latest' || '' }}
+
+ Build-FPM-Images:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ phpver: [74, 80, 81, 82, 83, 84, 85]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Gitea
+ uses: docker/login-action@v3
+ with:
+ registry: repo.anhonesthost.net
+ username: ${{ secrets.CI_USER }}
+ password: ${{ secrets.CI_TOKEN }}
+
+ - name: Build and Push FPM Image
+ uses: docker/build-push-action@v6
+ with:
+ file: ./Dockerfile.fpm
+ platforms: linux/amd64
+ push: true
+ build-args: |
+ PHPVER=${{ matrix.phpver }}
+ tags: |
+ repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:php${{ matrix.phpver }}
+ ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-fpm:latest' || '' }}
+
+ Build-Shared-httpd:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Gitea
+ uses: docker/login-action@v3
+ with:
+ registry: repo.anhonesthost.net
+ username: ${{ secrets.CI_USER }}
+ password: ${{ secrets.CI_TOKEN }}
+
+ - name: Build and Push Shared httpd Image
+ uses: docker/build-push-action@v6
+ with:
+ file: ./Dockerfile.shared-httpd
+ platforms: linux/amd64
+ push: true
+ tags: |
+ repo.anhonesthost.net/cloud-hosting-platform/shared-httpd:latest
diff --git a/Dockerfile.fpm b/Dockerfile.fpm
new file mode 100644
index 0000000..1477ba5
--- /dev/null
+++ b/Dockerfile.fpm
@@ -0,0 +1,43 @@
+FROM almalinux/9-base
+ARG PHPVER=83
+
+# Install repos, update, install only needed packages (no httpd/mod_ssl), clean up in one layer
+RUN dnf install -y \
+ https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm \
+ https://rpms.remirepo.net/enterprise/remi-release-9.rpm && \
+ dnf update -y && \
+ dnf install -y wget procps cronie iproute postgresql-devel microdnf less git \
+ nano rsync unzip zip mariadb bind-utils jq patch nc tree dos2unix fcgi && \
+ dnf clean all && \
+ rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
+
+# Copy scripts into the image and set permissions
+COPY ./scripts/ /scripts/
+RUN chmod +x /scripts/*
+
+# Create needed dirs, install PHP, clean up (no SSL cert, no httpd)
+RUN mkdir -p /run/php-fpm/ && \
+ /scripts/install-php$PHPVER.sh && \
+ rm -rf /tmp/*
+
+# Download and install wp-cli
+RUN curl -L -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && \
+ chmod +x /usr/local/bin/wp
+
+# Download and install Composer
+RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \
+ chmod +x /usr/local/bin/composer
+
+# Copy configs (PHP only, no Apache configs)
+COPY ./configs/prod-php.ini /etc/php.ini
+COPY ./configs/mariadb.repo /etc/yum.repos.d/
+
+# Set up cron job for log rotation
+RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
+
+EXPOSE 9000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+ CMD SCRIPT_FILENAME=/fpm-ping SCRIPT_NAME=/fpm-ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 | grep -q pong || exit 1
+
+ENTRYPOINT [ "/scripts/entrypoint-fpm.sh" ]
diff --git a/Dockerfile.shared-httpd b/Dockerfile.shared-httpd
new file mode 100644
index 0000000..838394d
--- /dev/null
+++ b/Dockerfile.shared-httpd
@@ -0,0 +1,40 @@
+FROM almalinux/9-base
+
+# Install Apache and minimal dependencies (no PHP at all)
+RUN dnf install -y \
+ https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm && \
+ dnf update -y && \
+ dnf install -y httpd mod_ssl iproute cronie procps curl && \
+ dnf clean all && \
+ rm -rf /var/cache/dnf /usr/share/doc /usr/share/man /usr/share/locale/*
+
+# Copy scripts and set permissions
+COPY ./scripts/detect-memory.sh /scripts/detect-memory.sh
+COPY ./scripts/create-apache-mpm-config.sh /scripts/create-apache-mpm-config.sh
+COPY ./scripts/log-rotate.sh /scripts/log-rotate.sh
+COPY ./scripts/entrypoint-shared-httpd.sh /scripts/entrypoint-shared-httpd.sh
+COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh
+RUN chmod +x /scripts/*
+
+# Generate self-signed SSL cert (same as main CAC image)
+RUN openssl req -newkey rsa:2048 -nodes \
+ -keyout /etc/pki/tls/private/localhost.key \
+ -x509 -days 3650 -subj "/CN=localhost" \
+ -out /etc/pki/tls/certs/localhost.crt
+
+# Copy Apache configs
+COPY ./configs/remote_ip.conf /etc/httpd/conf.d/
+COPY ./configs/default-index.conf /etc/httpd/conf.d/
+
+# Create vhosts directory (will be volume-mounted from host)
+RUN mkdir -p /etc/httpd/conf.d/vhosts
+
+# Set up cron job for log rotation
+RUN echo "15 */12 * * * root /scripts/log-rotate.sh" >> /etc/crontab
+
+EXPOSE 80 443
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+ CMD curl -sfk https://localhost/ping || exit 1
+
+ENTRYPOINT [ "/scripts/entrypoint-shared-httpd.sh" ]
diff --git a/configs/shared-vhost-template.tpl b/configs/shared-vhost-template.tpl
new file mode 100644
index 0000000..3ca0c57
--- /dev/null
+++ b/configs/shared-vhost-template.tpl
@@ -0,0 +1,39 @@
+
+ AllowOverride None
+ Require all granted
+
+
+
+ Options All MultiViews
+ AllowOverride All
+ Require all granted
+
+
+
+ ServerName "~~domain~~"
+ ~~alias_block~~
+ DocumentRoot "/mnt/users/~~user~~/~~domain~~/public_html"
+ RewriteEngine on
+ RewriteCond %{SERVER_NAME} =~~domain~~
+ RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
+
+
+
+
+ ServerName "~~domain~~"
+ ~~alias_block~~
+ DocumentRoot "/mnt/users/~~user~~/~~domain~~/public_html"
+
+ SSLCertificateFile /etc/pki/tls/certs/localhost.crt
+ SSLCertificateKeyFile /etc/pki/tls/private/localhost.key
+
+
+ SetHandler "proxy:fcgi://~~fpm_host~~:~~fpm_port~~"
+
+
+ DirectoryIndex index.php index.html index.htm
+
+ ErrorLog "/var/log/httpd/~~domain~~-error.log"
+ CustomLog "/var/log/httpd/~~domain~~-access.log" combined
+
+
diff --git a/scripts/create-php-config.sh b/scripts/create-php-config.sh
index 5fa253e..ffdcb95 100644
--- a/scripts/create-php-config.sh
+++ b/scripts/create-php-config.sh
@@ -2,15 +2,28 @@
rm /etc/php-fpm.d/www.conf
+FPM_LISTEN=${FPM_LISTEN:-/run/php-fpm/www.sock}
+
+# Determine listen directive and ownership based on socket vs TCP
+if echo "$FPM_LISTEN" | grep -q '/'; then
+ # Unix socket mode
+ listen_directive="$FPM_LISTEN"
+ listen_owner_block="listen.owner = apache
+listen.group = apache"
+else
+ # TCP port mode
+ listen_directive="0.0.0.0:${FPM_LISTEN}"
+ listen_owner_block=""
+fi
+
cat < /etc/php-fpm.d/$user.conf
[$user]
user = $user
group = $user
-listen = /run/php-fpm/www.sock
-listen.owner = apache
-listen.group = apache
+listen = ${listen_directive}
+${listen_owner_block}
pm = ${PHP_FPM_PM}
pm.max_children = ${PHP_FPM_MAX_CHILDREN}
@@ -22,7 +35,12 @@ pm.start_servers = ${PHP_FPM_START_SERVERS}
pm.min_spare_servers = ${PHP_FPM_MIN_SPARE}
pm.max_spare_servers = ${PHP_FPM_MAX_SPARE}
-slowlog = /etc/httpd/logs/error_log
+; Health check endpoints
+ping.path = /fpm-ping
+ping.response = pong
+pm.status_path = /fpm-status
+
+slowlog = /home/$user/logs/php-fpm/slowlog
request_slowlog_timeout = 3s
php_admin_value[error_log] = /home/$user/logs/php-fpm/error.log
diff --git a/scripts/detect-memory.sh b/scripts/detect-memory.sh
index bf7f5d7..da27045 100755
--- a/scripts/detect-memory.sh
+++ b/scripts/detect-memory.sh
@@ -38,6 +38,8 @@ fi
CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024))
# --- Budget calculation ---
+CONTAINER_ROLE=${CONTAINER_ROLE:-combined} # combined | fpm_only | httpd_only
+
OS_RESERVE_MB=50
FIXED_PROCESS_MB=50
DEV_OVERHEAD_MB=0
@@ -50,63 +52,83 @@ if [ "$AVAILABLE_MB" -lt 60 ]; then
AVAILABLE_MB=60
fi
-PHP_BUDGET_MB=$((AVAILABLE_MB * 80 / 100))
-APACHE_BUDGET_MB=$((AVAILABLE_MB * 20 / 100))
+case "$CONTAINER_ROLE" in
+ fpm_only)
+ PHP_BUDGET_MB=$AVAILABLE_MB
+ APACHE_BUDGET_MB=0
+ ;;
+ httpd_only)
+ PHP_BUDGET_MB=0
+ APACHE_BUDGET_MB=$AVAILABLE_MB
+ ;;
+ *)
+ PHP_BUDGET_MB=$((AVAILABLE_MB * 80 / 100))
+ APACHE_BUDGET_MB=$((AVAILABLE_MB * 20 / 100))
+ ;;
+esac
-# --- PHP-FPM parameters ---
-PHP_WORKER_ESTIMATE_MB=${PHP_WORKER_ESTIMATE_MB:-60}
+# --- PHP-FPM parameters (skipped for httpd_only) ---
+if [ "$CONTAINER_ROLE" != "httpd_only" ]; then
+ PHP_WORKER_ESTIMATE_MB=${PHP_WORKER_ESTIMATE_MB:-60}
-calc_max_children=$((PHP_BUDGET_MB / PHP_WORKER_ESTIMATE_MB))
-# Floor at 2, cap at 50
-if [ "$calc_max_children" -lt 2 ]; then
- calc_max_children=2
-fi
-if [ "$calc_max_children" -gt 50 ]; then
- calc_max_children=50
+ calc_max_children=$((PHP_BUDGET_MB / PHP_WORKER_ESTIMATE_MB))
+ # Floor at 2, cap at 50
+ if [ "$calc_max_children" -lt 2 ]; then
+ calc_max_children=2
+ fi
+ if [ "$calc_max_children" -gt 50 ]; then
+ calc_max_children=50
+ fi
+
+ PHP_FPM_PM=${FPM_PM:-ondemand}
+ PHP_FPM_MAX_CHILDREN=${FPM_MAX_CHILDREN:-$calc_max_children}
+ PHP_FPM_PROCESS_IDLE_TIMEOUT=${FPM_PROCESS_IDLE_TIMEOUT:-10s}
+ PHP_FPM_MAX_REQUESTS=${FPM_MAX_REQUESTS:-500}
+
+ # Dynamic mode fallbacks (used if user overrides FPM_PM=dynamic)
+ PHP_FPM_START_SERVERS=${FPM_START_SERVERS:-2}
+ PHP_FPM_MIN_SPARE=${FPM_MIN_SPARE_SERVERS:-1}
+ PHP_FPM_MAX_SPARE=${FPM_MAX_SPARE_SERVERS:-3}
fi
-PHP_FPM_PM=${FPM_PM:-ondemand}
-PHP_FPM_MAX_CHILDREN=${FPM_MAX_CHILDREN:-$calc_max_children}
-PHP_FPM_PROCESS_IDLE_TIMEOUT=${FPM_PROCESS_IDLE_TIMEOUT:-10s}
-PHP_FPM_MAX_REQUESTS=${FPM_MAX_REQUESTS:-500}
+# --- Apache MPM parameters (skipped for fpm_only) ---
+if [ "$CONTAINER_ROLE" != "fpm_only" ]; then
+ # ServerLimit: roughly 1 process per ~25 workers, floor 2, cap 16
+ calc_server_limit=$((APACHE_BUDGET_MB / 30))
+ if [ "$calc_server_limit" -lt 2 ]; then
+ calc_server_limit=2
+ fi
+ if [ "$calc_server_limit" -gt 16 ]; then
+ calc_server_limit=16
+ fi
-# Dynamic mode fallbacks (used if user overrides FPM_PM=dynamic)
-PHP_FPM_START_SERVERS=${FPM_START_SERVERS:-2}
-PHP_FPM_MIN_SPARE=${FPM_MIN_SPARE_SERVERS:-1}
-PHP_FPM_MAX_SPARE=${FPM_MAX_SPARE_SERVERS:-3}
+ # MaxRequestWorkers: ServerLimit * ThreadsPerChild (25)
+ calc_max_request_workers=$((calc_server_limit * 25))
+ if [ "$calc_max_request_workers" -gt 400 ]; then
+ calc_max_request_workers=400
+ fi
-# --- Apache MPM parameters ---
-# ServerLimit: roughly 1 process per ~25 workers, floor 2, cap 16
-calc_server_limit=$((APACHE_BUDGET_MB / 30))
-if [ "$calc_server_limit" -lt 2 ]; then
- calc_server_limit=2
+ # StartServers: 1 for ≤1GB, 2 for larger
+ calc_start_servers=1
+ if [ "$CONTAINER_MEMORY_MB" -gt 1024 ]; then
+ calc_start_servers=2
+ fi
+
+ APACHE_START_SERVERS=${APACHE_START_SERVERS:-$calc_start_servers}
+ APACHE_SERVER_LIMIT=${APACHE_SERVER_LIMIT:-$calc_server_limit}
+ APACHE_MAX_REQUEST_WORKERS=${APACHE_MAX_REQUEST_WORKERS:-$calc_max_request_workers}
+ APACHE_MIN_SPARE_THREADS=${APACHE_MIN_SPARE_THREADS:-5}
+ APACHE_MAX_SPARE_THREADS=${APACHE_MAX_SPARE_THREADS:-15}
+ APACHE_MAX_CONNECTIONS_PER_CHILD=${APACHE_MAX_CONNECTIONS_PER_CHILD:-3000}
fi
-if [ "$calc_server_limit" -gt 16 ]; then
- calc_server_limit=16
-fi
-
-# MaxRequestWorkers: ServerLimit * ThreadsPerChild (25)
-calc_max_request_workers=$((calc_server_limit * 25))
-if [ "$calc_max_request_workers" -gt 400 ]; then
- calc_max_request_workers=400
-fi
-
-# StartServers: 1 for ≤1GB, 2 for larger
-calc_start_servers=1
-if [ "$CONTAINER_MEMORY_MB" -gt 1024 ]; then
- calc_start_servers=2
-fi
-
-APACHE_START_SERVERS=${APACHE_START_SERVERS:-$calc_start_servers}
-APACHE_SERVER_LIMIT=${APACHE_SERVER_LIMIT:-$calc_server_limit}
-APACHE_MAX_REQUEST_WORKERS=${APACHE_MAX_REQUEST_WORKERS:-$calc_max_request_workers}
-APACHE_MIN_SPARE_THREADS=${APACHE_MIN_SPARE_THREADS:-5}
-APACHE_MAX_SPARE_THREADS=${APACHE_MAX_SPARE_THREADS:-15}
-APACHE_MAX_CONNECTIONS_PER_CHILD=${APACHE_MAX_CONNECTIONS_PER_CHILD:-3000}
# --- Export all variables ---
-export CONTAINER_MEMORY_MB
-export PHP_FPM_PM PHP_FPM_MAX_CHILDREN PHP_FPM_PROCESS_IDLE_TIMEOUT PHP_FPM_MAX_REQUESTS
-export PHP_FPM_START_SERVERS PHP_FPM_MIN_SPARE PHP_FPM_MAX_SPARE
-export APACHE_START_SERVERS APACHE_SERVER_LIMIT APACHE_MAX_REQUEST_WORKERS
-export APACHE_MIN_SPARE_THREADS APACHE_MAX_SPARE_THREADS APACHE_MAX_CONNECTIONS_PER_CHILD
+export CONTAINER_ROLE CONTAINER_MEMORY_MB
+if [ "$CONTAINER_ROLE" != "httpd_only" ]; then
+ export PHP_FPM_PM PHP_FPM_MAX_CHILDREN PHP_FPM_PROCESS_IDLE_TIMEOUT PHP_FPM_MAX_REQUESTS
+ export PHP_FPM_START_SERVERS PHP_FPM_MIN_SPARE PHP_FPM_MAX_SPARE
+fi
+if [ "$CONTAINER_ROLE" != "fpm_only" ]; then
+ export APACHE_START_SERVERS APACHE_SERVER_LIMIT APACHE_MAX_REQUEST_WORKERS
+ export APACHE_MIN_SPARE_THREADS APACHE_MAX_SPARE_THREADS APACHE_MAX_CONNECTIONS_PER_CHILD
+fi
diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh
new file mode 100755
index 0000000..ae2d445
--- /dev/null
+++ b/scripts/entrypoint-fpm.sh
@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+
+if [ -z "$PHPVER" ]; then
+ PHPVER="83";
+fi
+
+if [ -z "$environment" ]; then
+ environment="PROD"
+fi
+
+# Default to FPM-only role
+export CONTAINER_ROLE="fpm_only"
+export FPM_LISTEN=${FPM_LISTEN:-9000}
+
+adduser -u $uid $user
+
+mkdir -p /home/$user/public_html
+mkdir -p /home/$user/logs/php-fpm
+
+ln -sf /home/$user/logs/php-fpm /var/log/php-fpm
+
+source /scripts/detect-memory.sh
+echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP-FPM pm=${PHP_FPM_PM} max_children=${PHP_FPM_MAX_CHILDREN} | Listen=${FPM_LISTEN}"
+
+/scripts/create-php-config.sh
+
+mkdir -p /run/php-fpm/
+/usr/sbin/php-fpm -y /etc/php-fpm.conf
+chown -R $user:$user /home/$user
+chmod -R 755 /home/$user
+
+if [[ $environment == 'DEV' ]]; then
+ echo "Starting Dev Deployment (FPM-only mode)"
+ mkdir -p /home/$user/_db_backups
+ if ! command -v microdnf &> /dev/null; then
+ echo "microdnf not found, installing with dnf..."
+ dnf install -y microdnf && dnf clean all
+ fi
+ microdnf install -y MariaDB-server MariaDB-client memcached
+ sed -r -i 's/session.save_path="memcache:11211/session.save_path="localhost:11211/' /etc/php.ini
+ nohup mysqld -umysql &
+ if [ ! -f /home/$user/mysql_creds ]; then
+ echo "Give MySQL a chance to finish starting..."
+ sleep 10
+ mysql_user=$(tr -dc A-Za-z0-9 /home/$user/crontab
+ echo "*/15 * * * * /scripts/mysql-backup.sh $user devdb_$mysql_db" >> /home/$user/crontab
+ chown $user:$user /home/$user/crontab
+ echo "MySQL User: "$mysql_user > /home/$user/mysql_creds
+ echo "MySQL Password: "$mysql_password >> /home/$user/mysql_creds
+ echo "MySQL Database: devdb_"$mysql_db >> /home/$user/mysql_creds
+ cat /home/$user/mysql_creds
+ fi
+ /usr/bin/memcached -d -u $user
+fi
+
+if [[ $environment == 'PROD' ]]; then
+ if [ -f /etc/php.d/50-memcached.ini ]; then
+ sed -r -i 's/;session.save_path="localhost:11211/session.save_path="memcache:11211/' /etc/php.d/50-memcached.ini
+ fi
+fi
+
+# Set up user crontab
+if [ ! -f /home/$user/crontab ]; then
+ echo "# User crontab for $user" > /home/$user/crontab
+ echo "# Add your cron jobs here" >> /home/$user/crontab
+ echo "# Example: */5 * * * * /home/$user/scripts/my-script.sh" >> /home/$user/crontab
+ chown $user:$user /home/$user/crontab
+fi
+
+# Load user crontab
+crontab -u $user /home/$user/crontab
+
+/usr/sbin/crond
+
+# Tail PHP-FPM logs (becomes PID 1 process)
+touch /home/$user/logs/php-fpm/error.log
+tail -f /home/$user/logs/php-fpm/*
+
+exit 0
diff --git a/scripts/entrypoint-shared-httpd.sh b/scripts/entrypoint-shared-httpd.sh
new file mode 100755
index 0000000..c63b377
--- /dev/null
+++ b/scripts/entrypoint-shared-httpd.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+
+export CONTAINER_ROLE="httpd_only"
+
+if [ -z "$environment" ]; then
+ environment="PROD"
+fi
+
+# Generate self-signed SSL cert if not already present
+if [ ! -f /etc/pki/tls/certs/localhost.crt ]; then
+ openssl req -newkey rsa:2048 -nodes \
+ -keyout /etc/pki/tls/private/localhost.key \
+ -x509 -days 3650 -subj "/CN=localhost" \
+ -out /etc/pki/tls/certs/localhost.crt
+fi
+
+# Create log directory
+mkdir -p /var/log/httpd
+
+# Remove default configs that conflict
+rm -f /etc/httpd/conf.d/userdir.conf
+
+# Configure RemoteIP for Docker network
+docker_network=$(ip addr show | grep eth0 | grep inet | awk -F " " '{print $2}')
+if [ -n "$docker_network" ]; then
+ echo "RemoteIPInternalProxy $docker_network" >> /etc/httpd/conf.d/remoteip.conf
+fi
+
+# Detect memory and calculate Apache MPM tuning
+source /scripts/detect-memory.sh
+echo "Container memory: ${CONTAINER_MEMORY_MB}MB | Apache workers=${APACHE_MAX_REQUEST_WORKERS} | Role=${CONTAINER_ROLE}"
+
+# Generate MPM tuning config
+/scripts/create-apache-mpm-config.sh
+
+# Write SSL global config (matches standalone CAC behavior)
+cat <<'EOF' > /etc/httpd/conf.d/ssl-global.conf
+Listen 443 https
+SSLPassPhraseDialog exec:/usr/libexec/httpd-ssl-pass-dialog
+SSLSessionCache shmcb:/run/httpd/sslcache(512000)
+SSLSessionCacheTimeout 300
+SSLCryptoDevice builtin
+EOF
+
+# Disable the default ssl.conf if present (we use per-vhost SSL)
+if [ -f /etc/httpd/conf.d/ssl.conf ]; then
+ mv /etc/httpd/conf.d/ssl.conf /etc/httpd/conf.d/ssl.conf.bak
+fi
+
+# Ensure vhosts directory exists and is included
+mkdir -p /etc/httpd/conf.d/vhosts
+if ! grep -q 'IncludeOptional conf.d/vhosts/' /etc/httpd/conf/httpd.conf; then
+ echo 'IncludeOptional conf.d/vhosts/*.conf' >> /etc/httpd/conf/httpd.conf
+fi
+
+# Start Apache
+/usr/sbin/httpd -k start
+
+# Start cron for log rotation
+/usr/sbin/crond
+
+# Tail Apache logs (becomes PID 1 process)
+touch /var/log/httpd/error_log
+tail -f /var/log/httpd/*
+
+exit 0
diff --git a/scripts/tune-mpm.sh b/scripts/tune-mpm.sh
new file mode 100755
index 0000000..796b035
--- /dev/null
+++ b/scripts/tune-mpm.sh
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Hot-adjust Apache MPM Event settings and graceful reload.
+# Usage: tune-mpm.sh [--max-workers N] [--server-limit N] [--start-servers N]
+# [--min-spare-threads N] [--max-spare-threads N]
+# [--max-connections-per-child N]
+
+set -euo pipefail
+
+# Read current values from the config as defaults
+CONFIG_FILE="/etc/httpd/conf.d/mpm-tuning.conf"
+
+if [ ! -f "$CONFIG_FILE" ]; then
+ echo "Error: $CONFIG_FILE not found. Run detect-memory.sh first."
+ exit 1
+fi
+
+# Parse current values from config
+current_start=$(grep -oP 'StartServers\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "1")
+current_min_spare=$(grep -oP 'MinSpareThreads\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "5")
+current_max_spare=$(grep -oP 'MaxSpareThreads\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "15")
+current_max_workers=$(grep -oP 'MaxRequestWorkers\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "50")
+current_server_limit=$(grep -oP 'ServerLimit\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "2")
+current_max_conn=$(grep -oP 'MaxConnectionsPerChild\s+\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "3000")
+
+# Parse arguments
+START_SERVERS=$current_start
+MIN_SPARE_THREADS=$current_min_spare
+MAX_SPARE_THREADS=$current_max_spare
+MAX_REQUEST_WORKERS=$current_max_workers
+SERVER_LIMIT=$current_server_limit
+MAX_CONNECTIONS_PER_CHILD=$current_max_conn
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --max-workers) MAX_REQUEST_WORKERS="$2"; shift 2 ;;
+ --server-limit) SERVER_LIMIT="$2"; shift 2 ;;
+ --start-servers) START_SERVERS="$2"; shift 2 ;;
+ --min-spare-threads) MIN_SPARE_THREADS="$2"; shift 2 ;;
+ --max-spare-threads) MAX_SPARE_THREADS="$2"; shift 2 ;;
+ --max-connections-per-child) MAX_CONNECTIONS_PER_CHILD="$2"; shift 2 ;;
+ --help|-h)
+ echo "Usage: $0 [--max-workers N] [--server-limit N] [--start-servers N]"
+ echo " [--min-spare-threads N] [--max-spare-threads N]"
+ echo " [--max-connections-per-child N]"
+ echo ""
+ echo "Current values:"
+ echo " StartServers: $current_start"
+ echo " MinSpareThreads: $current_min_spare"
+ echo " MaxSpareThreads: $current_max_spare"
+ echo " MaxRequestWorkers: $current_max_workers"
+ echo " ServerLimit: $current_server_limit"
+ echo " MaxConnectionsPerChild: $current_max_conn"
+ exit 0
+ ;;
+ *) echo "Unknown option: $1"; exit 1 ;;
+ esac
+done
+
+# Write updated config
+cat < "$CONFIG_FILE"
+
+ StartServers ${START_SERVERS}
+ MinSpareThreads ${MIN_SPARE_THREADS}
+ MaxSpareThreads ${MAX_SPARE_THREADS}
+ ThreadLimit 64
+ ThreadsPerChild 25
+ MaxRequestWorkers ${MAX_REQUEST_WORKERS}
+ ServerLimit ${SERVER_LIMIT}
+ MaxConnectionsPerChild ${MAX_CONNECTIONS_PER_CHILD}
+
+EOF
+
+echo "MPM config updated:"
+echo " StartServers=$START_SERVERS ServerLimit=$SERVER_LIMIT MaxRequestWorkers=$MAX_REQUEST_WORKERS"
+echo " MinSpareThreads=$MIN_SPARE_THREADS MaxSpareThreads=$MAX_SPARE_THREADS MaxConnectionsPerChild=$MAX_CONNECTIONS_PER_CHILD"
+
+# Graceful reload
+/usr/sbin/httpd -k graceful
+echo "Apache graceful reload triggered."