Compare commits

..

11 Commits

Author SHA1 Message Date
Elijah Duffy
0d00fd2ac4 php-fpm: preinstall wp-cli, add WP-independent healthcheck
All checks were successful
nginx-build / build (push) Successful in 1m2s
php-fpm-build / build (7.4) (push) Successful in 5m23s
2025-12-09 13:30:27 -08:00
Elijah Duffy
238d3244b2 php-fpm: warn to logs when debug is enabled
All checks were successful
php-fpm-build / build (7.4) (push) Successful in 4m52s
2025-12-08 20:53:54 -08:00
Elijah Duffy
072c284ba5 php-fpm: capture & logs exceptions
All checks were successful
php-fpm-build / build (7.4) (push) Successful in 4m53s
2025-12-08 20:08:07 -08:00
Elijah Duffy
f3c65de9da php-fpm: refactor with deterministic config files & improved debug
All checks were successful
php-fpm-build / build (7.4) (push) Successful in 5m6s
2025-12-08 19:26:15 -08:00
Elijah Duffy
77ceaf6bb0 php-fpm: revert breaking www.conf copy change
All checks were successful
php-fpm-build / build (7.4) (push) Successful in 4m56s
2025-12-08 18:50:52 -08:00
Elijah Duffy
811a8c8f51 php-fpm: temporarily force debug mode
Some checks failed
php-fpm-build / build (7.4) (push) Failing after 39s
2025-12-08 18:45:39 -08:00
Elijah Duffy
d98ec294e7 php-fpm: try to fix hidden error logs
All checks were successful
php-fpm-build / build (7.4) (push) Successful in 4m35s
2025-12-08 02:59:49 -08:00
Elijah Duffy
f5d29e3515 nginx: direct error & access logs to stderr and stdout respectively
All checks were successful
nginx-build / build (push) Successful in 34s
2025-12-08 02:51:08 -08:00
Elijah Duffy
e506f11873 php-fpm: don't drop to app user in docker, let fpm handle this
All checks were successful
php-fpm-build / build (7.4) (push) Successful in 4m33s
2025-12-08 02:47:36 -08:00
Elijah Duffy
d004e8a145 nginx: fix conf file errors
All checks were successful
nginx-build / build (push) Successful in 33s
2025-12-08 02:41:14 -08:00
Elijah Duffy
8e386802ba improve production docker-compose.yml in README 2025-12-08 02:31:19 -08:00
12 changed files with 308 additions and 63 deletions

View File

