diff --git a/.env.example b/.env.example index ecccb17..3bff736 100644 --- a/.env.example +++ b/.env.example @@ -1,54 +1,100 @@ -# ============================= -# === App Container Prefix === -# ============================= +# -------------------------------------------------- +# Core application settings +# -------------------------------------------------- + +# Docker container name prefix CONTAINER_NAME=app -# ============================= -# === Database Configuration === -# ============================= +# -------------------------------------------------- +# Database (MySQL) +# -------------------------------------------------- + DATABASE_NAME=wordpress DATABASE_USER=wp_user DATABASE_PASSWORD=wp_password DATABASE_ROOT_PASSWORD=root_password -# ============================= -# === Nginx Configuration === -# ============================= -SERVER_NAME=localhost -HTTP_PORT=8000 +# -------------------------------------------------- +# Web / Nginx +# -------------------------------------------------- -# ============================= -# === phpMyAdmin Configuration === -# ============================= -PHPMYADMIN_PORT=8001 +# Public server name (domain or hostname) +SERVER_NAME=localhost -# ============================= -# === WordPress Initialization === -# ============================= +# -------------------------------------------------- +# WordPress initialization (wp-init) +# -------------------------------------------------- -# Skip WordPress initialization service (true/false) +# Skip WordPress initialization tasks (true/false) SKIP_WP_INIT=false -# Full site URL +# Final site URL used by WordPress +# In production this should be: https:// SITE_URL=http://${SERVER_NAME}:${HTTP_PORT} -# Columns to skip during WP-CLI search-replace (comma-separated, no spaces) -# Example: guid,post_content +# Columns to skip during WP-CLI search-replace +# Comma-separated, no spaces (e.g. guid,post_content) SKIP_COLUMNS=guid -# ============================= -# === Database Backup Service === -# ============================= +# -------------------------------------------------- +# Database backup service (db-backup) +# -------------------------------------------------- -# Skip database backup service (true/false) +# Skip automated database backups (true/false) SKIP_DB_BACKUP=false # Maximum number of backup files to keep DATABASE_BACKUP_MAX_FILES=3 -# Initial delay before first backup (supports s/m/h/d) -# Examples: 60s, 5m, 2h, 1d +# Delay before first backup (s/m/h/d) DATABASE_BACKUP_INITIAL_DELAY=60s -# Interval between backups (supports s/m/h/d) +# Interval between backups (s/m/h/d) DATABASE_BACKUP_INTERVAL=3600s + +# -------------------------------------------------- +# Development only (docker-compose.dev.yml) +# -------------------------------------------------- + +# HTTP port exposed by Nginx +HTTP_PORT=8000 + +# phpMyAdmin exposed port +PHPMYADMIN_PORT=8001 + +# -------------------------------------------------- +# Production only (docker-compose.prod.yml) +# -------------------------------------------------- + +# Maximum size of a single container log file +LOG_SIZE=10m + +# Number of rotated log files to keep +LOG_FILES=3 + +# Database +DB_CPUS=1.0 +DB_MEM_LIMIT=1024M + +# WordPress +WP_CPUS=1.0 +WP_MEM_LIMIT=512M + +# Nginx +NGINX_CPUS=0.5 +NGINX_MEM_LIMIT=128M + +# wp-init (one-shot container) +WP_INIT_CPUS=0.5 +WP_INIT_MEM_LIMIT=128M + +# db-backup +DB_BACKUP_CPUS=0.5 +DB_BACKUP_MEM_LIMIT=256M + +# Certbot +# Email used for Let's Encrypt registration +LETSENCRYPT_EMAIL=admin@example.com + +# Certbot renewal interval (s/m/h/d) +CERTBOT_RENEW_INTERVAL=12h diff --git a/Makefile b/Makefile index cdc83dd..58e06ca 100644 --- a/Makefile +++ b/Makefile @@ -1,35 +1,63 @@ -# Variables for compose files -COMPOSE_DEV := docker-compose -f docker-compose.yml -f docker-compose.dev.yml -# COMPOSE_PROD := docker-compose -f docker-compose.yml -f docker-compose.prod.yml +COMPOSE_DEV := docker compose -f docker-compose.yml -f docker-compose.dev.yml +COMPOSE_PROD := docker compose -f docker-compose.yml -f docker-compose.prod.yml -# Default compose (dev by default) -COMPOSE := $(COMPOSE_DEV) +# -------------------------------------------------- +# Development targets +# -------------------------------------------------- -# Primary targets -up: - @$(COMPOSE) up -d --build +up: + @$(COMPOSE_DEV) up -d --build -down: - @$(COMPOSE) down +down: + @$(COMPOSE_DEV) down -clean: - @$(COMPOSE) down -v - -restart: down up +clean: + @$(COMPOSE_DEV) down -v reset: clean up -logs: - @$(COMPOSE) logs -f +logs: + @$(COMPOSE_DEV) logs -f + +sync-site-url: + @$(COMPOSE_DEV) exec -T wp-cli sh /scripts/wp-init/site-url/sync-site-url.sh + +db-backup: + @$(COMPOSE_DEV) exec -T db-cli sh /scripts/db-backup/run-db-backup-once.sh + +db-restore: + @$(COMPOSE_DEV) exec -T db-cli sh -c "/scripts/db-cli/run-db-restore.sh '$(SQLFILE)'" + +# -------------------------------------------------- +# Production targets +# -------------------------------------------------- + +up-prod: + @$(COMPOSE_PROD) up -d + +down-prod: + @$(COMPOSE_PROD) down + +logs-prod: + @$(COMPOSE_PROD) logs -f -# Dev-specific targets (requires containers to be running) -sync-site-url: - @$(COMPOSE) exec -T wp-cli sh /scripts/wp-init/site-url/sync-site-url.sh +certbot-first-issue: + @$(COMPOSE_PROD) run --rm \ + --entrypoint sh \ + certbot \ + /scripts/certbot/certbot-first-issue/certbot-first-issue.sh -db-backup: - @$(COMPOSE) exec -T db-cli sh /scripts/db-backup/run-db-backup-once.sh +certbot-dry-run: + @$(COMPOSE_PROD) run --rm \ + --entrypoint sh \ + certbot \ + /scripts/certbot/certbot-dry-run/certbot-dry-run.sh -db-restore: - @$(COMPOSE) exec -T db-cli sh -c "/scripts/db-cli/run-db-restore.sh '$(SQLFILE)'" +certbot-renew: + @$(COMPOSE_PROD) run --rm \ + --entrypoint sh \ + certbot \ + /scripts/certbot/certbot-renew/certbot-renew.sh -.PHONY: up down clean restart reset logs sync-site-url db-backup db-restore \ No newline at end of file +.PHONY: up down clean reset logs sync-site-url db-backup db-restore \ + up-prod down-prod logs-prod certbot-first-issue certbot-dry-run certbot-renew diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index df6d4bc..ab0de4f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,15 +5,19 @@ services: wordpress: volumes: - - ./src:/var/www/html/wp-content:rw + - ./php/zz-dev.ini:/usr/local/etc/php/conf.d/zz-dev.ini:ro + - ./src:/var/www/html/wp-content nginx: + ports: + - "${HTTP_PORT:-8000}:80" volumes: - - ./src:/var/www/html/wp-content:rw + - ./nginx/dev.conf.template:/etc/nginx/templates/default.conf.template:ro + - ./src:/var/www/html/wp-content wp-init: volumes: - - ./src:/var/www/html/wp-content:rw + - ./src:/var/www/html/wp-content - ./scripts:/scripts:ro entrypoint: ["/scripts/wp-init/entrypoint.sh"] @@ -37,9 +41,9 @@ services: WORDPRESS_DB_PASSWORD: ${DATABASE_PASSWORD} WORDPRESS_PATH: /var/www/html volumes: - - wordpress:/var/www/html - - ./src:/var/www/html/wp-content:rw + - ./src:/var/www/html/wp-content - ./scripts:/scripts:ro + - wordpress:/var/www/html networks: - internal entrypoint: ["tail", "-f", "/dev/null"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fb7b5d5 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,107 @@ +x-logging: &default-logging + logging: + driver: json-file + options: + max-size: "${LOG_SIZE:-10m}" + max-file: "${LOG_FILES:-3}" + +services: + database: + <<: *default-logging + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + deploy: + resources: + limits: + cpus: '${DB_CPUS:-1.0}' + memory: ${DB_MEM_LIMIT:-1024M} + + wordpress: + <<: *default-logging + init: true + security_opt: + - no-new-privileges:true + environment: + WORDPRESS_CONFIG_EXTRA: | + define('WP_ENVIRONMENT_TYPE', 'production'); + define('DISALLOW_FILE_EDIT', true); + define('DISALLOW_FILE_MODS', true); + volumes: + - ./php/zz-prod.ini:/usr/local/etc/php/conf.d/zz-prod.ini:ro + tmpfs: + - /tmp + deploy: + resources: + limits: + cpus: '${WP_CPUS:-1.0}' + memory: ${WP_MEM_LIMIT:-512M} + + nginx: + <<: *default-logging + init: true + security_opt: + - no-new-privileges:true + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/prod.conf.template:/etc/nginx/templates/default.conf.template:ro + - certbot_conf:/etc/letsencrypt + - certbot_www:/var/www/certbot + deploy: + resources: + limits: + cpus: '${NGINX_CPUS:-0.5}' + memory: ${NGINX_MEM_LIMIT:-128M} + + wp-init: + <<: *default-logging + restart: "no" + volumes: + - ./scripts:/scripts:ro + entrypoint: ["/scripts/wp-init/entrypoint.sh"] + deploy: + resources: + limits: + cpus: '${WP_INIT_CPUS:-0.5}' + memory: ${WP_INIT_MEM_LIMIT:-128M} + + db-backup: + <<: *default-logging + restart: unless-stopped + volumes: + - ./scripts:/scripts:ro + - db_backups:/backups + entrypoint: ["/scripts/db-backup/entrypoint.sh"] + deploy: + resources: + limits: + cpus: '${DB_BACKUP_CPUS:-0.5}' + memory: ${DB_BACKUP_MEM_LIMIT:-256M} + + certbot: + <<: *default-logging + image: certbot/certbot:v5.2.2 + container_name: ${CONTAINER_NAME}-certbot + env_file: .env + environment: + SERVER_NAME: ${SERVER_NAME} + LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} + CERTBOT_RENEW_INTERVAL: ${CERTBOT_RENEW_INTERVAL:-12h} + tmpfs: + - /var/lib/letsencrypt + volumes: + - ./scripts:/scripts:ro + - certbot_conf:/etc/letsencrypt + - certbot_www:/var/www/certbot + deploy: + resources: + limits: + cpus: '${CERTBOT_CPUS:-0.5}' + memory: ${CERTBOT_MEM_LIMIT:-128M} + +volumes: + db_backups: + certbot_conf: + certbot_www: diff --git a/docker-compose.yml b/docker-compose.yml index 387337a..208b721 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: timeout: 30s retries: 10 volumes: - - dbdata:/var/lib/mysql + - db_data:/var/lib/mysql networks: - internal @@ -46,10 +46,7 @@ services: env_file: .env environment: - SERVER_NAME=${SERVER_NAME} - ports: - - "${HTTP_PORT:-8000}:80" volumes: - - ./nginx:/etc/nginx/templates:ro - wordpress:/var/www/html networks: - internal @@ -91,7 +88,7 @@ services: - internal volumes: - dbdata: + db_data: wordpress: networks: diff --git a/nginx/default.conf.template b/nginx/dev.conf.template similarity index 99% rename from nginx/default.conf.template rename to nginx/dev.conf.template index 385090d..64a0e44 100644 --- a/nginx/default.conf.template +++ b/nginx/dev.conf.template @@ -4,17 +4,23 @@ server { root /var/www/html; index index.php index.html; - + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header X-XSS-Protection "1; mode=block"; + location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { + include fastcgi_params; try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass wordpress:9000; fastcgi_index index.php; - include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; } @@ -22,8 +28,4 @@ server { location ~ /\.ht { deny all; } - - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-Content-Type-Options "nosniff"; - add_header X-XSS-Protection "1; mode=block"; } \ No newline at end of file diff --git a/nginx/prod.conf.template b/nginx/prod.conf.template new file mode 100644 index 0000000..ce89145 --- /dev/null +++ b/nginx/prod.conf.template @@ -0,0 +1,56 @@ +server { + listen 80; + server_name ${SERVER_NAME}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name ${SERVER_NAME}; + + root /var/www/html; + index index.php; + + ssl_certificate /etc/letsencrypt/live/${SERVER_NAME}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${SERVER_NAME}/privkey.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options SAMEORIGIN; + add_header X-XSS-Protection "1; mode=block"; + add_header Referrer-Policy strict-origin-when-cross-origin; + + location / { + try_files $uri $uri/ /index.php?$args; + } + + location ~ \.php$ { + include fastcgi_params; + + fastcgi_pass wordpress:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + fastcgi_read_timeout 60s; + } + + location ~ /\.(?!well-known) { + deny all; + } +} diff --git a/php/zz-dev.ini b/php/zz-dev.ini new file mode 100644 index 0000000..b764d1f --- /dev/null +++ b/php/zz-dev.ini @@ -0,0 +1,25 @@ +; -------------------------------------------------- +; PHP configuration overrides (Development) +; -------------------------------------------------- + +; Increase memory for local development +;memory_limit = 256M + +; Allow longer execution time during development +;max_execution_time = 60 +;max_input_time = 60 + +; Allow larger uploads for media and testing +;upload_max_filesize = 64M +;post_max_size = 64M + +; Display errors for debugging +;display_errors = On +;display_startup_errors = On +;error_reporting = E_ALL + +; Expose PHP version in headers (acceptable in dev) +;expose_php = On + +; Default character set +;default_charset = "UTF-8" diff --git a/php/zz-prod.ini b/php/zz-prod.ini new file mode 100644 index 0000000..3017fbb --- /dev/null +++ b/php/zz-prod.ini @@ -0,0 +1,32 @@ +; -------------------------------------------------- +; PHP configuration overrides (Production) +; -------------------------------------------------- + +; Limit memory usage to avoid exhausting the host +;memory_limit = 256M + +; Prevent long-running PHP processes +;max_execution_time = 30 +;max_input_time = 30 + +; Reasonable upload limits +;upload_max_filesize = 64M +;post_max_size = 64M + +; Do NOT display errors in production +;display_errors = Off +;display_startup_errors = Off +;error_reporting = E_ALL + +; Log errors instead (handled by container logs) +;log_errors = On + +; Hide PHP version from headers +;expose_php = Off + +; Default character set +;default_charset = "UTF-8" + +; Realpath cache for performance +;realpath_cache_size = 4096K +;realpath_cache_ttl = 600 diff --git a/scripts/certbot/certbot-dry-run/certbot-dry-run.sh b/scripts/certbot/certbot-dry-run/certbot-dry-run.sh new file mode 100644 index 0000000..4d18ff1 --- /dev/null +++ b/scripts/certbot/certbot-dry-run/certbot-dry-run.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +echo "Running Certbot dry-run (renew simulation)" + +certbot renew --dry-run + +echo "Dry-run completed successfully" diff --git a/scripts/certbot/certbot-first-issue/certbot-first-issue.sh b/scripts/certbot/certbot-first-issue/certbot-first-issue.sh new file mode 100644 index 0000000..d249d74 --- /dev/null +++ b/scripts/certbot/certbot-first-issue/certbot-first-issue.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +. /scripts/utils/check-required-vars.sh + +check_required_vars "SERVER_NAME LETSENCRYPT_EMAIL" + +echo "Requesting first Let's Encrypt certificate for $SERVER_NAME" + +certbot certonly \ + --webroot \ + --webroot-path /var/www/certbot \ + --domain "$SERVER_NAME" \ + --email "$LETSENCRYPT_EMAIL" \ + --agree-tos \ + --no-eff-email \ + --non-interactive + +echo "Certificate successfully issued" diff --git a/scripts/certbot/certbot-renew/certbot-renew.sh b/scripts/certbot/certbot-renew/certbot-renew.sh new file mode 100644 index 0000000..bed3543 --- /dev/null +++ b/scripts/certbot/certbot-renew/certbot-renew.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +. /scripts/certbot/certbot-renew/lib/terminate.sh + +trap terminate TERM INT + +echo "Certbot renewal service started" +echo "Renewal interval: ${CERTBOT_RENEW_INTERVAL}" + +while true; do + echo "Checking certificates" + + if certbot renew --webroot -w /var/www/certbot --quiet; then + echo "Renewal check completed" + else + echo "Renewal check returned non-zero status (may be expected)" + fi + + echo "Waiting ${CERTBOT_RENEW_INTERVAL} for next check..." + sleep "${CERTBOT_RENEW_INTERVAL}" +done diff --git a/scripts/certbot/certbot-renew/lib/terminate.sh b/scripts/certbot/certbot-renew/lib/terminate.sh new file mode 100644 index 0000000..079f439 --- /dev/null +++ b/scripts/certbot/certbot-renew/lib/terminate.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +terminate() { + echo "Stopping certbot renewal service" + exit 0 +} diff --git a/scripts/wp-cli/check-wp-installed.sh b/scripts/wp-cli/check-wp-installed.sh new file mode 100644 index 0000000..54fb4e3 --- /dev/null +++ b/scripts/wp-cli/check-wp-installed.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +check_wp_installed() { + if [ "${WP_INSTALLED_READY}" = "true" ]; then + return 0 + fi + + if ! wp core is-installed --allow-root >/dev/null 2>&1; then + echo "WordPress is not installed in the database" + return 1 + fi + + export WP_INSTALLED_READY=true + echo "WordPress database is available" + return 0 +} diff --git a/scripts/wp-init/site-url/get-current-site-url.sh b/scripts/wp-init/site-url/get-current-site-url.sh index 830dd5e..4560f31 100644 --- a/scripts/wp-init/site-url/get-current-site-url.sh +++ b/scripts/wp-init/site-url/get-current-site-url.sh @@ -3,6 +3,7 @@ set -e . /scripts/wp-cli/check-wp-path.sh . /scripts/wp-cli/check-wp-cli.sh +. /scripts/wp-cli/check-wp-installed.sh . /scripts/db-common/wait-for-db.sh check_wp_path @@ -10,6 +11,7 @@ check_wp_cli # shellcheck disable=SC2119 wait_for_db +check_wp_installed || exit 0 CURRENT_SITE_URL=$(wp option get siteurl --allow-root 2>/dev/null || true) diff --git a/scripts/wp-init/site-url/update-site-url.sh b/scripts/wp-init/site-url/update-site-url.sh index 65db999..64d6b46 100644 --- a/scripts/wp-init/site-url/update-site-url.sh +++ b/scripts/wp-init/site-url/update-site-url.sh @@ -4,6 +4,7 @@ set -e . /scripts/utils/check-required-vars.sh . /scripts/wp-cli/check-wp-path.sh . /scripts/wp-cli/check-wp-cli.sh +. /scripts/wp-cli/check-wp-installed.sh . /scripts/db-common/wait-for-db.sh check_required_vars "CURRENT_SITE_URL SITE_URL" @@ -13,6 +14,7 @@ check_wp_cli # shellcheck disable=SC2119 wait_for_db +check_wp_installed || exit 0 echo "Starting site URL update: ${CURRENT_SITE_URL} → ${SITE_URL}"