A journey from concept to production: Building a self-hosted WordPress website with Docker
Introduction
When I decided to build my own self-hosted website and email infrastructure, I knew I wanted complete control over my data, flexibility to customize everything, and a learning experience that would deepen my understanding of modern web technologies. This is the story of building BlueElysium – a fully containerized, production-ready web and mail server.
In this first part, I’ll walk you through building the web server foundation: WordPress, MySQL, Nginx reverse proxy, and automated SSL certificate management.
The Vision
Goal: Create a self-hosted WordPress website with:
- Automated SSL certificate management
- Production-grade security
- Easy maintenance and updates
- Scalable architecture for future expansion
- Complete data ownership
Technology Stack:
- Docker & Docker Compose for containerization
- WordPress with PHP 8.2-FPM
- MySQL 8.0 for database
- Nginx as reverse proxy and web server
- Let’s Encrypt for SSL/TLS certificates via Certbot
Part 1: The Infrastructure Design
Why Docker?
I chose Docker for several compelling reasons:
- Isolation: Each service runs in its own container with defined resources
- Reproducibility: The entire stack can be rebuilt from docker-compose.yml
- Portability: Easy to migrate to another server if needed
- Updates: Update individual services without affecting others
- Scalability: Add new services (like email) without disrupting existing ones
Architecture Overview
Internet → Port 80 443 → Nginx (webserver) → WordPress (PHP-FPM) → MySQL
↓
Let's Encrypt (certbot)
The architecture consists of four core containers:
- MySQL Database – Backend storage for WordPress
- WordPress – The CMS with PHP-FPM
- Nginx (webserver) – Reverse proxy with SSL termination
- Certbot – Automated SSL certificate management
Part 2: Setting Up the Foundation
Step 1: Project Structure
I organized the project with persistent data separated from container definitions:
BlueElysium/
├── docker-compose.yml # Container orchestration
├── .env # Sensitive configuration
├── docker-data/ # Persistent data volumes
│ ├── wordpress/
│ │ └── html/ # WordPress files
│ ├── mysql/ # Database files
│ └── certbot/ # SSL certificates
└── nginx-conf/ # Nginx configuration
└── default.conf # Reverse proxy config
Step 2: The Database Container
MySQL is the heart of the WordPress installation. Here’s what I learned about configuring it:
Key Decisions:
- Used MySQL 8.0 for better performance and security features
- Created separate databases for WordPress and future mail server
- Configured for UTF-8mb4 to support all Unicode characters
- Set up health checks to ensure database is ready before WordPress starts
Important Configuration:
db:
image: mysql:8.0
container_name: db
restart: unless-stopped
env_file: .env
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_WPBE_DATABASE}
- MYSQL_USER=${MYSQL_WPBE_USER}
- MYSQL_PASSWORD=${MYSQL_WPBE_PASSWORD}
volumes:
- dbdata:/var/lib/mysql
- ./docker/provision/mysql/init:/docker-entrypoint-initdb.d
command: '--default-authentication-plugin=mysql_native_password'
#command: '--default-authentication-plugin=caching_sha256_password'
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 4
networks:
- app-network
Lesson Learned: Always use health checks! This prevents WordPress from trying to connect before MySQL is ready, eliminating those frustrating “Error establishing database connection” messages during startup.
Step 3: WordPress Container
WordPress runs as a separate container with PHP-FPM, communicating with Nginx via FastCGI.
Key Configurations:
wordpress:
depends_on:
db:
condition: service_healthy
image: wordpress:6.4.3-fpm-alpine
container_name: wordpress
restart: unless-stopped
env_file: .env
environment:
- WORDPRESS_DB_HOST=db:3306
- WORDPRESS_DB_USER=${MYSQL_WPBE_USER}
- WORDPRESS_DB_PASSWORD=${MYSQL_WPBE_PASSWORD}
- WORDPRESS_DB_NAME=${MYSQL_WPBE_DATABASE}
volumes:
- wordpress:/var/www/html
healthcheck:
test: ["CMD-SHELL", "php-fpm -t || exit 1"]
timeout: 10s
retries: 4
interval: 20s
networks:
- app-network
Why PHP-FPM?
- Better performance than Apache mod_php
- Separates web server (Nginx) from PHP processing
- More granular resource control
- Industry standard for production WordPress
Challenge: Getting WordPress and Nginx to communicate properly took some trial and error. The key was ensuring they shared the WordPress files volume and using the correct FastCGI parameters.
Step 4: Nginx Reverse Proxy
Nginx handles incoming HTTP/HTTPS requests and routes them to WordPress.
Configuration Highlights:
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name blueelysium.net www.blueelysium.net;
location ~ /.well-known/acme-challenge {
allow all;
root /var/www/html;
}
...
Critical Lessons:
- Shared Volumes: Nginx needs access to WordPress files for static assets
- FastCGI Path: Must use correct SCRIPT_FILENAME path
- Security Headers: Always include HSTS and other security headers
- HTTP/2: Significantly improves performance for modern browsers
Step 5: SSL Certificate Automation
Let’s Encrypt provides free SSL certificates, but they expire every 90 days. Automation is essential.
Certbot Container:
certbot:
image: certbot/certbot:latest
volumes:
- ./docker-data/certbot/conf:/etc/letsencrypt
- ./docker-data/certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
Initial Certificate Request:
docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email admin@blueelysium.net \
--agree-tos \
--no-eff-email \
-d blueelysium.net \
-d www.blueelysium.net
The Chicken-and-Egg Problem:
- Nginx needs certificates to start with SSL
- Certbot needs Nginx running to verify domain ownership
Solution:
- Start with HTTP-only Nginx configuration
- Request certificates
- Update Nginx config to use SSL
- Restart Nginx
Pro Tip: Use the webroot method instead of standalone. This allows Nginx to keep running during renewals.
Part 3: Environment Variables & Security
The .env File
Never hardcode secrets! I used environment variables for all sensitive data:
# Domain Configuration
DOMAIN=blueelysium.net
CERTBOT_EMAIL=admin@blueelysium.net
# MySQL Configuration
MYSQL_ROOT_PASSWORD=SecureRootPassword123!
MYSQL_WPBE_DATABASE=mydb
MYSQL_WPBE_USER=myuser
MYSQL_WPBE_PASSWORD=SecureWPPassword456!
Security Practices:
- Never commit .env to git – Add to .gitignore immediately
- Use strong passwords – 20+ characters, mixed case, numbers, symbols
- Different passwords – Each service gets unique credentials
- Backup .env securely – Store encrypted copy off-server
Firewall Configuration
I configured UFW (Uncomplicated Firewall) to allow only necessary ports:
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow 22/tcp # SSH
sudo ufw enable
Important: Configure SSH access BEFORE enabling the firewall!
Part 4: Deployment & Testing
First Deployment
# Start all services
docker compose up -d
# Check status
docker compose ps
# View logs
docker compose logs -f
What I Watched For:
- MySQL health check passing
- WordPress connecting to database
- Nginx starting without errors
- Certificates being issued successfully
Initial WordPress Setup
After containers were running, I accessed https://blueelysium.net and completed WordPress installation:
- Selected language
- Created admin account (strong password!)
- Set site title and tagline
- Configured permalink structure (Post name for SEO)
Performance Testing
I used several tools to verify everything was working optimally:
SSL/TLS Configuration:
# Test SSL
curl -I https://blueelysium.net
# Check certificate
openssl s_client -connect blueelysium.net:443 -servername blueelysium.net
Results:
- ✅ A+ rating on SSL Labs
- ✅ TLS 1.2 and 1.3 only
- ✅ Strong cipher suites
- ✅ HSTS enabled
Page Load Speed:
- Initial: ~2.5 seconds
- With caching plugins: ~800ms
- With CDN consideration for future
Part 5: Common Issues & Solutions
Issue 1: “Error Establishing Database Connection”
Problem: WordPress couldn’t connect to MySQL
Root Cause: WordPress container started before MySQL was ready
Solution: Added health check to database and depends_on with condition: service_healthy
Issue 2: 502 Bad Gateway
Problem: Nginx showed 502 error when accessing WordPress
Root Cause: FastCGI configuration incorrect
Solution:
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
Must use $document_root not hardcoded path.
Issue 3: Static Assets Not Loading
Problem: Images and CSS files returned 404
Root Cause: Nginx didn’t have access to WordPress files
Solution: Shared WordPress volume with Nginx:
webserver:
volumes:
- ./docker-data/wordpress/html:/var/www/html:ro
Issue 4: Certificate Renewal Failed
Problem: Let’s Encrypt couldn’t verify domain ownership
Root Cause: Nginx blocked /.well-known/acme-challenge/ path
Solution: Added specific location block for ACME challenges:
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
Part 6: Optimization & Fine-Tuning
WordPress Optimizations
Plugins Installed:
- WP Super Cache – Page caching for better performance
- Wordfence Security – Security scanning and firewall
- UpdraftPlus – Additional backup option
- Yoast SEO – Search engine optimization
Configuration Tweaks:
// wp-config.php additions
define('WP_CACHE', true);
define('COMPRESS_CSS', true);
define('COMPRESS_SCRIPTS', true);
define('CONCATENATE_SCRIPTS', true);
define('ENFORCE_GZIP', true);
Nginx Optimizations
Added caching headers for static assets:
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
MySQL Tuning
Adjusted MySQL configuration for better performance:
[mysqld]
innodb_buffer_pool_size = 512M
max_connections = 100
query_cache_size = 0
query_cache_type = 0
Part 7: Monitoring & Maintenance
Daily Monitoring
Quick Health Check:
# Check all containers running
docker compose ps
# Check logs for errors
docker compose logs --tail=100 | grep -i error
# Check disk space
df -h
Weekly Tasks
- Review WordPress updates – Check dashboard for plugin/theme updates
- Check SSL expiry – Certbot should auto-renew, but verify
- Review access logs – Check for unusual activity
- Database backup – Automated with weekly backup system
Monthly Tasks
- Update Docker images – Pull latest stable versions
- Security scan – Run Wordfence deep scan
- Performance review – Check page load times
- Backup verification – Test restore from backup
Lessons Learned
What Went Well
- Docker Compose: Made the entire stack manageable and reproducible
- Separation of Concerns: Each service in its own container simplified troubleshooting
- Environment Variables: Kept secrets out of version control
- Health Checks: Prevented startup race conditions
- Documentation: Writing everything down saved hours later
What I’d Do Differently
- Start with Backups: I added automated backups later; should have been day one
- Monitoring Earlier: Wished I’d set up monitoring before going live
- Staging Environment: Would have been useful for testing updates
- Better Logging: Should have configured centralized logging from the start
Unexpected Challenges
- FastCGI Configuration: Took several iterations to get right
- Volume Permissions: File ownership between containers required attention
- Certificate Initial Setup: The chicken-and-egg problem with SSL
- Network Between Containers: Understanding Docker networking took time
The Results
After several days of configuration, testing, and optimization:
✅ Performance:
- Page load time: < 1 second
- 99.9% uptime since launch
- A+ SSL rating
✅ Security:
- Strong TLS configuration
- Firewall configured
- Security plugins active
- Regular updates
✅ Maintainability:
- Single docker-compose command to update
- Automated SSL renewal
- Automated backups (weekly)
- Comprehensive documentation
What’s Next?
With the web server foundation solid, the next challenge was email. In Part 2, I’ll cover:
- Setting up a full-featured mail server with docker-mailserver
- Configuring SMTP, IMAP, DKIM, SPF, and DMARC
- Integrating PostfixAdmin for email account management
- Troubleshooting network and authentication issues
- Dealing with Fail2Ban blocking legitimate traffic
- The satisfying moment when the first email arrives
Resources & References
Official Documentation:
Helpful Tools:
- SSL Labs – Test SSL configuration
- GTmetrix – Performance testing
- Docker Hub – Container images
My Repository:
- Full
docker-compose.ymland configurations available - Detailed documentation in
docs/directory - Backup and restore scripts included
Conclusion
Building a self-hosted WordPress website with Docker was both challenging and incredibly rewarding. I now have:
- Complete control over my web presence
- Deep understanding of how modern web infrastructure works
- Scalable foundation for adding more services (like email!)
- Skills transferable to professional DevOps work
The best part? Everything is reproducible. If my server dies tomorrow, I can rebuild the entire stack from my docker-compose.yml and restore from backups.
Total time invested: ~3 days for initial setup + ongoing maintenance
Worth it? Absolutely. The learning experience alone was invaluable, and having complete ownership of my data is priceless.
In the next post, I’ll dive into the real challenge: building a production-ready mail server. Email is notoriously difficult to self-host, and I learned that the hard way…
Stay tuned for Part 2: Building the Mail Server – DKIM, Dovecot, and Debugging
Questions or feedback? Feel free to reach out!
Published: November 2025
Author: Frank @ BlueElysium
Series: Building a Self-Hosted Web & Mail Server
Part: 1 of 2