Private
Public Access
1
0

Initial commit: complete WebsiteBox project

Docker-based self-hosted WordPress deployment system with:
- Four-container stack (nginx, wordpress/php-fpm, mariadb, certbot)
- Automatic SSL via Let's Encrypt with self-signed fallback
- First-boot WordPress setup via WP-CLI (GeneratePress + child theme, plugins)
- Interactive setup wizard and one-line install script
- Backup, update, healthcheck, and SSL renewal scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
constantprojects
2026-02-20 15:24:23 -07:00
commit a440026701
32 changed files with 3397 additions and 0 deletions

20
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM nginx:alpine
RUN apk add --no-cache \
curl \
certbot \
openssl \
inotify-tools \
bash
COPY nginx.conf /etc/nginx/nginx.conf
COPY wordpress.conf /etc/nginx/conf.d/default.conf
COPY wordpress-ssl.conf /etc/nginx/conf.d/wordpress-ssl.conf.disabled
COPY ssl-params.conf /etc/nginx/snippets/ssl-params.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && \
mkdir -p /etc/nginx/snippets /var/www/certbot /var/run/certbot-signal
ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

95
nginx/entrypoint.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
set -eo pipefail
DOMAIN="${DOMAIN:-localhost}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
CERT_DIR="/etc/letsencrypt/live/${DOMAIN}"
SIGNAL_DIR="/var/run/certbot-signal"
# Create setup-pending placeholder page
cat > /usr/share/nginx/html/setup-pending.html <<'HTMLEOF'
<!DOCTYPE html>
<html>
<head><title>WebsiteBox - Setting Up</title>
<style>body{font-family:sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f5f5f5}
.box{text-align:center;padding:2em;background:white;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:500px}
h1{color:#333}p{color:#666;line-height:1.6}</style></head>
<body><div class="box">
<h1>Setting up SSL...</h1>
<p>Your WebsiteBox site is starting up and acquiring an SSL certificate.</p>
<p>If this page persists, your DNS may not be pointing to this server yet.
Once DNS is configured, restart nginx:</p>
<pre>docker compose restart nginx</pre>
</div></body></html>
HTMLEOF
echo "WebsiteBox nginx: checking SSL certificates for ${DOMAIN}..."
if [ -f "${CERT_DIR}/fullchain.pem" ] && [ -f "${CERT_DIR}/privkey.pem" ]; then
echo "SSL certificates found. Starting with SSL."
# Activate SSL config
sed "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" /etc/nginx/conf.d/wordpress-ssl.conf.disabled > /etc/nginx/conf.d/wordpress-ssl.conf
# Replace HTTP-only default with redirect
rm -f /etc/nginx/conf.d/default.conf
else
echo "No SSL certificates found. Starting HTTP-only for ACME challenge..."
# Generate temporary self-signed cert so nginx can start if needed
mkdir -p "${CERT_DIR}"
openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
-keyout "${CERT_DIR}/privkey.pem" \
-out "${CERT_DIR}/fullchain.pem" \
-subj "/CN=${DOMAIN}" 2>/dev/null
# Start nginx in background with HTTP-only config
nginx -g "daemon on;"
echo "Requesting Let's Encrypt certificate for ${DOMAIN}..."
# Attempt certificate acquisition
if certbot certonly --webroot \
-w /var/www/certbot \
-d "${DOMAIN}" \
--agree-tos \
--email "${ADMIN_EMAIL}" \
--non-interactive \
--no-eff-email; then
echo "SSL certificate acquired successfully!"
# Activate SSL config
sed "s/DOMAIN_PLACEHOLDER/${DOMAIN}/g" /etc/nginx/conf.d/wordpress-ssl.conf.disabled > /etc/nginx/conf.d/wordpress-ssl.conf
rm -f /etc/nginx/conf.d/default.conf
# Stop background nginx — will be restarted by CMD
nginx -s stop
sleep 1
else
echo "WARNING: SSL certificate acquisition failed."
echo "This usually means DNS is not pointing to this server yet."
echo "Once DNS is configured, run: docker compose restart nginx"
# Stop background nginx — will be restarted by CMD with HTTP-only
nginx -s stop
sleep 1
# Remove the self-signed certs so we retry on next start
rm -rf "${CERT_DIR}"
fi
fi
# Start background loop to watch for certbot renewal signal
(
while true; do
if [ -f "${SIGNAL_DIR}/reload" ]; then
echo "Certbot renewal detected. Reloading nginx..."
rm -f "${SIGNAL_DIR}/reload"
nginx -s reload 2>/dev/null || true
fi
sleep 60
done
) &
# Execute the CMD (nginx -g daemon off)
exec "$@"

40
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,40 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Client body size (match PHP upload limits)
client_max_body_size 64M;
# Rate limiting zone for wp-login.php
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=1r/s;
include /etc/nginx/conf.d/*.conf;
}

15
nginx/ssl-params.conf Normal file
View File

@@ -0,0 +1,15 @@
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

107
nginx/wordpress-ssl.conf Normal file
View File

@@ -0,0 +1,107 @@
# HTTPS server block — activated after SSL certificates are acquired
server {
listen 443 ssl;
http2 on;
server_name DOMAIN_PLACEHOLDER;
ssl_certificate /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/DOMAIN_PLACEHOLDER/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
root /var/www/html;
index index.php index.html;
# Nginx healthcheck endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# ACME challenge (for renewals)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Rate limit wp-login.php
location = /wp-login.php {
limit_req zone=wplogin burst=3 nodelay;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param HTTPS on;
}
# Block xmlrpc.php at nginx level
location = /xmlrpc.php {
deny all;
return 403;
}
# Block access to sensitive files
location ~ /\. {
deny all;
}
location ~* /(?:uploads|files)/.*\.php$ {
deny all;
}
# WordPress permalinks
location / {
try_files $uri $uri/ /index.php?$args;
}
# PHP handling via FastCGI to WordPress container
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass wordpress:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param HTTPS on;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_read_timeout 300;
}
# Static file caching
# NOTE: add_header in a location block overrides ALL parent add_header directives,
# so security headers must be repeated here.
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
access_log off;
}
}
# HTTP to HTTPS redirect (replaces the HTTP-only block)
server {
listen 80;
server_name DOMAIN_PLACEHOLDER;
# ACME challenge must remain accessible over HTTP
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Nginx healthcheck endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
location / {
return 301 https://$host$request_uri;
}
}

23
nginx/wordpress.conf Normal file
View File

@@ -0,0 +1,23 @@
# HTTP server block — used during SSL bootstrap and for ACME challenges
server {
listen 80;
server_name _;
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Nginx healthcheck endpoint
location /nginx-health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Before SSL is acquired, serve a placeholder
location / {
root /usr/share/nginx/html;
try_files /setup-pending.html =404;
}
}