Skip to main content

Command Palette

Search for a command to run...

KymaCloud WordPress Multi-Site Environment - Technical Documentation

Published
23 min read
KymaCloud WordPress Multi-Site Environment - Technical Documentation
B

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

  1. System Architecture

  2. Network Configuration

  3. Container Specifications

  4. Configuration Files

  5. Data Flow & Request Lifecycle

  6. Scaling Implementation

  7. Security Implementation

  8. Deployment Procedures

  9. Troubleshooting Guide

  10. Performance Tuning


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

ComponentVersionImagePurpose
NGINX1.29.5nginx:alpineReverse proxy, load balancer
WordPress 16.9wordpress:php8.1-fpm-alpineCMS, PHP 8.1
WordPress 26.9wordpress:php8.4-fpm-alpineCMS, PHP 8.4
MySQL8.0mysql:8.0RDBMS for WordPress 1
MariaDB11mariadb:11RDBMS for WordPress 2
PHPMyAdmin5.2.3phpmyadmin:latestDatabase administration
SFTPLatestatmoz/sftp:alpineFile 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

ServiceContainer PortHost PortProtocol
NGINX8080HTTP
NGINX443443HTTPS
SFTP1222221SSH
SFTP2222222SSH
WordPress19000-FastCGI (internal)
WordPress29000-FastCGI (internal)
MySQL3306-MySQL (internal)
MariaDB3306-MySQL (internal)
PHPMyAdmin180-HTTP (internal)
PHPMyAdmin280-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:

ParameterValuePurpose
worker_processesautoAutomatic CPU core detection
worker_connections2048Max concurrent connections per worker
gzip_comp_level6Balance between CPU and compression
limit_req_zone (wp)10r/sGeneral page rate limit
limit_req_zone (login)5r/mLogin 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 WordPress

  • function (1205): More conservative

  • off (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:

ParameterValueImpact
innodb_buffer_pool_size512MCaches data & indexes in memory
innodb_flush_log_at_trx_commit2Better performance, small risk
max_connections151Concurrent connection limit
log_binEnabledPoint-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: wordpress1172.21.0.3

  • Scaled (3x): wordpress1172.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 zone

  • rate=10r/s: 10 requests per second average

  • burst=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 vectors

  • proc_open, popen: Process execution

  • curl_exec: SSRF potential

  • parse_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 clickjacking

  • X-Content-Type-Options: Prevents MIME sniffing

  • X-XSS-Protection: XSS filter enforcement

  • Referrer-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:

  1. Database not ready:
# Check database logs
docker-compose logs mysql

# Wait for initialization
docker-compose logs mysql | grep "ready for connections"
  1. 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;"
  1. 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:

  1. Verify network connectivity:
docker-compose exec wordpress1 ping -c 2 mysql
# Should succeed: 64 bytes from mysql
  1. Check database is running:
docker-compose ps mysql
# Should show: (healthy)
  1. 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
  1. 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:

  1. Check WordPress is running:
docker-compose ps wordpress1
# Must show "Up" status
  1. 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
  1. Check NGINX can reach PHP-FPM:
docker-compose exec nginx nc -zv wordpress1 9000
# Should show: wordpress1 (172.21.0.3:9000) open
  1. Check NGINX error logs:
docker-compose logs nginx | grep error
docker-compose logs nginx | grep "connect() failed"

Common Causes:

  1. FastCGI path mismatch:
# WRONG:
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

# CORRECT:
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
  1. WordPress crashed:
docker-compose restart wordpress1
docker-compose logs wordpress1
  1. 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:

  1. SSH keys missing:
ls -la sftp/wp1/
# Should show: ssh_host_ed25519_key, ssh_host_rsa_key

# Regenerate if missing
./setup.sh
  1. Wrong password:
# Check password
cat .passwords | grep SFTP1

# Or regenerate
./setup.sh
  1. 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): Disabled

  • function (1205): Function-level JIT

  • tracing (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

VariableContainerDescriptionExample
MYSQL_ROOT_PASSWORDmysqlMySQL root passwordAuto-generated
WP1_DB_NAMEmysql, wordpress1WordPress 1 database namewordpress1
WP1_DB_USERmysql, wordpress1WordPress 1 database userwp1user
WP1_DB_PASSWORDmysql, wordpress1WordPress 1 database passwordAuto-generated
MARIADB_ROOT_PASSWORDmariadbMariaDB root passwordAuto-generated
WP2_DB_NAMEmariadb, wordpress2WordPress 2 database namewordpress2
WP2_DB_USERmariadb, wordpress2WordPress 2 database userwp2user
WP2_DB_PASSWORDmariadb, wordpress2WordPress 2 database passwordAuto-generated
SFTP1_PASSWORDsftp1SFTP Site 1 passwordAuto-generated
SFTP2_PASSWORDsftp2SFTP Site 2 passwordAuto-generated
WP1_TABLE_PREFIXwordpress1Table prefix Site 1wp1_
WP2_TABLE_PREFIXwordpress2Table prefix Site 2wp2_
PMA1_URLphpmyadmin1PHPMyAdmin MySQL URLhttp://localhost/pma1/
PMA2_URLphpmyadmin2PHPMyAdmin MariaDB URLhttp://localhost/pma2/

Docker Volume Reference

Volume NameMount PointContainer(s)Purpose
wordpress1_data/var/www/htmlwordpress1, sftp1WordPress 1 files
wordpress2_data/var/www/htmlwordpress2, sftp2WordPress 2 files
mysql_data/var/lib/mysqlmysqlMySQL database
mariadb_data/var/lib/mysqlmariadbMariaDB database
-/var/www/site1nginxWordPress 1 files (ro)
-/var/www/site2nginxWordPress 2 files (ro)

Port Reference

PortServiceProtocolAccess
80NGINXHTTPPublic
443NGINXHTTPSPublic
2221SFTP1SSHPublic
2222SFTP2SSHPublic
9000WordPress1FastCGIInternal
9000WordPress2FastCGIInternal
3306MySQLMySQLInternal
3306MariaDBMySQLInternal
80PHPMyAdmin1HTTPInternal
80PHPMyAdmin2HTTPInternal

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/DirectoryPermissionOwnerPurpose
.env600userEnvironment variables
.passwords600userPassword reference
sftp//ssh_host_600userSSH host keys
setup.sh755userSetup script
scripts/*.sh755userUtility scripts