How to Set Up Automatic SSL Certificate Renewal with Certbot in Docker Containers
Automate SSL Certificate Renewal in Docker with Certbot: A Step-by-Step Guide

*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.*
The Problem We're Solving
Picture this: You have a beautiful Docker setup with nginx serving your application over HTTPS. Your Let's Encrypt certificates are working perfectly... until they expire 90 days later.
The typical pain points:
- 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
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
version: '3.8'
services:
frontend:
build: ./frontend
ports:
- "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:ro
restart: unless-stopped
backend:
build: ./backend
ports:
- "8000:8000"
volumes:
- .:/workspace:cached
# Mount certificates in backend too if needed
- /etc/letsencrypt:/etc/letsencrypt:ro
restart: unless-stopped
redis:
image: redis:alpine
restart: unless-stopped
volumes:
frontend-node-modules:
Configure nginx to use mounted certificates
Update your nginx configuration file:
server { listen 80; server_name yourdomain.com www.yourdomain.com; # Redirect HTTP to HTTPS return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name yourdomain.com www.yourdomain.com; # 🔑 Point directly to mounted certificates ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; # Modern SSL configuration ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; # Your application configuration location / { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
Restart your containers
# 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
# 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 accessible
ls -la /etc/letsencrypt/live/yourdomain.com/
You should see output like this:
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
# Check that the actual certificate files exist
ls -la /etc/letsencrypt/archive/yourdomain.com/
# Test reading the certificate
cat /etc/letsencrypt/live/yourdomain.com/fullchain.pem | head -5
Test nginx configuration
# 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:
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:
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:
# This will fail if nginx is running on port 80
sudo 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.
Create the hook directory
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
Create the deploy hook script
sudo nano /etc/letsencrypt/renewal-hooks/deploy/restart-docker.sh
Add this content:
#!/bin/bash
# Certbot deploy hook for Docker-based applications
# This script runs after successful certificate renewal
set -e # Exit on any error
# Configuration - UPDATE THESE PATHS FOR YOUR SETUP
DOCKER_COMPOSE_PATH="/path/to/your/docker-compose-directory" # ⚠️ CHANGE THIS
LOG_FILE="/var/log/letsencrypt/deploy-hook.log"
# Function to log messages with timestamps
log_message() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
log_message "=== Deploy hook started - SSL certificates renewed ==="
# Change to docker-compose directory
if [ ! -d "$DOCKER_COMPOSE_PATH" ]; then
log_message "ERROR: Docker compose directory not found: $DOCKER_COMPOSE_PATH"
exit 1
fi
cd "$DOCKER_COMPOSE_PATH"
log_message "Changed to directory: $DOCKER_COMPOSE_PATH"
# Determine which docker compose command to use
if command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
log_message "Using docker-compose command"
elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
log_message "Using docker compose command"
else
log_message "ERROR: Neither docker-compose nor docker compose found"
exit 1
fi
# Try to gracefully reload nginx first
NGINX_CONTAINER=$(docker ps --format "{{.Names}}" | grep -E "(frontend|nginx|web)" | head -1)
if [ -n "$NGINX_CONTAINER" ]; then
log_message "Found nginx container: $NGINX_CONTAINER"
log_message "Attempting graceful nginx reload..."
if docker exec "$NGINX_CONTAINER" nginx -s reload 2>/dev/null; then
log_message "✅ Nginx reloaded successfully"
log_message "=== Deploy hook completed successfully ==="
exit 0
else
log_message "⚠️ Nginx reload failed, falling back to container restart"
fi
fi
# Fallback: restart all containers
log_message "Restarting all Docker containers..."
if $DOCKER_COMPOSE restart; then
log_message "✅ Docker containers restarted successfully"
# Wait a moment and verify services are up
sleep 10
if curl -f -s -I https://$(hostname -f) >/dev/null 2>&1; then
log_message "✅ HTTPS verification successful"
else
log_message "⚠️ HTTPS verification failed - please check manually"
fi
else
log_message "❌ ERROR: Failed to restart Docker containers"
exit 1
fi
log_message "=== Deploy hook completed successfully ==="
Make the hook executable
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-docker.sh
Update the script with your actual path
This is crucial! Edit the script and replace /path/to/your/docker-compose-directory
with your actual path:
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.
Create pre-hook to stop containers
sudo mkdir -p /etc/letsencrypt/renewal-hooks/pre
sudo nano /etc/letsencrypt/renewal-hooks/pre/stop-docker.sh
#!/bin/bash
# Pre-hook: Stop Docker containers before certificate renewal
set -e
# Configuration - UPDATE THIS PATH
DOCKER_COMPOSE_PATH="/path/to/your/docker-compose-directory" # ⚠️ CHANGE THIS
LOG_FILE="/var/log/letsencrypt/pre-hook.log"
log_message() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
log_message "=== Pre-hook started - Stopping containers for renewal ==="
cd "$DOCKER_COMPOSE_PATH"
# Determine docker compose command
if command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
elif command -v docker >/dev/null 2>&1; then
DOCKER_COMPOSE="docker compose"
else
log_message "ERROR: Docker compose not found"
exit 1
fi
# Stop containers
log_message "Stopping Docker containers..."
if $DOCKER_COMPOSE down; then
log_message "✅ Containers stopped successfully"
# Wait for ports to be freed
sleep 5
else
log_message "❌ ERROR: Failed to stop containers"
exit 1
fi
log_message "=== Pre-hook completed - Port 80 is now free for certbot ==="
Create post-hook to start containers
sudo mkdir -p /etc/letsencrypt/renewal-hooks/post
sudo nano /etc/letsencrypt/renewal-hooks/post/start-docker.sh
#!/bin/bash
# Post-hook: Start Docker containers after certificate renewal
set -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 command
if command -v docker-compose >/dev/null 2>&1; then
DOCKER_COMPOSE="docker-compose"
elif command -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 working
if 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"
fi
else
log_message "❌ ERROR: Failed to start containers"
exit 1
fi
log_message "=== Post-hook completed successfully ==="
Make hooks executable and update paths
# Make executable
sudo chmod +x /etc/letsencrypt/renewal-hooks/pre/stop-docker.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/post/start-docker.sh
# Update paths in both files
sudo 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
# 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
# Test the complete renewal process without actually renewing
sudo certbot renew --cert-name yourdomain.com --dry-run
You should see output like:
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.
# Force an actual renewal to test everything
sudo certbot renew --cert-name yourdomain.com --force-renewal
Check the logs
# Check all hook logs
sudo tail -20 /var/log/letsencrypt/pre-hook.log
sudo tail -20 /var/log/letsencrypt/post-hook.log
sudo tail -20 /var/log/letsencrypt/deploy-hook.log
# Check general certbot log
sudo tail -20 /var/log/letsencrypt/letsencrypt.log
Verify HTTPS is working
# 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
# Check if certbot timer is enabled
sudo systemctl status certbot.timer
# If not active, enable it
sudo systemctl enable certbot.timer
sudo systemctl start certbot.timer
Alternative: Cron setup
If you prefer cron or don't have systemd timers:
# Edit root's crontab
sudo crontab -e
# Add this line to run twice daily at random minutes
23 2,14 * * * /usr/bin/certbot renew --quiet
Test automatic renewal timing
# See when certbot will next run
sudo systemctl list-timers certbot.timer
# Or check certificate expiration dates
sudo certbot certificates
Monitoring and Troubleshooting
Essential log files to monitor
# Certbot main log
sudo tail -f /var/log/letsencrypt/letsencrypt.log
# Your custom hook logs
sudo tail -f /var/log/letsencrypt/pre-hook.log
sudo tail -f /var/log/letsencrypt/post-hook.log
sudo tail -f /var/log/letsencrypt/deploy-hook.log
# System journal for certbot service
sudo journalctl -u certbot.service -f
Common issues and solutions
Issue: "nginx: command not found" in deploy hook
# 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
# Check container status
docker ps -a
# Check compose logs
docker-compose logs
# Manual restart
cd /your/compose/path && docker-compose restart
Issue: Certificates don't update in containers
# 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:
sudo nano /usr/local/bin/check-ssl-expiry.sh
#!/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 ]; then
echo "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.
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.