KymaCloud WordPress Multi-Site Environment - Technical Documentation

DevOps and Cloud Engineer
Focused on optimizing the software development lifecycle through seamless integration of development and operations, specializing in designing, implementing, and managing scalable cloud infrastructure with a strong emphasis on automation and collaboration.
Key Skills:
Terraform: Skilled in Infrastructure as Code (IaC) for automating infrastructure deployment and management. Ansible: Proficient in automation tasks, configuration management, and application deployment. AWS: Extensive experience with AWS services like EC2, S3, RDS, and Lambda, designing scalable and cost-effective solutions. Kubernetes: Expert in container orchestration, deploying, scaling, and managing containerized applications. Docker: Proficient in containerization for consistent development, testing, and deployment. Google Cloud Platform: Familiar with GCP services for compute, storage, and machine learning.
Table of Contents
System Architecture
Container Stack
┌─────────────────────────────────────────────────────────────┐
│ Docker Host System │
├─────────────────────────────────────────────────────────────┤
│ Frontend Network (172.20.0.0/24) │
│ ├── nginx-proxy (172.20.0.6) │
│ ├── phpmyadmin-mysql (172.20.0.3) │
│ └── phpmyadmin-mariadb (172.20.0.4) │
├─────────────────────────────────────────────────────────────┤
│ Backend Network WP1 (172.21.0.0/24) - internal: true │
│ ├── wordpress1 (172.21.0.3) │
│ ├── mysql-wp1 (172.21.0.2) │
│ └── sftp-wp1 (172.21.0.4) │
├─────────────────────────────────────────────────────────────┤
│ Backend Network WP2 (172.22.0.0/24) - internal: true │
│ ├── wordpress2 (172.22.0.3) │
│ ├── mariadb-wp2 (172.22.0.2) │
│ └── sftp-wp2 (172.22.0.4) │
└─────────────────────────────────────────────────────────────┘
Technology Stack
| Component | Version | Image | Purpose |
| NGINX | 1.29.5 | nginx:alpine | Reverse proxy, load balancer |
| WordPress 1 | 6.9 | wordpress:php8.1-fpm-alpine | CMS, PHP 8.1 |
| WordPress 2 | 6.9 | wordpress:php8.4-fpm-alpine | CMS, PHP 8.4 |
| MySQL | 8.0 | mysql:8.0 | RDBMS for WordPress 1 |
| MariaDB | 11 | mariadb:11 | RDBMS for WordPress 2 |
| PHPMyAdmin | 5.2.3 | phpmyadmin:latest | Database administration |
| SFTP | Latest | atmoz/sftp:alpine | File transfer protocol |
Network Configuration
Network Topology
Frontend Network
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
internal: false # Internet access enabled
Purpose: Public-facing services Hosts: NGINX, PHPMyAdmin instances Routing: Can access backend networks via Docker DNS
Backend Network WP1
driver: bridge
ipam:
config:
- subnet: 172.21.0.0/24
internal: true # No internet access
Purpose: WordPress 1 stack isolation Hosts: wordpress1, mysql-wp1, sftp-wp1 Security: Cannot initiate outbound connections
Backend Network WP2
driver: bridge
ipam:
config:
- subnet: 172.22.0.0/24
internal: true # No internet access
Purpose: WordPress 2 stack isolation Hosts: wordpress2, mariadb-wp2, sftp-wp2 Security: Cannot initiate outbound connections
Port Mappings
| Service | Container Port | Host Port | Protocol |
| NGINX | 80 | 80 | HTTP |
| NGINX | 443 | 443 | HTTPS |
| SFTP1 | 22 | 2221 | SSH |
| SFTP2 | 22 | 2222 | SSH |
| WordPress1 | 9000 | - | FastCGI (internal) |
| WordPress2 | 9000 | - | FastCGI (internal) |
| MySQL | 3306 | - | MySQL (internal) |
| MariaDB | 3306 | - | MySQL (internal) |
| PHPMyAdmin1 | 80 | - | HTTP (internal) |
| PHPMyAdmin2 | 80 | - | HTTP (internal) |
Container Specifications
NGINX Container
Image: nginx:alpine Container Name: nginx-proxy Networks: frontend, backend_wp1, backend_wp2 Restart Policy: unless-stopped
Resource Limits:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
Health Check:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Volume Mounts:
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- wordpress1_data:/var/www/site1:ro
- wordpress2_data:/var/www/site2:ro
WordPress 1 Container
Image: wordpress:php8.1-fpm-alpine Container Name: wordpress1 Networks: frontend, backend_wp1 Restart Policy: unless-stopped
Environment Variables:
WORDPRESS_DB_HOST: mysql:3306
WORDPRESS_DB_NAME: ${WP1_DB_NAME}
WORDPRESS_DB_USER: ${WP1_DB_USER}
WORDPRESS_DB_PASSWORD: ${WP1_DB_PASSWORD}
WORDPRESS_TABLE_PREFIX: ${WP1_TABLE_PREFIX}
Resource Limits:
resources:
limits:
cpus: '1.5'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
Health Check:
test: ["CMD-SHELL", "php-fpm -t || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Depends On:
depends_on:
mysql:
condition: service_healthy
MySQL Container
Image: mysql:8.0 Container Name: mysql-wp1 Networks: backend_wp1 Restart Policy: unless-stopped
Environment Variables:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${WP1_DB_NAME}
MYSQL_USER: ${WP1_DB_USER}
MYSQL_PASSWORD: ${WP1_DB_PASSWORD}
Resource Limits:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
Health Check:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Volume Mounts:
volumes:
- mysql_data:/var/lib/mysql
- ./mysql/my.cnf:/etc/mysql/conf.d/custom.cnf:ro
MariaDB Container
Image: mariadb:11 Container Name: mariadb-wp2 Networks: backend_wp2 Restart Policy: unless-stopped
Environment Variables:
MYSQL_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MYSQL_DATABASE: ${WP2_DB_NAME}
MYSQL_USER: ${WP2_DB_USER}
MYSQL_PASSWORD: ${WP2_DB_PASSWORD}
Resource Limits:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
Health Check:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
PHPMyAdmin Containers
Image: phpmyadmin:latest Container Names: phpmyadmin-mysql, phpmyadmin-mariadb Networks: frontend, backend_wp1 / backend_wp2 Restart Policy: unless-stopped
Environment Variables (MySQL):
PMA_HOST: mysql
PMA_PORT: 3306
PMA_ARBITRARY: 0
Environment Variables (MariaDB):
PMA_HOST: mariadb
PMA_PORT: 3306
PMA_ARBITRARY: 0
Resource Limits:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
SFTP Containers
Image: atmoz/sftp:alpine Container Names: sftp-wp1, sftp-wp2 Networks: backend_wp1 / backend_wp2 Restart Policy: unless-stopped
Command:
# SFTP1
command: wp1user:${SFTP1_PASSWORD}:1000:1000:wordpress
# SFTP2
command: wp2user:${SFTP2_PASSWORD}:1000:1000:wordpress
Volume Mounts:
volumes:
- wordpress1_data:/home/wp1user/wordpress:rw
- ./sftp/wp1/ssh_host_ed25519_key:/etc/ssh/ssh_host_ed25519_key:ro
- ./sftp/wp1/ssh_host_rsa_key:/etc/ssh/ssh_host_rsa_key:ro
Configuration Files
NGINX Main Configuration (nginx/nginx.conf)
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 2048;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
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;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Gzip Compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
# Rate Limiting Zones
limit_req_zone $binary_remote_addr zone=wp_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Include Virtual Hosts
include /etc/nginx/conf.d/*.conf;
}
Key Configuration Parameters:
| Parameter | Value | Purpose |
| worker_processes | auto | Automatic CPU core detection |
| worker_connections | 2048 | Max concurrent connections per worker |
| gzip_comp_level | 6 | Balance between CPU and compression |
| limit_req_zone (wp) | 10r/s | General page rate limit |
| limit_req_zone (login) | 5r/m | Login attempt rate limit |
NGINX Site Configuration (nginx/conf.d/site1.conf)
upstream wordpress1_backend {
server wordpress1:9000;
keepalive 32;
}
server {
listen 80;
server_name site1.local www.site1.local;
root /var/www/site1;
index index.php index.html;
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Security: Block hidden files
location ~ /\. {
deny all;
access_log off;
}
# Security: Block wp-config.php
location ~ /wp-config.php {
deny all;
}
# Rate limiting for login
location ~ ^/(wp-login\.php|xmlrpc\.php) {
limit_req zone=login_limit burst=5 nodelay;
include fastcgi_params;
fastcgi_pass wordpress1_backend;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
fastcgi_intercept_errors on;
}
# Static file caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Main location
location / {
limit_req zone=wp_limit burst=20 nodelay;
try_files $uri $uri/ /index.php?$args;
}
# PHP processing
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
include fastcgi_params;
fastcgi_pass wordpress1_backend;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_intercept_errors on;
fastcgi_buffer_size 128k;
fastcgi_buffers 256 16k;
fastcgi_busy_buffers_size 256k;
fastcgi_read_timeout 240;
}
}
Critical Path Mapping:
NGINX root:
/var/www/site1(shared volume)WordPress root:
/var/www/html(container internal)FastCGI SCRIPT_FILENAME:
/var/www/html$fastcgi_script_name
This mapping is essential because NGINX and PHP-FPM are in different containers but share the same volume.
PHP Configuration (php/php8.1.ini)
[PHP]
; Memory & Execution
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
max_input_vars = 3000
; File Uploads
upload_max_filesize = 64M
post_max_size = 64M
; OPcache Configuration
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
opcache.fast_shutdown = 1
opcache.enable_cli = 0
; Security: Disable dangerous functions
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
; Error Handling
display_errors = Off
log_errors = On
error_log = /var/log/php/error.log
; Session
session.gc_maxlifetime = 1440
session.cookie_httponly = 1
session.cookie_secure = 0
session.use_strict_mode = 1
PHP 8.4 Configuration (php/php8.4.ini)
Includes all PHP 8.1 settings plus:
; JIT Configuration
opcache.jit_buffer_size = 100M
opcache.jit = tracing
JIT Compilation Modes:
tracing(1254): Most aggressive, best for WordPressfunction(1205): More conservativeoff(0): Disabled
MySQL Configuration (mysql/my.cnf)
[mysqld]
# InnoDB Settings
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
# Query Cache (disabled in MySQL 8.0)
# query_cache_type = 1
# query_cache_size = 32M
# Connection Settings
max_connections = 151
connect_timeout = 10
wait_timeout = 600
max_allowed_packet = 64M
# Binary Logging
log_bin = /var/lib/mysql/mysql-bin.log
expire_logs_days = 7
max_binlog_size = 100M
# Performance Schema
performance_schema = ON
# Charset
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
[client]
default-character-set = utf8mb4
Key Parameters:
| Parameter | Value | Impact |
| innodb_buffer_pool_size | 512M | Caches data & indexes in memory |
| innodb_flush_log_at_trx_commit | 2 | Better performance, small risk |
| max_connections | 151 | Concurrent connection limit |
| log_bin | Enabled | Point-in-time recovery |
MariaDB Configuration (mariadb/my.cnf)
[mysqld]
# InnoDB Settings
innodb_buffer_pool_size = 512M
innodb_log_file_size = 128M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
# Query Cache (available in MariaDB)
query_cache_type = 1
query_cache_size = 32M
query_cache_limit = 2M
# Connection Settings
max_connections = 151
connect_timeout = 10
wait_timeout = 600
max_allowed_packet = 64M
# Binary Logging
log_bin = /var/lib/mysql/mariadb-bin.log
expire_logs_days = 7
max_binlog_size = 100M
# Performance
thread_cache_size = 50
table_open_cache = 2000
# Charset
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
[client]
default-character-set = utf8mb4
Data Flow & Request Lifecycle
HTTP Request Flow (WordPress Page)
1. Client Browser
↓ HTTP GET http://site1.local/
2. Host System (port 80)
↓ Docker port forwarding
3. NGINX Container (nginx-proxy)
↓ Matches server_name site1.local
↓ Applies rate limiting (wp_limit: 10r/s)
↓ try_files $uri $uri/ /index.php?$args
↓ Forwards to location ~ \.php$
4. FastCGI Processing
↓ fastcgi_pass wordpress1:9000
↓ SCRIPT_FILENAME: /var/www/html/index.php
5. WordPress Container (wordpress1)
↓ PHP-FPM receives request on port 9000
↓ Executes /var/www/html/index.php
↓ WordPress loads configuration
6. Database Query
↓ WORDPRESS_DB_HOST=mysql:3306
↓ Docker DNS resolves "mysql" to 172.21.0.2
↓ Connection via backend_wp1 network
7. MySQL Container (mysql-wp1)
↓ Authenticates wp1user
↓ Queries wordpress1 database
↓ Returns result set
8. Response Generation
↓ WordPress renders HTML
↓ PHP-FPM returns to NGINX
↓ NGINX applies gzip compression
↓ Adds security headers
↓ Sends to client
9. Client Browser
↓ Receives compressed HTML
↓ Renders page
SFTP Connection Flow
1. SFTP Client
↓ sftp -P 2221 wp1user@localhost
2. Host System (port 2221)
↓ Docker port forwarding
3. SFTP Container (sftp-wp1)
↓ SSH daemon on port 22
↓ Authenticates wp1user with password/key
↓ Changes to /home/wp1user/wordpress
4. Volume Mount
↓ /home/wp1user/wordpress → wordpress1_data volume
↓ Same volume mounted by wordpress1 container
5. File Operations
↓ User uploads/downloads files
↓ Changes reflected immediately in WordPress
Database Connection Establishment
WordPress Container Startup:
1. Docker Compose starts mysql container first (depends_on)
2. Health check waits for mysqladmin ping success
3. WordPress container starts after mysql is healthy
4. WordPress reads environment variables:
- WORDPRESS_DB_HOST=mysql:3306
- WORDPRESS_DB_NAME=wordpress1
- WORDPRESS_DB_USER=wp1user
- WORDPRESS_DB_PASSWORD=<from .env>
5. Docker DNS resolves "mysql" to 172.21.0.2
6. TCP connection to 172.21.0.2:3306 via backend_wp1 network
7. MySQL authenticates credentials
8. WordPress creates wp-config.php if not exists
9. Database connection pool established
Scaling Implementation
Horizontal Scaling Architecture
Why PHP-FPM?
Stateless: No session affinity required
Process isolation: Each request independent
Shared storage: All instances read same files
Database pooling: Concurrent connections handled
Scaling Commands
# Scale WordPress 1 to 5 instances
docker-compose up -d --scale wordpress1=5
# Scale both sites
docker-compose up -d --scale wordpress1=5 --scale wordpress2=3
# Verify scaling
docker-compose ps | grep wordpress
Load Balancing Configuration
NGINX automatically load balances across scaled instances:
upstream wordpress1_backend {
server wordpress1:9000;
# When scaled, Docker DNS returns multiple IPs
# NGINX round-robins between them
keepalive 32; # Connection pooling
}
Docker DNS Resolution:
Single instance:
wordpress1→172.21.0.3Scaled (3x):
wordpress1→172.21.0.3, 172.21.0.4, 172.21.0.5
Load Balancing Algorithms
Current: Round Robin (default)
Alternative configurations:
# Least Connections
upstream wordpress1_backend {
least_conn;
server wordpress1:9000;
}
# IP Hash (session affinity)
upstream wordpress1_backend {
ip_hash;
server wordpress1:9000;
}
# Weighted Round Robin
upstream wordpress1_backend {
server wordpress1:9000 weight=3;
server wordpress1:9000 weight=1;
}
Scaling Limitations in Docker Compose
Current Environment:
Scales within single host
Shared volumes via local filesystem
Limited by host resources
Production Scaling (Kubernetes):
apiVersion: apps/v1
kind: Deployment
metadata:
name: wordpress1
spec:
replicas: 5
selector:
matchLabels:
app: wordpress1
template:
spec:
containers:
- name: wordpress
image: wordpress:php8.1-fpm-alpine
resources:
limits:
memory: "1Gi"
cpu: "1500m"
requests:
memory: "512Mi"
cpu: "500m"
Required for Production:
Shared storage: NFS, EFS, or object storage (S3)
External load balancer: ALB, ELB, or Ingress
Redis/Memcached: Session storage
Read replicas: Database scaling
Security Implementation
Defense in Depth Strategy
Layer 1: Network Isolation
networks:
backend_wp1:
internal: true # No internet egress
backend_wp2:
internal: true # No internet egress
Effect:
Databases cannot be accessed from internet
Compromised database cannot exfiltrate data
Lateral movement between stacks prevented
Security Validation
File Permissions:
# Enforced by setup.sh
chmod 600 .env
chmod 600 .passwords
chmod 600 sftp/*/ssh_host_*
Verification:
./scripts/security-check.sh
Output:
[PASS] .env permissions are secure (600)
[PASS] .env is not tracked by git
[PASS] .passwords file removed
[PASS] No default passwords found
[PASS] SSH key permissions checked
Rate Limiting Implementation
Zone Definitions:
limit_req_zone $binary_remote_addr zone=wp_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
Parameters:
$binary_remote_addr: Uses binary IP (saves memory)zone=wp_limit:10m: 10MB shared memory zonerate=10r/s: 10 requests per second averageburst=20: Allow temporary bursts up to 20
Applied:
location / {
limit_req zone=wp_limit burst=20 nodelay;
}
location ~ ^/(wp-login\.php|xmlrpc\.php) {
limit_req zone=login_limit burst=5 nodelay;
}
Effect:
Prevents brute force login attempts
Mitigates DDoS attacks
Protects against resource exhaustion
PHP Security Hardening
Disabled Functions:
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source
Why These Functions:
exec,shell_exec,system: Command injection vectorsproc_open,popen: Process executioncurl_exec: SSRF potentialparse_ini_file,show_source: Information disclosure
Impact:
Blocks most PHP backdoors
Prevents reverse shells
Limits malware capabilities
Some plugins may break (rare)
Database Security
User Separation:
-- WordPress user (limited privileges)
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER
ON wordpress1.* TO 'wp1user'@'%';
-- Root user (full privileges, restricted to container)
-- Only accessible from within mysql container
Network Restrictions:
Databases only accessible via internal networks
No external exposure (no port mapping)
Communication only with designated WordPress containers
SFTP Security
Non-Standard Ports:
Port 2221 (Site 1) instead of 22
Port 2222 (Site 2) instead of 22
Reduces automated attack surface
SSH Key Support:
volumes:
- ./sftp/wp1/ssh_host_ed25519_key:/etc/ssh/ssh_host_ed25519_key:ro
- ./sftp/wp1/ssh_host_rsa_key:/etc/ssh/ssh_host_rsa_key:ro
Best Practice: Disable password auth, use SSH keys only
Security Headers
Applied by NGINX:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
Protection:
X-Frame-Options: Prevents clickjackingX-Content-Type-Options: Prevents MIME sniffingX-XSS-Protection: XSS filter enforcementReferrer-Policy: Controls referrer information
Deployment Procedures
Initial Deployment
# 1. Clone repository
git clone <repository-url>
cd kymacloud-wordpress
# 2. Generate secrets
chmod +x setup.sh
./setup.sh
# 3. Review generated passwords
cat .passwords
# Save to password manager
# 4. Delete password file
rm .passwords
# 5. Start services
docker-compose up -d
# 6. Monitor startup
docker-compose logs -f
# 7. Wait for healthy status
watch -n 2 'docker-compose ps'
# 8. Configure DNS/hosts
echo "127.0.0.1 site1.local site2.local pma1.local pma2.local" | sudo tee -a /etc/hosts
# 9. Verify accessibility
curl -I http://site1.local
curl -I http://site2.local
curl -I http://pma1.local
curl -I http://pma2.local
# 10. Complete WordPress installation
# Visit http://site1.local and http://site2.local in browser
Password Rotation Procedure
# 1. Remove old SSH keys
rm -f sftp/wp1/ssh_host_* sftp/wp2/ssh_host_*
# 2. Generate new passwords
./setup.sh
# Answer 'y' to regenerate
# 3. Save new passwords
cat .passwords
# Update password manager
# 4. Stop services
docker-compose down
# 5. Remove volumes with old credentials
docker volume rm kymacloud_wordpress1_data \
kymacloud_wordpress2_data \
kymacloud_mysql_data \
kymacloud_mariadb_data
# 6. Start fresh with new credentials
docker-compose up -d
# 7. Wait for initialization
sleep 60
docker-compose ps
# 8. Verify connectivity
docker-compose exec mysql mysql -u root -p -e "SELECT 1;"
docker-compose exec mariadb mysql -u root -p -e "SELECT 1;"
# 9. Delete password file
rm .passwords
Update Procedure
NGINX Configuration Update:
# 1. Edit configuration
nano nginx/conf.d/site1.conf
# 2. Test configuration
docker-compose exec nginx nginx -t
# 3. Reload NGINX (zero downtime)
docker-compose exec nginx nginx -s reload
# 4. Verify
curl -I http://site1.local
docker-compose logs nginx
PHP Configuration Update:
# 1. Edit configuration
nano php/php8.1.ini
# 2. Restart WordPress containers
docker-compose restart wordpress1 wordpress2
# 3. Verify
docker-compose exec wordpress1 php -i | grep opcache
Database Configuration Update:
# 1. Edit configuration
nano mysql/my.cnf
# 2. Restart database (causes downtime)
docker-compose restart mysql
# 3. Verify
docker-compose exec mysql mysql -u root -p -e "SHOW VARIABLES LIKE 'innodb_buffer_pool_size';"
Backup Procedures
Manual Backup:
# Database backup
docker-compose exec mysql mysqldump -u root -p wordpress1 > backups/mysql/wordpress1_$(date +%Y%m%d).sql
docker-compose exec mariadb mysqldump -u root -p wordpress2 > backups/mariadb/wordpress2_$(date +%Y%m%d).sql
# WordPress files backup
docker run --rm -v kymacloud_wordpress1_data:/data -v $(pwd)/backups/wordpress1:/backup alpine tar czf /backup/wordpress1_$(date +%Y%m%d).tar.gz -C /data .
docker run --rm -v kymacloud_wordpress2_data:/data -v $(pwd)/backups/wordpress2:/backup alpine tar czf /backup/wordpress2_$(date +%Y%m%d).tar.gz -C /data .
Automated Backup Script:
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
# MySQL backup
docker-compose exec -T mysql mysqldump -u root -p${MYSQL_ROOT_PASSWORD} wordpress1 | gzip > backups/mysql/wordpress1_${DATE}.sql.gz
# MariaDB backup
docker-compose exec -T mariadb mysqldump -u root -p${MARIADB_ROOT_PASSWORD} wordpress2 | gzip > backups/mariadb/wordpress2_${DATE}.sql.gz
# Rotate old backups (keep 7 days)
find backups/mysql -name "*.sql.gz" -mtime +7 -delete
find backups/mariadb -name "*.sql.gz" -mtime +7 -delete
echo "Backup completed: ${DATE}"
Schedule with cron:
# Run daily at 2 AM
0 2 * * * /path/to/kymacloud-wordpress/backup.sh >> /var/log/wordpress-backup.log 2>&1
Restore Procedures
Database Restore:
# MySQL restore
docker-compose exec -T mysql mysql -u root -p${MYSQL_ROOT_PASSWORD} wordpress1 < backups/mysql/wordpress1_20260206.sql
# MariaDB restore
docker-compose exec -T mariadb mysql -u root -p${MARIADB_ROOT_PASSWORD} wordpress2 < backups/mariadb/wordpress2_20260206.sql
WordPress Files Restore:
# Extract backup to volume
docker run --rm -v kymacloud_wordpress1_data:/data -v $(pwd)/backups/wordpress1:/backup alpine tar xzf /backup/wordpress1_20260206.tar.gz -C /data
# Restart WordPress to pick up changes
docker-compose restart wordpress1
Troubleshooting Guide
Container Health Check Failures
Symptoms:
docker-compose ps
# Shows: (unhealthy) status
Diagnosis:
# Check health check logs
docker inspect wordpress1 | jq '.[0].State.Health'
# View recent health check results
docker inspect wordpress1 | jq '.[0].State.Health.Log[-5:]'
Common Causes:
- Database not ready:
# Check database logs
docker-compose logs mysql
# Wait for initialization
docker-compose logs mysql | grep "ready for connections"
- Wrong credentials:
# Verify environment variables
docker-compose exec wordpress1 env | grep WORDPRESS_DB
# Check database users
docker-compose exec mysql mysql -u root -p -e "SELECT User, Host FROM mysql.user;"
- Health check endpoint missing:
# Test health endpoint
curl http://localhost/health
# Check NGINX configuration
docker-compose exec nginx cat /etc/nginx/conf.d/site1.conf | grep health
Database Connection Errors
Error: "Error establishing a database connection"
Diagnosis Steps:
- Verify network connectivity:
docker-compose exec wordpress1 ping -c 2 mysql
# Should succeed: 64 bytes from mysql
- Check database is running:
docker-compose ps mysql
# Should show: (healthy)
- Verify credentials:
# From WordPress container
docker-compose exec wordpress1 env | grep WORDPRESS_DB_PASSWORD
# From .env file
grep WP1_DB_PASSWORD .env
# Test login
docker-compose exec mysql mysql -u wp1user -p
- Check wp-config.php:
docker-compose exec wordpress1 cat /var/www/html/wp-config.php | grep DB_
Solution if credentials mismatch:
# Nuclear option: regenerate everything
docker-compose down
docker volume rm kymacloud_wordpress1_data kymacloud_mysql_data
rm -f sftp/wp1/ssh_host_*
./setup.sh
docker-compose up -d
NGINX 502 Bad Gateway
Symptoms: HTTP 502 error when accessing WordPress sites
Diagnosis:
- Check WordPress is running:
docker-compose ps wordpress1
# Must show "Up" status
- Check PHP-FPM is listening:
docker-compose exec wordpress1 netstat -tlnp | grep 9000
# Should show: tcp 0 0 0.0.0.0:9000 0.0.0.0:* LISTEN
- Check NGINX can reach PHP-FPM:
docker-compose exec nginx nc -zv wordpress1 9000
# Should show: wordpress1 (172.21.0.3:9000) open
- Check NGINX error logs:
docker-compose logs nginx | grep error
docker-compose logs nginx | grep "connect() failed"
Common Causes:
- FastCGI path mismatch:
# WRONG:
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# CORRECT:
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
- WordPress crashed:
docker-compose restart wordpress1
docker-compose logs wordpress1
- Network misconfiguration:
# Verify WordPress is on correct network
docker inspect wordpress1 | jq '.[0].NetworkSettings.Networks'
Port Conflicts
Error: "Bind for 0.0.0.0:80 failed: port is already allocated"
Diagnosis:
# Find process using port 80
sudo lsof -i :80
# or
sudo netstat -tlnp | grep :80
Solution:
# Stop conflicting service
sudo systemctl stop apache2
# or
sudo systemctl stop nginx
# Start Docker containers
docker-compose up -d
Volume Permission Issues
Symptoms: WordPress cannot write files, upload failures
Diagnosis:
# Check permissions inside container
docker-compose exec wordpress1 ls -la /var/www/html/
# Check volume mount
docker volume inspect kymacloud_wordpress1_data
Solution:
# Fix ownership
docker-compose exec wordpress1 chown -R www-data:www-data /var/www/html/
# Fix permissions
docker-compose exec wordpress1 chmod -R 755 /var/www/html/
SFTP Connection Refused
Symptoms: Cannot connect via SFTP
Diagnosis:
# Check SFTP container is running
docker-compose ps sftp1
# Check port mapping
docker-compose ps | grep 2221
# Test connection
telnet localhost 2221
Common Issues:
- SSH keys missing:
ls -la sftp/wp1/
# Should show: ssh_host_ed25519_key, ssh_host_rsa_key
# Regenerate if missing
./setup.sh
- Wrong password:
# Check password
cat .passwords | grep SFTP1
# Or regenerate
./setup.sh
- Permission denied:
# Check key permissions
ls -l sftp/wp1/ssh_host_*
# Should show: -rw------- (600)
# Fix if wrong
chmod 600 sftp/wp1/ssh_host_*
Docker Compose Version Warning
Warning: "the attribute version is obsolete"
Explanation: Docker Compose v2 doesn't require version field
Solution (optional):
# Edit docker-compose.yml
nano docker-compose.yml
# Remove first line:
# version: '3.8'
Out of Disk Space
Symptoms: Containers fail to start, write errors
Diagnosis:
# Check disk usage
df -h
# Check Docker disk usage
docker system df
# Check volume sizes
docker volume ls
docker system df -v
Solution:
# Remove unused containers, images, networks
docker system prune -a
# Remove unused volumes (CAUTION: deletes data)
docker volume prune
# Remove specific volumes
docker volume rm kymacloud_wordpress1_data
Memory Issues
Symptoms: Containers randomly restart, OOM errors
Diagnosis:
# Check container memory usage
docker stats --no-stream
# Check host memory
free -h
# Check container logs for OOM
docker-compose logs | grep -i "out of memory"
Solution:
# Increase container limits in docker-compose.yml
services:
wordpress1:
deploy:
resources:
limits:
memory: 2G # Increase from 1G
Performance Tuning
NGINX Performance
Worker Processes:
worker_processes auto; # Auto-detect CPU cores
events {
worker_connections 2048; # Connections per worker
use epoll; # Efficient event mechanism (Linux)
multi_accept on; # Accept multiple connections
}
Calculation:
Max concurrent connections = worker_processes × worker_connections
Example: 4 cores × 2048 = 8192 concurrent connections
Buffer Tuning:
http {
client_body_buffer_size 128k;
client_max_body_size 64M;
client_header_buffer_size 1k;
large_client_header_buffers 4 16k;
}
Static Content Caching:
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
FastCGI Tuning:
fastcgi_buffer_size 128k;
fastcgi_buffers 256 16k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;
fastcgi_read_timeout 240;
fastcgi_connect_timeout 60;
PHP-FPM Performance
Process Manager Configuration:
; Dynamic process management
pm = dynamic
pm.max_children = 50 ; Max PHP processes
pm.start_servers = 5 ; Initial processes
pm.min_spare_servers = 5 ; Minimum idle processes
pm.max_spare_servers = 10 ; Maximum idle processes
pm.max_requests = 500 ; Restart after N requests (prevents memory leaks)
Calculation:
Max Memory Usage = pm.max_children × memory_limit
Example: 50 × 256MB = 12.8GB
Rule of thumb:
pm.max_children = (Available RAM - Reserved) / memory_limit
OPcache Tuning:
opcache.memory_consumption = 128 ; MB for compiled code
opcache.interned_strings_buffer = 16 ; MB for strings
opcache.max_accelerated_files = 10000 ; Number of files to cache
opcache.revalidate_freq = 2 ; Seconds between file checks
OPcache Hit Rate Monitoring:
# Create monitoring script
docker-compose exec wordpress1 php -r 'print_r(opcache_get_status());'
JIT Configuration (PHP 8.4):
opcache.jit = tracing ; or 1254
opcache.jit_buffer_size = 100M
JIT Modes:
off(0): Disabledfunction(1205): Function-level JITtracing(1254): Trace-based JIT (fastest for WordPress)
MySQL Performance
Buffer Pool Sizing:
innodb_buffer_pool_size = 512M
Calculation:
Dedicated DB server: 70-80% of total RAM
Shared environment: 25-50% of total RAM
Example: 2GB RAM → 512MB-1GB buffer pool
Connection Pool:
max_connections = 151
thread_cache_size = 50
Calculation:
Memory per connection ≈ 0.5-2MB
Max connection memory = max_connections × 1MB ≈ 151MB
Query Optimization:
-- Enable slow query log
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;
-- Analyze queries
SHOW FULL PROCESSLIST;
EXPLAIN SELECT * FROM wp_posts WHERE post_status = 'publish';
Index Optimization:
-- Check index usage
SELECT * FROM sys.schema_unused_indexes;
-- Add missing indexes
ALTER TABLE wp_posts ADD INDEX idx_post_status (post_status);
MariaDB Performance
Query Cache (available in MariaDB, removed in MySQL 8.0):
query_cache_type = 1
query_cache_size = 32M
query_cache_limit = 2M
Monitoring:
SHOW VARIABLES LIKE 'query_cache%';
SHOW STATUS LIKE 'Qcache%';
Calculate efficiency:
Hit Rate = Qcache_hits / (Qcache_hits + Qcache_inserts + Qcache_not_cached)
Ideal: > 80%
WordPress Performance
Object Caching:
# Install Redis (requires additional container)
docker-compose exec wordpress1 wp plugin install redis-cache --activate
docker-compose exec wordpress1 wp redis enable
Database Optimization:
# Optimize tables
docker-compose exec mysql mysqlcheck -u root -p --optimize wordpress1
# Clean up revisions
docker-compose exec wordpress1 wp post delete $(wp post list --post_type='revision' --format=ids) --force
Media Optimization:
# Install optimization plugin
docker-compose exec wordpress1 wp plugin install ewww-image-optimizer --activate
Load Testing
Apache Bench:
# Test homepage
ab -n 1000 -c 10 http://site1.local/
# Test with keepalive
ab -n 1000 -c 10 -k http://site1.local/
Siege:
siege -c 10 -t 60s http://site1.local/
Interpretation:
Requests per second: Target 100+ for small sites
Time per request: Target < 100ms
Failed requests: Target 0%
Monitoring Performance
Real-time Monitoring:
# Container resource usage
docker stats
# NGINX access logs
docker-compose logs -f nginx | grep -v "GET /health"
# PHP-FPM status
docker-compose exec wordpress1 curl http://localhost/status?full
Performance Metrics:
# Response time measurement
curl -w "@curl-format.txt" -o /dev/null -s http://site1.local/
# curl-format.txt:
time_namelookup: %{time_namelookup}\n
time_connect: %{time_connect}\n
time_appconnect: %{time_appconnect}\n
time_pretransfer: %{time_pretransfer}\n
time_redirect: %{time_redirect}\n
time_starttransfer: %{time_starttransfer}\n
----------\n
time_total: %{time_total}\n
Appendices
Environment Variables Reference
| Variable | Container | Description | Example |
| MYSQL_ROOT_PASSWORD | mysql | MySQL root password | Auto-generated |
| WP1_DB_NAME | mysql, wordpress1 | WordPress 1 database name | wordpress1 |
| WP1_DB_USER | mysql, wordpress1 | WordPress 1 database user | wp1user |
| WP1_DB_PASSWORD | mysql, wordpress1 | WordPress 1 database password | Auto-generated |
| MARIADB_ROOT_PASSWORD | mariadb | MariaDB root password | Auto-generated |
| WP2_DB_NAME | mariadb, wordpress2 | WordPress 2 database name | wordpress2 |
| WP2_DB_USER | mariadb, wordpress2 | WordPress 2 database user | wp2user |
| WP2_DB_PASSWORD | mariadb, wordpress2 | WordPress 2 database password | Auto-generated |
| SFTP1_PASSWORD | sftp1 | SFTP Site 1 password | Auto-generated |
| SFTP2_PASSWORD | sftp2 | SFTP Site 2 password | Auto-generated |
| WP1_TABLE_PREFIX | wordpress1 | Table prefix Site 1 | wp1_ |
| WP2_TABLE_PREFIX | wordpress2 | Table prefix Site 2 | wp2_ |
| PMA1_URL | phpmyadmin1 | PHPMyAdmin MySQL URL | http://localhost/pma1/ |
| PMA2_URL | phpmyadmin2 | PHPMyAdmin MariaDB URL | http://localhost/pma2/ |
Docker Volume Reference
| Volume Name | Mount Point | Container(s) | Purpose |
| wordpress1_data | /var/www/html | wordpress1, sftp1 | WordPress 1 files |
| wordpress2_data | /var/www/html | wordpress2, sftp2 | WordPress 2 files |
| mysql_data | /var/lib/mysql | mysql | MySQL database |
| mariadb_data | /var/lib/mysql | mariadb | MariaDB database |
| - | /var/www/site1 | nginx | WordPress 1 files (ro) |
| - | /var/www/site2 | nginx | WordPress 2 files (ro) |
Port Reference
| Port | Service | Protocol | Access |
| 80 | NGINX | HTTP | Public |
| 443 | NGINX | HTTPS | Public |
| 2221 | SFTP1 | SSH | Public |
| 2222 | SFTP2 | SSH | Public |
| 9000 | WordPress1 | FastCGI | Internal |
| 9000 | WordPress2 | FastCGI | Internal |
| 3306 | MySQL | MySQL | Internal |
| 3306 | MariaDB | MySQL | Internal |
| 80 | PHPMyAdmin1 | HTTP | Internal |
| 80 | PHPMyAdmin2 | HTTP | Internal |
Command Reference
Management:
docker-compose up -d # Start all services
docker-compose down # Stop all services
docker-compose restart # Restart all services
docker-compose ps # Show service status
docker-compose logs -f # Follow logs
docker-compose exec SERVICE # Execute command in container
Scaling:
docker-compose up -d --scale wordpress1=N # Scale WordPress 1
docker-compose up -d --scale wordpress2=N # Scale WordPress 2
Cleanup:
docker-compose down -v # Stop and remove volumes
docker system prune -a # Clean unused resources
docker volume prune # Clean unused volumes
Monitoring:
docker stats # Resource usage
docker-compose top # Process list
docker inspect SERVICE # Detailed info
File Permissions Reference
| File/Directory | Permission | Owner | Purpose |
| .env | 600 | user | Environment variables |
| .passwords | 600 | user | Password reference |
| sftp//ssh_host_ | 600 | user | SSH host keys |
| setup.sh | 755 | user | Setup script |
| scripts/*.sh | 755 | user | Utility scripts |



