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

·Matija Žiberna·
How to Set Up Automatic SSL Certificate Renewal with Certbot in Docker Containers

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

  1. Mounts SSL certificates directly into containers via Docker volumes
  2. Uses certbot hooks to automatically handle container restarts during renewal
  3. Handles port conflicts gracefully by temporarily stopping services
  4. Logs everything so you know exactly what's happening
  5. 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
# 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:

  1. Eliminated manual certificate copying by mounting volumes directly
  2. Solved the port 80 conflict with proper pre/post hooks
  3. Created robust error handling with comprehensive logging
  4. Set up automatic container restarts after renewal
  5. 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.

6

Frequently Asked Questions

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in