Ravindra BagaleCourses & study guides मराठी

Chapter 57: Docker Projects Part 1: Websites, Databases, LAMP, LEMP, WordPress, 3-Tier and Every Language with MySQL and MongoDB

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.
Point the domain to the server. Write .env and compose.yaml. Write the HTTP nginx config. Start on HTTP and install WordPress. Get the certificate. Switch nginx to HTTPS. Renew, back up, update and clean.
Visitor to nginx to PHP-FPM WordPress to MySQL

How: step by step

  1. Point the domain to the server. An A record like blog.example.com to the EC2 public IP.
  2. Write .env and compose.yaml. db, wordpress (fpm), nginx and certbot.
  3. Write the HTTP nginx config. Serves PHP through FPM and the ACME challenge folder.
  4. Start on HTTP and install WordPress. Open http://domain and finish the setup page.
  5. Get the certificate. Run certbot with the webroot method.
  6. Switch nginx to HTTPS. Add the 443 server block and redirect 80 to 443.
  7. Renew, back up, update and clean. certbot renew, mysqldump, new image tag, down -v.

Architecture

Visitor: https://blog.example.com nginx :80 and :443 (TLS, redirect, static files) wordpress:fpm :9000 (PHP code, uploads) MySQL 8.4 (posts, users) Volumes: db-data, wp-html, certs, certbot-www certbot (on demand + nightly cron)
WordPress with HTTPS 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)

  1. Point the domain

    dig +short blog.example.com
    

    What it does: Must print the EC2 public IP before certbot can work.

    13.233.10.20
    
  2. 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
    
  3. Make folders

    mkdir -p wp/nginx/conf.d && cd wp
    

    What it does: Project folder with the nginx config folder.

    (no output)
    
  4. Start on HTTP

    docker compose up -d
    

    What 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
    
  5. Check the PHP path

    curl -s -o /dev/null -w '%{http_code}\n' http://localhost/wp-admin/install.php
    

    What it does: 200 means nginx talks to PHP-FPM.

    200
    
  6. 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/test
    

    What it does: 404 here is fine: the folder works, the file just does not exist yet.

    404
    
  7. 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 --staging
    

    What it does: Proves the setup without using the real rate limit.

    Successfully received certificate.
    
  8. 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-renewal
    

    What it does: Gets a trusted certificate.

    This certificate expires on 2027-01-23.
    
  9. Switch to the HTTPS config and reload

    docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload
    

    What it does: Test before reload so a typo never takes the site down.

    signal process started
    
  10. Check the certificate

    echo | openssl s_client -connect blog.example.com:443 -servername blog.example.com 2>/dev/null | openssl x509 -noout -issuer -enddate
    

    What 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
    
  11. Watch the logs

    docker compose logs -f --tail 20 nginx wordpress
    

    What it does: See visits reach nginx and PHP.

    nginx-1 | "GET / HTTP/2.0" 200
    
  12. Clean up

    docker compose down -v
    

    What 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

  1. Check dig +short blog.example.com shows this server.
  2. Check the acme-challenge location and that certbot-www is mounted in both nginx and certbot.
  3. 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

  1. Put back the HTTP config and reload.
  2. Get the certificate first.
  3. 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

  1. Check docker compose ps shows db healthy.
  2. Check WORDPRESS_DB_HOST is db:3306 and passwords match .env.
  3. 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

  1. Add fastcgi_param HTTPS on in the 443 block.
  2. Set both site addresses to https:// in Settings, General.
  3. 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.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. Run each step and compare with the solution.
Solution
docker compose up -d
curl -sI http://localhost/wp-admin/install.php | head -1

Easy Task 2: See containers

Why: Know the parts.

What to do: List the containers and their ports.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. 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.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. Run each step and compare with the solution.
Solution
docker compose logs -f nginx

Medium Task 4: Staging cert

Why: Safe test.

What to do: Get a staging certificate.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. 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 --staging

Medium Task 5: Real HTTPS

Why: Production.

What to do: Get the real certificate and switch config.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. 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 reload

Medium Task 6: Renew test

Why: Automation.

What to do: Run a dry-run renewal.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. Run each step and compare with the solution.
Solution
docker compose run --rm certbot renew --dry-run

Medium Task 7: Backup

Why: Safety.

What to do: Back up the database and wp-html.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. 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.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. Run each step and compare with the solution.
Solution
docker compose pull wordpress && docker compose up -d wordpress

Hard Task 9: Upload limit

Why: Tuning.

What to do: Allow 128 MB uploads in nginx and PHP.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. 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.

How:
  1. Use an Ubuntu VM with a public IP (EC2 t3.small is fine) and Docker with Compose.
  2. For HTTPS you need a domain name whose A record points to the VM. Without a domain, do the HTTP part on localhost.
  3. Run each step and compare with the solution.
Solution
docker compose --profile tools down -v

Homework: 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

  1. 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.

  2. 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.

  3. Why FPM and nginx instead of Apache?

    Model answer: nginx serves static files efficiently and terminates TLS; PHP-FPM handles only PHP.

  4. 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.

  5. 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?

Ravi Sir's Cards for this lesson

मराठी cards