57.4 Project 4: WordPress with MySQL, nginx and free HTTPS from Let's Encrypt
Why: the problem
Suppose a Pune bakery, like a small Chitale-style shop, asks us for a blog and order page. WordPress powers a very large part of all websites, so it is the natural choice. A normal install needs Apache or nginx, PHP with the right extensions, MySQL, file permissions, and an SSL certificate that expires every 90 days. Each of these is a place where a manual setup breaks. With Docker we run four containers: MySQL for data, WordPress with PHP-FPM for the code, nginx in front, and certbot for the free certificate. Uploads and the database live in volumes, so we can update WordPress by changing one tag. The certificate renews by itself, and nginx reloads to use the new one. This is the same pattern that DigitalOcean and many hosting companies teach for production WordPress on one server. We first make it work on plain HTTP, then add HTTPS, then backups and updates.
What: every term in plain English
- WordPress — A popular website and blog system written in PHP. It stores posts in MySQL and files in wp-content.
- PHP-FPM — A PHP process manager. nginx sends PHP requests to it on port 9000. The wordpress:fpm image has it.
- Reverse proxy — nginx receives every request first and passes it to the right backend.
- Let's Encrypt — A free certificate authority. Certificates last 90 days and renew automatically.
- certbot — The tool that asks Let's Encrypt for a certificate. We run it as a container.
- Webroot challenge — Let's Encrypt checks a file under /.well-known/acme-challenge/ on our site to prove we own the domain.
- wp-content — The WordPress folder for themes, plugins and uploads. It lives in a named volume.
- Staging server — Let's Encrypt's test server. Use it first so mistakes do not hit the rate limit.
How: step by step
- Point the domain to the server. An A record like blog.example.com to the EC2 public IP.
- Write .env and compose.yaml. db, wordpress (fpm), nginx and certbot.
- Write the HTTP nginx config. Serves PHP through FPM and the ACME challenge folder.
- Start on HTTP and install WordPress. Open http://domain and finish the setup page.
- Get the certificate. Run certbot with the webroot method.
- Switch nginx to HTTPS. Add the 443 server block and redirect 80 to 443.
- Renew, back up, update and clean. certbot renew, mysqldump, new image tag, down -v.
Architecture
Folder layout
wp/
.env
compose.yaml
nginx/
conf.d/
wordpress.conf
backups/ (created by the backup steps)
Example 1: WordPress on plain HTTP first
Goal. WordPress on plain HTTP first.
.env
DOMAIN=blog.example.com
EMAIL=admin@example.com
DB_NAME=wordpress
DB_USER=wp
DB_PASSWORD=change-me-wp
DB_ROOT_PASSWORD=change-me-root
compose.yaml
services:
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${DB_ROOT_PASSWORD}"]
interval: 5s
retries: 20
restart: unless-stopped
wordpress:
image: wordpress:6.6-php8.3-fpm-alpine
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
volumes:
- wp-html:/var/www/html
depends_on:
db:
condition: service_healthy
restart: unless-stopped
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- wp-html:/var/www/html:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certbot-www:/var/www/certbot:ro
- certs:/etc/letsencrypt:ro
depends_on:
- wordpress
restart: unless-stopped
certbot:
image: certbot/certbot:v2.11.0
volumes:
- certbot-www:/var/www/certbot
- certs:/etc/letsencrypt
profiles: ["tools"]
volumes:
db-data:
wp-html:
certbot-www:
certs:
nginx/conf.d/wordpress.conf (HTTP version)
server {
listen 80;
server_name blog.example.com;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass wordpress:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
What each line does
wordpress:6.6-php8.3-fpm-alpine— WordPress with PHP-FPM, no web server inside. Pinned version.WORDPRESS_DB_HOST: db:3306— WordPress reaches MySQL by service name.wp-html volume— WordPress code and uploads. nginx mounts the same volume read-only to serve images and CSS.fastcgi_pass wordpress:9000— PHP files go to the FPM container on port 9000.try_files ... /index.php?$args— Pretty links like /2026/10/diwali-sweets go to WordPress.client_max_body_size 64m— Allows bigger image uploads.acme-challenge location— Serves the files certbot writes, for the domain check.profiles: tools— certbot does not start with up. We run it only when we need it.
Green output
[+] Running 4/4
✔ Container wp-db-1 Healthy
✔ Container wp-wordpress-1 Started
✔ Container wp-nginx-1 Started
$ curl -sI http://blog.example.com/wp-admin/install.php | head -1
HTTP/1.1 200 OK
Expected result. Open http://blog.example.com, choose the language, set the site title and an admin user with a strong password. WordPress is live on HTTP.
Example 2: Get a free certificate and switch to HTTPS
Goal. Get a free certificate and switch to HTTPS.
Commands: test with staging, then real
docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
-d blog.example.com --email admin@example.com --agree-tos --no-eff-email --staging
docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
-d blog.example.com --email admin@example.com --agree-tos --no-eff-email --force-renewal
nginx/conf.d/wordpress.conf (HTTPS version)
server {
listen 80;
server_name blog.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name blog.example.com;
ssl_certificate /etc/letsencrypt/live/blog.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
root /var/www/html;
index index.php;
client_max_body_size 64m;
location / { try_files $uri $uri/ /index.php?$args; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass wordpress:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS on;
}
}
Reload nginx
docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload
curl -sI http://blog.example.com | grep -i location
curl -sI https://blog.example.com | head -1
What each line does
run --rm certbot— Runs certbot once and removes the container.--webroot -w /var/www/certbot— certbot writes the challenge file into the shared volume; nginx serves it.--staging— Test certificate first. Not trusted by browsers, but no rate limit.--force-renewal— Replaces the staging certificate with a real one.return 301 https://— Every HTTP visit moves to HTTPS.fullchain.pem / privkey.pem— The certificate and key, read from the certs volume.Strict-Transport-Security— Tells browsers to use HTTPS for one year.fastcgi_param HTTPS on— WordPress knows the visit is HTTPS, so it does not loop redirects.
Green output
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/blog.example.com/fullchain.pem
This certificate expires on 2027-01-23.
nginx: configuration file /etc/nginx/nginx.conf test is successful
Location: https://blog.example.com/
HTTP/2 200
Expected result. The browser shows the lock icon. In WordPress Settings, General, set both addresses to https://blog.example.com.
Example 3: Automatic renewal, backups and updates
Goal. Automatic renewal, backups and updates.
Crontab line on the host (crontab -e)
0 3 * * * cd /home/ubuntu/wp && docker compose run --rm certbot renew --quiet && docker compose exec nginx nginx -s reload
Commands
docker compose run --rm certbot renew --dry-run
mkdir -p backups
docker compose exec -T db mysqldump -uroot -p"change-me-root" --single-transaction wordpress > backups/wp-$(date +%F).sql
docker run --rm -v wp_wp-html:/data:ro -v "$PWD/backups":/backup alpine tar czf /backup/wp-html-$(date +%F).tgz -C /data .
ls -lh backups
# update WordPress: change the tag in compose.yaml, then
docker compose pull wordpress
docker compose up -d wordpress
docker compose exec wordpress php -r 'include "wp-includes/version.php"; echo $wp_version, PHP_EOL;'
What each line does
certbot renew— Renews only certificates that expire in less than 30 days.--dry-run— Tests renewal against staging without changing anything.nginx -s reload— nginx picks up the new certificate without dropping visitors.mysqldump --single-transaction— Consistent database backup.alpine tar czf from the volume— Backs up themes, plugins and uploads from the wp-html volume.pull then up -d wordpress— Updates only the WordPress container. Data stays in volumes.
Green output
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/blog.example.com/fullchain.pem (success)
-rw-r--r-- 1 ubuntu ubuntu 1.4M wp-2026-10-25.sql
-rw-r--r-- 1 ubuntu ubuntu 38M wp-html-2026-10-25.tgz
6.6.2
Expected result. Certificates renew every night if needed, backups are files on the host (copy them to S3 later), and updates are one tag change.
Every command, step by step (plain docker and Compose)
-
Point the domain
dig +short blog.example.comWhat it does: Must print the EC2 public IP before certbot can work.
13.233.10.20 -
Open ports 80 and 443
aws ec2 authorize-security-group-ingress --group-id sg-0123456789abcdef0 --ip-permissions IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0}] IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0}]What it does: Let's Encrypt and visitors need both ports.
"Return": true -
Make folders
mkdir -p wp/nginx/conf.d && cd wpWhat it does: Project folder with the nginx config folder.
(no output) -
Start on HTTP
docker compose up -dWhat it does: Starts db, wordpress and nginx. certbot is in the tools profile and stays off.
Container wp-db-1 Healthy Container wp-nginx-1 Started -
Check the PHP path
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/wp-admin/install.phpWhat it does: 200 means nginx talks to PHP-FPM.
200 -
Check the challenge path
docker compose exec nginx sh -c 'ls /var/www/certbot' curl -s -o /dev/null -w '%{http_code}\n' http://blog.example.com/.well-known/acme-challenge/testWhat it does: 404 here is fine: the folder works, the file just does not exist yet.
404 -
Staging certificate
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d blog.example.com --email admin@example.com --agree-tos --no-eff-email --stagingWhat it does: Proves the setup without using the real rate limit.
Successfully received certificate. -
Real certificate
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d blog.example.com --email admin@example.com --agree-tos --no-eff-email --force-renewalWhat it does: Gets a trusted certificate.
This certificate expires on 2027-01-23. -
Switch to the HTTPS config and reload
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reloadWhat it does: Test before reload so a typo never takes the site down.
signal process started -
Check the certificate
echo | openssl s_client -connect blog.example.com:443 -servername blog.example.com 2>/dev/null | openssl x509 -noout -issuer -enddateWhat it does: Shows Let's Encrypt as issuer and the end date.
issuer=C = US, O = Let's Encrypt, CN = R11 notAfter=Jan 23 09:12:44 2027 GMT -
Watch the logs
docker compose logs -f --tail 20 nginx wordpressWhat it does: See visits reach nginx and PHP.
nginx-1 | "GET / HTTP/2.0" 200 -
Clean up
docker compose down -vWhat it does: Removes the site, the database and the certificates. Use only on practice servers.
Volume wp_db-data Removed Volume wp_wp-html Removed Volume wp_certs Removed
When it is red 1
Certbot failed to authenticate some domains (authenticator: webroot).
Domain: blog.example.com
Type: unauthorized
Detail: Invalid response from http://blog.example.com/.well-known/acme-challenge/...: 404
Cause. nginx does not serve the challenge folder, or the domain points to another server.
Fix, step by step
- Check dig +short blog.example.com shows this server.
- Check the acme-challenge location and that certbot-www is mounted in both nginx and certbot.
- Use --staging until it works.
When it is red 2
nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/blog.example.com/fullchain.pem": No such file or directory
Cause. We switched to the HTTPS config before getting the certificate.
Fix, step by step
- Put back the HTTP config and reload.
- Get the certificate first.
- Then switch to the HTTPS config.
When it is red 3
Error establishing a database connection
Cause. WordPress cannot reach MySQL or the password is wrong.
Fix, step by step
- Check docker compose ps shows db healthy.
- Check WORDPRESS_DB_HOST is db:3306 and passwords match .env.
- If you changed .env after the first start, the old password is still in the volume.
When it is red 4
ERR_TOO_MANY_REDIRECTS
Cause. WordPress thinks the visit is HTTP while nginx forces HTTPS.
Fix, step by step
- Add fastcgi_param HTTPS on in the 443 block.
- Set both site addresses to https:// in Settings, General.
- Clear the browser cache and try again.
Practice tasks
Easy Task 1: HTTP first
Why: Start simple.
What to do: Run WordPress on localhost over HTTP and finish the install page.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose up -d
curl -sI http://localhost/wp-admin/install.php | head -1Easy Task 2: See containers
Why: Know the parts.
What to do: List the containers and their ports.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose ps --format '{{.Service}} {{.Ports}}'Easy Task 3: Logs
Why: Debug.
What to do: Follow nginx logs while you open a post.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose logs -f nginxMedium Task 4: Staging cert
Why: Safe test.
What to do: Get a staging certificate.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d YOUR_DOMAIN --email YOU@example.com --agree-tos --no-eff-email --stagingMedium Task 5: Real HTTPS
Why: Production.
What to do: Get the real certificate and switch config.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose run --rm certbot certonly ... --force-renewal
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reloadMedium Task 6: Renew test
Why: Automation.
What to do: Run a dry-run renewal.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose run --rm certbot renew --dry-runMedium Task 7: Backup
Why: Safety.
What to do: Back up the database and wp-html.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose exec -T db mysqldump -uroot -pchange-me-root --single-transaction wordpress > wp.sql
docker run --rm -v wp_wp-html:/data:ro -v $PWD:/b alpine tar czf /b/html.tgz -C /data .Hard Task 8: Upgrade
Why: Updates.
What to do: Move to a newer WordPress tag and check the version.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose pull wordpress && docker compose up -d wordpressHard Task 9: Upload limit
Why: Tuning.
What to do: Allow 128 MB uploads in nginx and PHP.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
client_max_body_size 128m;
PHP: mount a uploads.ini with upload_max_filesize=128M and post_max_size=128M into /usr/local/etc/php/conf.d/Easy Task 10: Clean
Why: Habit.
What to do: Remove the practice site completely.
- Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
- For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
- Run each step and compare with the solution.
Solution
docker compose --profile tools down -vHomework: try at home
Add a second site (shop.example.com) to the same server with its own WordPress and database containers, its own certificate, and one shared nginx. Back up both sites.
Recap
- WordPress runs as PHP-FPM behind nginx, with MySQL beside it.
- Code and uploads live in the wp-html volume.
- certbot uses the webroot method through a shared volume.
- Use staging first, then the real certificate.
- Renew with a nightly cron and reload nginx.
- Back up the database and wp-html, and update by changing the tag.
FAQ
Q. Can I use the wordpress:apache image instead?
Yes, it has Apache inside and needs no nginx. We use FPM + nginx because it is the common production pattern and handles TLS in one place.
Q. Do I need a domain for HTTPS?
Yes, Let's Encrypt gives certificates for domain names, not bare IP addresses.
Q. Is Traefik or Caddy easier?
Both get certificates automatically. Chapter 58 shows Traefik. Learn certbot first so you understand what they do.
Q. Where do I keep backups?
Copy them off the server, for example to Amazon S3 with aws s3 cp.
Q. How do I install plugins?
From the WordPress admin page as usual. They are saved in the wp-html volume.
Q. Can I move the site to a new server?
Yes: copy the database dump and wp-html archive, start the same compose file, restore both.
Interview questions
-
How would you deploy WordPress with Docker in production?
Model answer: MySQL with a volume, wordpress:fpm with a volume, nginx in front with TLS, certbot or Traefik for certificates, backups to S3, pinned tags.
-
How do you automate Let's Encrypt renewal with containers?
Model answer: A scheduled certbot renew (cron or a loop container) writing to a shared volume, then nginx reload.
-
Why FPM and nginx instead of Apache?
Model answer: nginx serves static files efficiently and terminates TLS; PHP-FPM handles only PHP.
-
How do you avoid redirect loops behind a TLS proxy?
Model answer: Pass HTTPS on or X-Forwarded-Proto so the app knows the original scheme.
-
What is the risk of hitting Let's Encrypt limits and how to avoid it?
Model answer: Too many failed or duplicate requests block issuance; test with --staging first.
Quick quiz
Q1. Why does nginx mount the WordPress volume read-only?
nginx only needs to read static files like images and CSS; PHP-FPM writes uploads.
Q2. Why use the Let's Encrypt staging server first?
It has no strict rate limit, so failed tries do not block the real certificate.
Q3. How does certbot prove we own the domain here?
It writes a file into the webroot volume and Let's Encrypt fetches it over HTTP.
Q4. How often do Let's Encrypt certificates expire?
Every 90 days; certbot renew renews those within 30 days of expiry.
Q5. What does profiles: tools do for certbot?
It keeps certbot from starting with docker compose up; we run it on demand.
Samajla ka?
Revision card
Interview card