This note documents how TLS was issued, configured, and made fully automatic for a WordPress site running on a single Ubuntu VM with Docker, Nginx, PHP-FPM, and MariaDB.
The goal was boring, predictable HTTPS โ no load balancers, no Front Door, no App Service magic.
Architecture Context
- Host: Azure Ubuntu VM (public IP)
- Web server: Nginx (Docker container)
- App: WordPress (PHP-FPM container)
- DB: MariaDB (container)
- TLS: Letโs Encrypt via Certbot (host-level)
- DNS: Azure DNS โ VM public IP
- Ports:
- 80 โ HTTP (redirect + ACME challenge)
- 443 โ HTTPS
1. Certificate Issuance (Initial)
Certbot was installed on the VM (host), not inside Docker.
Initial issuance was done using standalone mode (acceptable for first issuance):
sudo certbot certonly \
--standalone \
-d shahzadblog.com
This required:
- Port 80 temporarily free
- Docker/nginx stopped during issuance
Resulting certs live at:
/etc/letsencrypt/live/shahzadblog.com/
โโโ fullchain.pem
โโโ privkey.pem
2. Nginx TLS Configuration (Docker)
Nginx runs in Docker and mounts the host cert directory read-only.
Docker Compose (nginx excerpt)
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./wordpress:/var/www/html
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- /etc/letsencrypt:/etc/letsencrypt:ro
Nginx config (key points)
- Explicit HTTP โ HTTPS redirect
- TLS configured with Letโs Encrypt certs
- HTTP left available only for ACME challenges
# HTTP (ACME + redirect)
server {
listen 80;
server_name shahzadblog.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
allow all;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS
server {
listen 443 ssl;
http2 on;
server_name shahzadblog.com;
ssl_certificate /etc/letsencrypt/live/shahzadblog.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shahzadblog.com/privkey.pem;
root /var/www/html;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
fastcgi_pass wordpress:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
3. Why Standalone Renewal Failed
Certbot auto-renew initially failed with:
Could not bind TCP port 80
Reason:
- Docker/nginx already listening on port 80
- Standalone renewal always tries to bind port 80
This is expected behavior.
4. Switching to Webroot Renewal (Correct Fix)
Instead of stopping Docker every 60โ90 days, renewal was switched to webroot mode.
Key Insight
Certbot (host) and Nginx (container) must point to the same physical directory.
- Nginx serves:
~/wp-docker/wordpress โ /var/www/html (container) - Certbot must write challenges into:
~/wp-docker/wordpress/.well-known/acme-challenge
5. Renewal Config Fix (Critical Step)
Edit the renewal file:
sudo nano /etc/letsencrypt/renewal/shahzadblog.com.conf
Change:
authenticator = standalone
To:
authenticator = webroot
webroot_path = /home/azureuser/wp-docker/wordpress
โ ๏ธ Do not use
/var/www/htmlhere โ that path exists only inside Docker.
6. Filesystem Permissions
Because Docker created WordPress files as root, the ACME path had to be created with sudo:
sudo mkdir -p /home/azureuser/wp-docker/wordpress/.well-known/acme-challenge
sudo chmod -R 755 /home/azureuser/wp-docker/wordpress/.well-known
Validation test:
echo test | sudo tee /home/azureuser/wp-docker/wordpress/.well-known/acme-challenge/test.txt
curl http://shahzadblog.com/.well-known/acme-challenge/test.txt
Expected output:
test
7. Final Renewal Test (Success Condition)
sudo certbot renew --dry-run
Success message:
Congratulations, all simulated renewals succeeded!
At this point:
- Certbot timer is active
- Docker/nginx stays running
- No port conflicts
- No manual intervention required
Final State (What โDoneโ Looks Like)
- ๐ HTTPS works in all browsers
- ๐ Cert auto-renews in background
- ๐ณ Docker untouched during renewals
- ๐ธ No additional Azure services
- ๐ง Minimal moving parts
Key Lessons
- Standalone mode is fine for first issuance, not renewal
- In Docker setups, filesystem alignment matters more than ports
- Webroot renewal is the simplest long-term option
- Donโt fight permissions โ use
sudointentionally - โSimple & boringโ scales better than clever abstractions
This setup is intentionally non-enterprise, low-cost, and stable โ exactly what a long-running personal site needs.


