*Does this sound familiar? Manually copying SSL certificates into Docker containers every few months, setting calendar reminders, and inevitably forgetting until the site goes down with an expired certificate.
If yes, then you might find this article helpful.
After wrestling with this setup for way too long, I finally cracked the code for fully automated SSL renewal with Docker. Here's everything I learned, documented down to the last semicolon.*
Manual certificate copying into containers every 3 months
Port conflicts when certbot tries to renew (port 80 already in use)
Silent failures where renewal appears to work but doesn't actually update your running containers
Downtime when certificates expire because you forgot to renew them
Sound familiar? Let's fix this once and for all.
The Solution Overview
We'll create a bulletproof system that:
Mounts SSL certificates directly into containers via Docker volumes
Uses certbot hooks to automatically handle container restarts during renewal
Handles port conflicts gracefully by temporarily stopping services
Logs everything so you know exactly what's happening
Runs completely unattended - set it and forget it
Prerequisites
Docker and Docker Compose installed
Domain pointing to your server
Existing SSL certificates from Let's Encrypt (or ability to obtain them)
nginx running in a Docker container
nginx running in a Docker container
Note: If you want to avoid the complexity of SSL certificate management entirely, consider using Cloudflare Tunnel which handles SSL automatically.
Step 1: Configure Docker Compose for SSL Certificate Access
The foundation of our solution is mounting the Let's Encrypt certificates directly into our containers. No more manual copying!
Update your docker-compose.yml
yaml
version:'3.8'services:frontend:build:./frontendports:-"80:80"-"443:443"volumes:-.:/workspace:cached-frontend-node-modules:/workspace/frontend/node_modules# 🔑 This is the magic line - mount certificates read-only-/etc/letsencrypt:/etc/letsencrypt:rorestart:unless-stoppedbackend:build:./backendports:-"8000:8000"volumes:-.:/workspace:cached# Mount certificates in backend too if needed-/etc/letsencrypt:/etc/letsencrypt:rorestart:unless-stoppedredis:image:redis:alpinerestart:unless-stoppedvolumes:frontend-node-modules:
# Stop current containers
docker-compose down
# Start with new volume mounts
docker-compose up -d
Step 2: Verify Certificate Access in Containers
Before we set up automation, let's make sure our containers can actually see the certificates.
Check certificate files are mounted
bash
# Get your nginx container ID
docker ps
# Access the container shell (replace CONTAINER_ID with actual ID)
docker exec -it CONTAINER_ID sh
# Verify certificates are accessiblels -la /etc/letsencrypt/live/yourdomain.com/
You should see output like this:
bash
total 12
drwxr-xr-x 2 root root 4096 May 29 16:40 .
drwxr-xr-x 3 root root 4096 May 29 16:40 ..
-rwxr-xr-x 1 root root 692 May 29 16:40 README
lrwxrwxrwx 1 root root 48 May 29 16:40 cert.pem -> ../../archive/yourdomain.com/cert1.pem
lrwxrwxrwx 1 root root 49 May 29 16:40 chain.pem -> ../../archive/yourdomain.com/chain1.pem
lrwxrwxrwx 1 root root 53 May 29 16:40 fullchain.pem -> ../../archive/yourdomain.com/fullchain1.pem
lrwxrwxrwx 1 root root 51 May 29 16:40 privkey.pem -> ../../archive/yourdomain.com/privkey1.pem
Verify symlinks work correctly
bash
# Check that the actual certificate files existls -la /etc/letsencrypt/archive/yourdomain.com/
# Test reading the certificatecat /etc/letsencrypt/live/yourdomain.com/fullchain.pem | head -5
Test nginx configuration
bash
# Inside the container, test nginx config
nginx -t
# Should output:# nginx: the configuration file /etc/nginx/nginx.conf syntax is ok# nginx: configuration file /etc/nginx/nginx.conf test is successful
If everything checks out, exit the container:
bash
exit
Step 3: Understanding the Port 80 Conflict Issue
Here's the critical issue most tutorials don't mention: certbot needs port 80 for the HTTP challenge, but your nginx container is already using it.
The Silent Failure Problem
When you run certbot renew, you might see this error:
bash
Failed to renew certificate yourdomain.com with error: Could not bind TCP port 80 because it is already in use by another process on this system (such as a web server). Please stop the program in question and then try again.
This is why many people think their auto-renewal is working (because certbot renew returns success for certificates that don't need renewal yet), but when renewal time actually comes, it fails silently.
Testing the Problem
Try this to see the issue:
bash
# This will fail if nginx is running on port 80sudo certbot renew --cert-name yourdomain.com --force-renewal
You'll get the port binding error. This is exactly what we need to solve.
Step 4: Create the Certbot Deploy Hook
Certbot hooks are scripts that run at different stages of the renewal process. We'll use a deploy hook that runs after successful certificate renewal to restart our Docker containers.
This is crucial! Edit the script and replace /path/to/your/docker-compose-directory with your actual path:
bash
sudo nano /etc/letsencrypt/renewal-hooks/deploy/restart-docker.sh
# Change this line:
DOCKER_COMPOSE_PATH="/path/to/your/docker-compose-directory"# To your actual path, for example:
DOCKER_COMPOSE_PATH="/root/my-awesome-app"# or
DOCKER_COMPOSE_PATH="/home/ubuntu/my-project"
Step 5: Handle the Port 80 Conflict with Pre/Post Hooks
To solve the port binding issue, we need to stop our containers before renewal and start them after. We'll use pre and post hooks for this.
#!/bin/bash# Post-hook: Start Docker containers after certificate renewalset -e
# Configuration - UPDATE THIS PATH
DOCKER_COMPOSE_PATH="/path/to/your/docker-compose-directory"# ⚠️ CHANGE THIS
LOG_FILE="/var/log/letsencrypt/post-hook.log"log_message() {
echo"$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
log_message "=== Post-hook started - Starting containers after renewal ==="cd"$DOCKER_COMPOSE_PATH"# Determine docker compose commandifcommand -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"elifcommand -v docker >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"else
log_message "ERROR: Docker compose not found"exit 1
fi# Start containers
log_message "Starting Docker containers..."if$DOCKER_COMPOSE up -d; then
log_message "✅ Containers started successfully"# Wait for services to be fully ready
log_message "Waiting for services to initialize..."sleep 15
# Verify HTTPS is workingif curl -f -s -I https://$(hostname -f) >/dev/null 2>&1; then
log_message "✅ HTTPS verification successful"else
log_message "⚠️ HTTPS verification failed - services may still be starting"fielse
log_message "❌ ERROR: Failed to start containers"exit 1
fi
log_message "=== Post-hook completed successfully ==="
Make hooks executable and update paths
bash
# Make executablesudochmod +x /etc/letsencrypt/renewal-hooks/pre/stop-docker.sh
sudochmod +x /etc/letsencrypt/renewal-hooks/post/start-docker.sh
# Update paths in both filessudo nano /etc/letsencrypt/renewal-hooks/pre/stop-docker.sh
sudo nano /etc/letsencrypt/renewal-hooks/post/start-docker.sh
Step 6: Test the Complete Setup
Now let's test our entire renewal system end-to-end.
Test hooks individually
bash
# Test pre-hook (this will stop your containers!)sudo /etc/letsencrypt/renewal-hooks/pre/stop-docker.sh
# Check if containers are stopped
docker ps
# Test post-hook (this will start them again)sudo /etc/letsencrypt/renewal-hooks/post/start-docker.sh
# Check if containers are running
docker ps
Test dry-run renewal
bash
# Test the complete renewal process without actually renewingsudo certbot renew --cert-name yourdomain.com --dry-run
You should see output like:
code
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/yourdomain.com/fullchain.pem (success)
Test actual renewal (forced)
Warning: This will temporarily take your site offline for a few seconds.
bash
# Force an actual renewal to test everythingsudo certbot renew --cert-name yourdomain.com --force-renewal
Check the logs
bash
# Check all hook logssudotail -20 /var/log/letsencrypt/pre-hook.log
sudotail -20 /var/log/letsencrypt/post-hook.log
sudotail -20 /var/log/letsencrypt/deploy-hook.log
# Check general certbot logsudotail -20 /var/log/letsencrypt/letsencrypt.log
Verify HTTPS is working
bash
# Test your site
curl -I https://yourdomain.com
# Check certificate expiration
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com </dev/null 2>/dev/null | openssl x509 -noout -dates
Step 7: Set Up Automatic Renewal
The final step is ensuring certbot runs automatically. Most modern systems use systemd timers, but you can also use cron.
Check if systemd timer is active
bash
# Check if certbot timer is enabledsudo systemctl status certbot.timer
# If not active, enable itsudo systemctl enable certbot.timer
sudo systemctl start certbot.timer
Alternative: Cron setup
If you prefer cron or don't have systemd timers:
bash
# Edit root's crontabsudo crontab -e
# Add this line to run twice daily at random minutes
23 2,14 * * * /usr/bin/certbot renew --quiet
Test automatic renewal timing
bash
# See when certbot will next runsudo systemctl list-timers certbot.timer
# Or check certificate expiration datessudo certbot certificates
Monitoring and Troubleshooting
Essential log files to monitor
bash
# Certbot main logsudotail -f /var/log/letsencrypt/letsencrypt.log
# Your custom hook logssudotail -f /var/log/letsencrypt/pre-hook.log
sudotail -f /var/log/letsencrypt/post-hook.log
sudotail -f /var/log/letsencrypt/deploy-hook.log
# System journal for certbot servicesudo journalctl -u certbot.service -f
Common issues and solutions
Issue: "nginx: command not found" in deploy hook
bash
# The nginx binary might not be in PATH inside the container# Update the hook to use full path or docker exec differently
docker exec CONTAINER_NAME /usr/sbin/nginx -s reload
Issue: Containers don't restart properly
bash
# Check container status
docker ps -a
# Check compose logs
docker-compose logs
# Manual restartcd /your/compose/path && docker-compose restart
Issue: Certificates don't update in containers
bash
# Verify volume mount is working
docker exec CONTAINER_ID ls -la /etc/letsencrypt/live/
# Force container recreation
docker-compose down && docker-compose up -d
Setting up alerts
Create a simple monitoring script:
bash
sudo nano /usr/local/bin/check-ssl-expiry.sh
bash
#!/bin/bash
DOMAIN="yourdomain.com"
DAYS_WARNING=14
# Get certificate expiration date
EXPIRY=$(openssl s_client -connect $DOMAIN:443 -servername $DOMAIN </dev/null 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( ($EXPIRY_EPOCH - $NOW_EPOCH) / 86400 ))
if [ $DAYS_LEFT -lt $DAYS_WARNING ]; thenecho"WARNING: SSL certificate for $DOMAIN expires in $DAYS_LEFT days!"# Add your notification method here (email, Slack, etc.)fi
Conclusion
Congratulations! You now have a bulletproof SSL certificate renewal system that:
✅ Automatically renews certificates before they expire
✅ Handles port conflicts by stopping/starting containers
✅ Updates running containers immediately after renewal
✅ Logs everything for easy troubleshooting
✅ Requires zero manual intervention
What we accomplished:
Eliminated manual certificate copying by mounting volumes directly
Solved the port 80 conflict with proper pre/post hooks
Created robust error handling with comprehensive logging
Set up automatic container restarts after renewal
Established monitoring to catch any issues early
Key takeaways:
Volume mounts are your friend - direct access to certificates eliminates copying
Port conflicts are silent killers - always handle them explicitly
Hooks are more reliable than cron scripts - they're integrated into certbot's workflow
Logging is essential - you need to know what's happening during renewal
Test everything - dry-runs and forced renewals help catch issues early
Your SSL certificates will now renew automatically every 60-90 days, your containers will restart to pick up the new certificates, and your site will never go down due to expired SSL certificates again. For complete production deployment context, see my React Vite Docker deployment guide.
Found this helpful? The struggle with SSL automation is real, but once you get it right, it's incredibly satisfying. Feel free to adapt this setup for your specific needs - the principles remain the same across different Docker configurations.