HTTPS with Let's Encrypt
No excuses for no HTTPS
Every production app needs HTTPS. Without it, all traffic (including passwords, tokens, cookies) is sent in cleartext. Anyone on the network can read it.
Let’s Encrypt provides free SSL certificates. Certbot automates the process. There is no cost and no reason to skip this.
Setting up Certbot with Docker
Add a Certbot service to your compose file:
# docker-compose.yml
services:
app:
build: .
expose:
- "3000"
env_file:
- .env.production
volumes:
- app-data:/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- certbot-conf:/etc/letsencrypt:ro
- certbot-www:/var/www/certbot:ro
depends_on:
- app
restart: unless-stopped
certbot:
image: certbot/certbot
# The official Certbot Docker image. Certbot is the tool that
# requests and renews SSL certificates from Let's Encrypt.
# It runs as a one-off command (not a long-running service).
volumes:
- certbot-conf:/etc/letsencrypt
- certbot-www:/var/www/certbot
volumes:
app-data:
certbot-conf:
certbot-www: Step 1: HTTP-only Nginx config
Before getting a certificate, Nginx must serve HTTP so Certbot can verify your domain:
# nginx.conf (initial — HTTP only)
server {
listen 80;
server_name yourdomain.com;
# Certbot verification
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Proxy to app
location / {
proxy_pass http://app:3000;
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;
}
} Step 2: Get the certificate
# Start Nginx (HTTP only)
docker compose up -d nginx
# Request a certificate
docker compose run --rm certbot certonly \
--webroot \
--webroot-path /var/www/certbot \
-d yourdomain.com \
--email [email protected] \
--agree-tos \
--no-eff-email Certbot places a verification file in /var/www/certbot/.well-known/acme-challenge/. Let’s Encrypt’s server accesses it via HTTP to verify you control the domain. On success, the certificate is stored in the certbot-conf volume.
Step 3: Update Nginx for HTTPS
# nginx.conf (with SSL)
server {
listen 80;
server_name yourdomain.com;
# Redirect all HTTP to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Modern SSL config
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Certbot renewal endpoint
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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 Nginx to pick up the new config:
docker compose restart nginx Your app is now at https://yourdomain.com. HTTP redirects to HTTPS.
Auto-renewal
Let’s Encrypt certificates expire after 90 days. Set up a cron job to renew automatically:
# Add to crontab (crontab -e)
0 3 * * * cd /app && docker compose run --rm certbot renew && docker compose exec nginx nginx -s reload This runs daily at 3 AM. Certbot only renews if the certificate is within 30 days of expiry. After renewal, Nginx is reloaded to pick up the new certificate.
Exercises
Exercise 1: Point a domain to your VPS IP (A record in DNS). Set up Certbot and get a certificate.
Exercise 2: Verify HTTPS works. Open https://yourdomain.com in a browser. Check the certificate details.
Exercise 3: Test HTTP-to-HTTPS redirect. Open http://yourdomain.com. It should redirect to HTTPS.
Why do Let's Encrypt certificates expire after 90 days instead of a year?