@@ -31,12 +31,35 @@ The CI workflow is configured to build with the repository root as the Docker bu
- Images use `/var/www/html` as the webroot. When you mount a host directory over that path the mount replaces the image contents, including ownership. - Images use `/var/www/html` as the webroot. When you mount a host directory over that path the mount replaces the image contents, including ownership.
- Recommended safe options: - Recommended safe options:
- Pre-chown host files to UID/GID 1000 before starting containers: - Pre-chown host files to UID/GID 1000 before starting containers:
```bash ```bash
sudo chown -R 1000:1000 ./wp_root sudo chown -R 1000:1000 ./wp_root
``` ```
- Or enable the entrypoint-based fixup in php-fpm by setting `CHOWN_ON_START=1` for the `php-fpm` service (the entrypoint is guarded — it only runs when this env is explicitly enabled). - Or enable the entrypoint-based fixup in php-fpm by setting `CHOWN_ON_START=1` for the `php-fpm` service (the entrypoint is guarded — it only runs when this env is explicitly enabled).
## Logging & debugging
- PHP-FPM streams master/worker logs plus PHP fatals to stderr, so `docker compose logs php-fpm` (or your platform equivalent) will always contain the messages you need for incident response.
- The auto-prepend bootstrap additionally installs shutdown/exception hooks that write uncaught throwables and fatal errors to stderr even if WordPress or a plugin tampers with `ini_set()`.
- When you need full stack traces in the browser, set `FORCE_DEBUG_ERRORS=1` on the `php-fpm` service. The bootstrap enables verbose output and emits a warning in the container logs reminding you to turn it back off (leaving it on in production leaks stack traces to clients).
- Remove or unset `FORCE_DEBUG_ERRORS` after troubleshooting so production responses stay clean.
## Health checks
- The nginx config exposes `/healthz`, which bypasses WordPress entirely and executes `/usr/local/share/auvem/health/healthcheck.php` inside PHP-FPM. The script verifies the `/var/www/html` mount and optionally performs a MySQL connection test when `WORDPRESS_DB_*` variables are present.
- HTTP 200 means the platform (nginx ↔ php-fpm ↔ database) is healthy. Any filesystem or database failures return HTTP 503 with a short JSON payload detailing which probe failed. This lets you point uptime monitors at `/healthz` while still separately watching the public homepage for regressions caused by client code.
- Example local probe: `curl -fsS http://localhost:8080/healthz | jq`.
## WP-CLI baked in
- The PHP-FPM image now ships with the official `wp` binary, so you can run administrative remediation tasks without installing extra tooling on target hosts.
- Typical usage: `docker compose exec php-fpm wp --allow-root user update admin --user_pass='NewPassword123!' --skip-email --path=/var/www/html`.
- The binary runs as root inside the container, so remember the `--allow-root` flag (or create an app-level user if you prefer tighter isolation).
- Automation hooks (e.g., your client portal) can wrap `wp` commands for password resets, cron runs, plugin inspections, etc., provided they bind-mount the site into `/var/www/html`.
## Local Testing & Development ## Local Testing & Development
Use the provided `docker-compose.yml` in the repo root for local development — it builds images from the repo (so shared files are available) and mounts `./wp_root` for site content. Use the provided `docker-compose.yml` in the repo root for local development — it builds images from the repo (so shared files are available) and mounts `./wp_root` for site content.
@@ -47,7 +70,7 @@ Below is an example `docker-compose.yml` for production deployments that pulls i
```yaml ```yaml
services: services:
db: mariadb:
image: mariadb:latest image: mariadb:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -61,11 +84,14 @@ services:
php-fpm: php-fpm:
image: gitea.auvem.com/auvem/wordpress-docker/php-fpm:7.4-stable image: gitea.auvem.com/auvem/wordpress-docker/php-fpm:7.4-stable
restart: unless-stopped restart: unless-stopped
depends_on:
- mariadb
volumes: volumes:
- ./wp_root:/var/www/html:rw - ./wp_root:/var/www/html:rw
nginx: nginx:
image: gitea.auvem.com/auvem/wordpress-docker/nginx:latest image: gitea.auvem.com/auvem/wordpress-docker/nginx:latest
restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
depends_on: depends_on:

View File

@@ -13,7 +13,7 @@ services:
php-fpm: php-fpm:
build: build:
context: . context: .
dockerfile: docker/7.4/Dockerfile dockerfile: php-fpm/7.4/Dockerfile
image: local/auvem-php:7.4 image: local/auvem-php:7.4
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -1,69 +1,81 @@
error_log /dev/stderr warn;
access_log /dev/stdout;
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
# Set the server name to a catch all since this will be behind
# a reverse proxy or load balancer in most cases.
server_name _; server_name _;
root /var/www/html;
index index.php index.html index.htm;
# Basic security headers (can be extended per-site) root /var/www/html;
index index.php;
# Security Headers
# These provide a strong baseline against common web vulnerabilities.
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Deny access to hidden files and directories # Deny access to any files starting with a dot.
location ~ (^|/)[.] { location ~ /\. {
deny all; deny all;
access_log off;
log_not_found off;
} }
# Static files: long cache, immutable where appropriate # Deny access to specific sensitive file types.
location ~* \.(?:bak|conf|dist|fla|in[ci]|log|psd|sh|sql|sw[op])$ {
deny all;
}
# Cache static assets for a long time.
# The 'immutable' directive is great for versioned assets.
location ~* \.(?:css|js|gif|jpe?g|png|ico|svg|woff2?|ttf|eot)$ { location ~* \.(?:css|js|gif|jpe?g|png|ico|svg|woff2?|ttf|eot)$ {
try_files $uri =404; try_files $uri =404;
access_log off; access_log off;
expires 30d; expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable"; add_header Cache-Control "public, immutable";
} }
# Handle favicon and robots.txt without logging.
location = /favicon.ico { access_log off; log_not_found off; } location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; allow all; } location = /robots.txt { access_log off; log_not_found off; }
# Main front controller; fall back to index.php # Fleet readiness probe that bypasses WordPress and executes a bundled PHP script.
location / { location = /healthz {
try_files $uri $uri/ /index.php$is_args$args; access_log off;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/local/share/auvem/health/healthcheck.php;
fastcgi_param SCRIPT_NAME /healthz;
fastcgi_param PATH_INFO "";
fastcgi_pass php-fpm:9000;
} }
# PHP-FPM handling; pass to php-fpm:9000 (docker service name) # Main WordPress front-controller.
location ~ [^/] # All non-file requests fall through to index.php.
\.php(/|$) { location / {
# Prevent direct access to PHP files in uploads or other writable dirs if necessary try_files $uri $uri/ /index.php?$args;
try_files $document_root$fastcgi_script_name =404; }
# Pass PHP scripts to the PHP-FPM container.
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_split_path_info ^(.+\.php)(/.+)$;
# Use TCP FPM backend service name. Matches the php-fpm image we built.
# Use the service name of your PHP-FPM container.
fastcgi_pass php-fpm:9000; fastcgi_pass php-fpm:9000;
fastcgi_index index.php; fastcgi_index index.php;
include fastcgi_params; include fastcgi_params;
# Set required CGI parameters.
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS $https if_not_empty; fastcgi_param HTTPS $https if_not_empty;
fastcgi_read_timeout 300;
}
# Block access to .ht* files # Optimize buffers for potentially large WordPress headers.
location ~* /\.(?:ht|git) {
deny all;
}
# Optional: small buffer for large headers (WordPress with many cookies/plugins)
fastcgi_buffers 16 16k; fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k; fastcgi_buffer_size 32k;
fastcgi_read_timeout 300;
# Prevent clickjacking on all responses }
add_header X-Frame-Options "SAMEORIGIN";
} }
# Default server; allow override by mounting /etc/nginx/conf.d/default.conf

View File

@@ -27,6 +27,7 @@ RUN set -eux; \
# Install runtime dependencies # Install runtime dependencies
apk add --no-cache \ apk add --no-cache \
bash \ bash \
curl \
freetype \ freetype \
libjpeg-turbo \ libjpeg-turbo \
libpng \ libpng \
@@ -67,6 +68,10 @@ RUN set -eux; \
# Use production php.ini # Use production php.ini
cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"; \ cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"; \
\ \
# Install WP-CLI for remedial administration
curl -fSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp; \
chmod +x /usr/local/bin/wp; \
\
# Clean up build dependencies # Clean up build dependencies
apk del .build-deps; \ apk del .build-deps; \
rm -rf /var/cache/apk/* /tmp/* rm -rf /var/cache/apk/* /tmp/*
@@ -79,33 +84,26 @@ RUN addgroup -g 1000 app && \
mkdir -p /var/run/php /run/php /var/log/php && \ mkdir -p /var/run/php /run/php /var/log/php && \
chown -R app:app /var/run/php /run/php /var/log/php chown -R app:app /var/run/php /run/php /var/log/php
# Minimal security / production tuning for opcache and PHP # Ship opinionated PHP configuration snippets from source control
RUN set -eux; \ COPY php-fpm/conf.d/ /usr/local/etc/php/conf.d/
{ \
echo 'opcache.enable=1'; \ # Copy the force-debug script (enablement is handled via conf.d/99-force-debug.ini)
echo 'opcache.memory_consumption=128'; \ COPY --chown=app:app shared/php-fpm/force-debug.php /usr/local/etc/php/force-debug.php
echo 'opcache.interned_strings_buffer=8'; \
echo 'opcache.max_accelerated_files=10000'; \ # Copy shared healthcheck assets
echo 'opcache.revalidate_freq=2'; \ RUN mkdir -p /usr/local/share/auvem/health
echo 'opcache.fast_shutdown=1'; \ COPY shared/php-fpm/healthcheck.php /usr/local/share/auvem/health/healthcheck.php
echo 'opcache.enable_file_override=0'; \
} > /usr/local/etc/php/conf.d/zz-opcache.ini; \
{ \
echo 'expose_php = Off'; \
echo 'display_errors = Off'; \
echo 'log_errors = On'; \
echo 'error_log = /proc/self/fd/2'; \
} > /usr/local/etc/php/conf.d/zz-hardening.ini
# Copy pool configuration from this directory # Copy pool configuration from this directory
COPY --chown=app:app php-fpm/${BASE_VERSION}/www.conf /usr/local/etc/php-fpm.d/www.conf COPY --chown=app:app php-fpm/${BASE_VERSION}/www.conf /usr/local/etc/php-fpm.d/www.conf
# Copy the global php-fpm configuration so logging defaults are predictable
COPY php-fpm/${BASE_VERSION}/php-fpm.conf /usr/local/etc/php-fpm.conf
# Copy entrypoint from shared path in repo root # Copy entrypoint from shared path in repo root
COPY --chown=root:root shared/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh COPY --chown=root:root shared/php-fpm/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 755 /usr/local/bin/entrypoint.sh RUN chmod 755 /usr/local/bin/entrypoint.sh
WORKDIR /var/www/html WORKDIR /var/www/html
USER app
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["php-fpm"] CMD ["php-fpm"]

8
php-fpm/7.4/php-fpm.conf Normal file
View File

@@ -0,0 +1,8 @@
[global]
pid = /var/run/php/php-fpm.pid
error_log = /proc/self/fd/2
log_limit = 8192
log_buffering = no
daemonize = no
include = /usr/local/etc/php-fpm.d/*.conf

View File

@@ -41,10 +41,17 @@ request_terminate_timeout = 300s
request_slowlog_timeout = 5s request_slowlog_timeout = 5s
slowlog = /var/log/php/www-slow.log slowlog = /var/log/php/www-slow.log
; Force all PHP errors into the container log stream so kubernetes/docker can collect them.
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /proc/self/fd/2
; Redirect worker stdout and stderr to the main error log. ; Redirect worker stdout and stderr to the main error log.
; This ensures that any `echo` or `var_dump` calls from workers are captured in the container logs. ; This ensures that any `echo` or `var_dump` calls from workers are captured in the container logs.
catch_workers_output = yes catch_workers_output = yes
; Allow worker processes to write to stderr, which is essential for container logging.
decorate_workers_output = no
; We are logging errors to stderr in zz-hardening.ini, so we can disable the FPM access log ; We are logging errors to stderr in zz-hardening.ini, so we can disable the FPM access log
; to avoid redundant logging and improve performance. Nginx should handle access logging. ; to avoid redundant logging and improve performance. Nginx should handle access logging.
; access.log = /var/log/php/www-access.log ; access.log = /var/log/php/www-access.log

View File

@@ -0,0 +1,7 @@
opcache.enable=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=10000
opcache.revalidate_freq=2
opcache.fast_shutdown=1
opcache.enable_file_override=0

View File

@@ -0,0 +1,5 @@
expose_php=Off
log_errors=On
error_log=/proc/self/fd/2
display_errors=Off
display_startup_errors=Off

View File

@@ -0,0 +1 @@
auto_prepend_file=/usr/local/etc/php/force-debug.php

View File

@@ -1,12 +1,19 @@
#!/bin/sh #!/bin/sh
set -euo pipefail set -eu
: ${CHOWN_ON_START:=} PHP_ENTRYPOINT="/usr/local/bin/docker-php-entrypoint"
if [ "${CHOWN_ON_START}" = "1" ] || [ "${CHOWN_ON_START}" = "true" ]; then if [ ! -x "${PHP_ENTRYPOINT}" ]; then
echo "[entrypoint] Missing ${PHP_ENTRYPOINT}" >&2
exit 1
fi
case "${CHOWN_ON_START:-}" in
1|true|TRUE|yes|on)
echo "[entrypoint] CHOWN_ON_START enabled — fixing ownership of /var/www/html" echo "[entrypoint] CHOWN_ON_START enabled — fixing ownership of /var/www/html"
if [ -d /var/www/html ]; then if [ -d /var/www/html ]; then
chown -R 1000:1000 /var/www/html || true chown -R 1000:1000 /var/www/html || true
fi fi
fi ;;
esac
exec "$@" exec "${PHP_ENTRYPOINT}" "$@"

View File

@@ -0,0 +1,80 @@
<?php
// force-debug.php
// Automatically prepended; guarantees fatals reach container logs and optionally surfaces verbose output.
$forceDebugRaw = getenv('FORCE_DEBUG_ERRORS');
$forceDebugEnabled = false;
if ($forceDebugRaw !== false) {
$normalized = strtolower(trim($forceDebugRaw));
$forceDebugEnabled = in_array($normalized, ['1', 'true', 'yes', 'on'], true);
}
// Ensure PHP always logs to stderr even if application code tampers with settings.
ini_set('log_errors', '1');
ini_set('error_log', '/proc/self/fd/2');
// Helper to safely write to container stderr without relying on php.ini.
$forceDebugLog = static function (string $message): void {
static $stderr = null;
if ($stderr === null) {
$stderr = @fopen('php://stderr', 'ab');
}
if ($stderr) {
@fwrite($stderr, $message . PHP_EOL);
} else {
// Fallback to PHP's error_log if stderr is unavailable for any reason.
error_log($message);
}
};
// Capture uncaught exceptions so they can never disappear silently.
set_exception_handler(static function (Throwable $throwable) use ($forceDebugLog, $forceDebugEnabled): void {
$message = sprintf(
'[force-debug] Uncaught %s: %s in %s:%d',
get_class($throwable),
$throwable->getMessage(),
$throwable->getFile(),
$throwable->getLine()
);
$forceDebugLog($message);
$forceDebugLog($throwable->getTraceAsString());
if ($forceDebugEnabled) {
http_response_code(500);
echo '<pre>' . htmlspecialchars($message . "\n" . $throwable->getTraceAsString(), ENT_QUOTES, 'UTF-8') . '</pre>';
}
});
// Capture fatal shutdown errors (E_ERROR, E_PARSE, etc.) and surface them to stderr.
register_shutdown_function(static function () use ($forceDebugLog, $forceDebugEnabled): void {
$error = error_get_last();
if ($error === null) {
return;
}
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
if (!in_array($error['type'], $fatalTypes, true)) {
return;
}
$message = sprintf(
'[force-debug] Fatal error (%s): %s in %s:%d',
$error['type'],
$error['message'],
$error['file'],
$error['line']
);
$forceDebugLog($message);
if ($forceDebugEnabled) {
http_response_code(500);
echo '<pre>' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</pre>';
}
});
if ($forceDebugEnabled) {
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
$forceDebugLog('[force-debug] FORCE_DEBUG_ERRORS is enabled — stack traces will be sent to clients. Disable this in production.');
}

View File

@@ -0,0 +1,94 @@
<?php
// healthcheck.php
// Lightweight readiness probe independent of WordPress code.
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate');
$response = [
'status' => 'ok',
'php_version' => PHP_VERSION,
'checks' => [],
];
$httpStatus = 200;
// Filesystem/webroot probe
$webroot = '/var/www/html';
$fsStatus = [
'name' => 'filesystem',
'status' => 'ok',
'detail' => 'Webroot mounted',
];
if (!is_dir($webroot)) {
$fsStatus['status'] = 'error';
$fsStatus['detail'] = 'Missing /var/www/html mount';
$httpStatus = 503;
} elseif (!is_readable($webroot)) {
$fsStatus['status'] = 'error';
$fsStatus['detail'] = 'Webroot not readable';
$httpStatus = 503;
}
$response['checks'][] = $fsStatus;
// Database probe (optional if env vars missing)
$requiredEnv = ['WORDPRESS_DB_HOST', 'WORDPRESS_DB_USER', 'WORDPRESS_DB_PASSWORD', 'WORDPRESS_DB_NAME'];
$dbConfig = [];
$missingEnv = [];
foreach ($requiredEnv as $var) {
$value = getenv($var);
if ($value === false || $value === '') {
$missingEnv[] = $var;
} else {
$dbConfig[$var] = $value;
}
}
$dbStatus = [
'name' => 'database',
'status' => 'skipped',
'detail' => 'Database env vars missing',
];
if (empty($missingEnv)) {
[$host, $port] = (function (string $rawHost): array {
if (preg_match('/^\[(.+)\](?::(\d+))?$/', $rawHost, $matches)) {
return [$matches[1], isset($matches[2]) ? (int) $matches[2] : 3306];
}
if (substr_count($rawHost, ':') === 1 && strpos($rawHost, '::') === false) {
[$h, $p] = explode(':', $rawHost, 2);
return [$h, (int) $p];
}
return [$rawHost, 3306];
})($dbConfig['WORDPRESS_DB_HOST']);
$mysqli = @mysqli_init();
if ($mysqli !== false) {
@mysqli_options($mysqli, MYSQLI_OPT_CONNECT_TIMEOUT, 3);
$connected = @$mysqli->real_connect(
$host,
$dbConfig['WORDPRESS_DB_USER'],
$dbConfig['WORDPRESS_DB_PASSWORD'],
$dbConfig['WORDPRESS_DB_NAME'],
$port
);
if ($connected) {
$dbStatus['status'] = 'ok';
$dbStatus['detail'] = 'DB connection OK';
$mysqli->close();
} else {
$dbStatus['status'] = 'error';
$dbStatus['detail'] = sprintf('DB connect failed: %s', $mysqli->connect_error ?? 'unknown error');
$httpStatus = 503;
}
} else {
$dbStatus['status'] = 'error';
$dbStatus['detail'] = 'Unable to init mysqli';
$httpStatus = 503;
}
} else {
$dbStatus['detail'] = 'Missing env: ' . implode(', ', $missingEnv);
}
$response['checks'][] = $dbStatus;
http_response_code($httpStatus);
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";