10 KiB
SSL/TLS Certificate Management
Overview
Changemaker Lite V2 supports multiple SSL/TLS certificate sources for HTTPS deployment:
- Let's Encrypt: Free automated certificates (recommended for self-hosted)
- Cloudflare Origin Certificates: Static 15-year certificates (if using Cloudflare)
- Pangolin Tunnel SSL: Tunnel provider handles SSL termination
Recommendation: Use Pangolin tunnel for simplest setup (SSL handled by tunnel provider).
Certificate Sources
Let's Encrypt with Certbot
Best for: Self-hosted deployments with public DNS
Process:
- Install Certbot
- Generate certificate (DNS or HTTP challenge)
- Configure Nginx
- Auto-renewal via cron
Installation (Ubuntu/Debian):
sudo apt update
sudo apt install certbot python3-certbot-nginx
Generate Certificate (HTTP-01 challenge):
# Stop Nginx temporarily
docker compose stop nginx
# Generate cert
sudo certbot certonly --standalone \
-d cmlite.org \
-d "*.cmlite.org" \
--email admin@cmlite.org \
--agree-tos \
--non-interactive
# Start Nginx
docker compose start nginx
Certificate Location:
/etc/letsencrypt/live/cmlite.org/
├─ fullchain.pem (certificate + intermediate)
├─ privkey.pem (private key)
└─ chain.pem (intermediate only)
Nginx Configuration:
server {
listen 443 ssl http2;
server_name api.cmlite.org;
ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
ssl_prefer_server_ciphers on;
# ... locations
}
HTTP Redirect:
server {
listen 80;
server_name api.cmlite.org;
return 301 https://$host$request_uri;
}
Cloudflare Origin Certificates
Best for: Sites using Cloudflare DNS + proxy
Process:
- Generate certificate in Cloudflare dashboard
- Download certificate + private key
- Install in Nginx
- Set SSL mode to "Full (strict)"
Generate Certificate:
- Cloudflare dashboard → SSL/TLS → Origin Server
- Click "Create Certificate"
- Hostnames:
cmlite.org,*.cmlite.org - Validity: 15 years
- Download certificate + private key
Install Certificate:
# Create directory
sudo mkdir -p /etc/ssl/cloudflare
# Save files
sudo nano /etc/ssl/cloudflare/cmlite.org.pem # Certificate
sudo nano /etc/ssl/cloudflare/cmlite.org.key # Private key
# Set permissions
sudo chmod 600 /etc/ssl/cloudflare/cmlite.org.key
Nginx Configuration:
server {
listen 443 ssl http2;
server_name api.cmlite.org;
ssl_certificate /etc/ssl/cloudflare/cmlite.org.pem;
ssl_certificate_key /etc/ssl/cloudflare/cmlite.org.key;
# ... TLS config
}
Cloudflare SSL Mode: Set to "Full (strict)" (not "Flexible").
Pangolin Tunnel SSL
Best for: Quick deployment without SSL management
How it works:
- Pangolin tunnel terminates SSL at tunnel endpoint
- Traffic forwarded to your Nginx as HTTP
- No certificate management needed
Setup:
# Configure tunnel (see Tunneling guide)
PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org
NEWT_ID=<your-newt-id>
NEWT_SECRET=<your-newt-secret>
# Start Newt container
docker compose up -d newt
Nginx Configuration: Keep HTTP-only (tunnel handles HTTPS):
server {
listen 80;
server_name api.cmlite.org;
# No SSL config needed — tunnel terminates HTTPS
location / {
proxy_pass http://changemaker-v2-api:4000;
}
}
DNS Setup: Point domain to tunnel endpoint (provided by Pangolin).
See Tunneling for complete guide.
Nginx SSL Configuration
Strong TLS Settings
server {
listen 443 ssl http2;
server_name api.cmlite.org;
# Certificates
ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cmlite.org/privkey.pem;
# Protocols (TLS 1.2+ only)
ssl_protocols TLSv1.2 TLSv1.3;
# Ciphers (secure + fast)
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
# Session caching (performance)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# OCSP stapling (performance + privacy)
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/cmlite.org/chain.pem;
# HSTS (already set globally in nginx.conf)
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# ... locations
}
Explanation:
- TLS 1.2+: Disables insecure SSLv3, TLS 1.0/1.1
- Ciphers: ECDHE for forward secrecy, AES-GCM for speed
- Session cache: Reduces TLS handshake overhead
- OCSP stapling: Faster certificate validation
HTTP/2
Already enabled (:443 ssl http2):
- Multiplexes requests over single connection
- Server push support (optional)
- Faster page loads
No additional config needed — Nginx handles HTTP/2 automatically.
HSTS (HTTP Strict Transport Security)
Already set globally (in nginx/nginx.conf):
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Effect:
- Browsers cache HTTPS requirement for 1 year
- Prevents downgrade attacks
- Applies to all subdomains
Warning: Only enable after verifying HTTPS works (can't easily undo).
Test before enabling:
# Test HTTPS works
curl -I https://api.cmlite.org
# Check for redirects
curl -L https://api.cmlite.org
Certificate Renewal
Automated Renewal (Certbot)
Setup cron job:
# Edit crontab
sudo crontab -e
# Add renewal job (checks twice daily)
0 0,12 * * * certbot renew --quiet --post-hook "docker compose -f /path/to/changemaker.lite/docker-compose.yml exec nginx nginx -s reload"
Manual renewal:
# Dry run (test)
sudo certbot renew --dry-run
# Real renewal
sudo certbot renew
# Reload Nginx
docker compose exec nginx nginx -s reload
Renewal conditions:
- Certificates expire in <30 days
- HTTP-01 challenge succeeds (port 80 must be open)
Manual Renewal (Cloudflare)
Cloudflare origin certificates valid for 15 years — no renewal needed.
If replacing certificate:
- Generate new cert in Cloudflare dashboard
- Download files
- Replace files in
/etc/ssl/cloudflare/ - Reload Nginx:
docker compose exec nginx nginx -s reload
Monitoring Expiry
Check expiry date:
# Via OpenSSL
echo | openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org 2>/dev/null | openssl x509 -noout -dates
# Output:
# notBefore=Jan 1 00:00:00 2024 GMT
# notAfter=Apr 1 23:59:59 2024 GMT
Automated monitoring (via Prometheus + Alertmanager):
# In alerts.yml
- alert: SSLCertificateExpiringSoon
expr: probe_ssl_earliest_cert_expiry - time() < 86400 * 30 # 30 days
for: 1h
labels:
severity: warning
annotations:
summary: "SSL certificate expiring in <30 days"
Testing SSL
SSL Labs
Online test: https://www.ssllabs.com/ssltest/
Target grade: A or A+
Common issues:
- Missing intermediate certificate (use
fullchain.pemnotcert.pem) - Weak ciphers (update
ssl_cipherslist) - Missing HSTS header (already set globally)
Command Line
Test TLS handshake:
openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org
Check certificate chain:
openssl s_client -connect api.cmlite.org:443 -servername api.cmlite.org -showcerts
Test specific protocol:
# TLS 1.2
openssl s_client -connect api.cmlite.org:443 -tls1_2
# TLS 1.3
openssl s_client -connect api.cmlite.org:443 -tls1_3
# SSLv3 (should fail)
openssl s_client -connect api.cmlite.org:443 -ssl3
Wildcard Certificates
For *.cmlite.org (covers all subdomains):
Let's Encrypt (DNS-01 Challenge)
Required: API access to DNS provider (Cloudflare, Route53, etc.)
Example (Cloudflare):
# Install Cloudflare plugin
sudo apt install python3-certbot-dns-cloudflare
# Create credentials file
cat > ~/.secrets/cloudflare.ini <<EOF
dns_cloudflare_api_token = YOUR_API_TOKEN
EOF
chmod 600 ~/.secrets/cloudflare.ini
# Generate wildcard cert
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials ~/.secrets/cloudflare.ini \
-d cmlite.org \
-d "*.cmlite.org" \
--email admin@cmlite.org \
--agree-tos
Advantage: Single certificate covers all subdomains (api, app, db, etc.).
Troubleshooting
Certificate Not Trusted
Symptoms: Browser shows "Not Secure" warning
Causes:
- Missing intermediate certificate
- Wrong certificate file
- Certificate expired
Solution:
# Use fullchain.pem (includes intermediate)
ssl_certificate /etc/letsencrypt/live/cmlite.org/fullchain.pem;
# NOT cert.pem (missing intermediate)
# ssl_certificate /etc/letsencrypt/live/cmlite.org/cert.pem; # ❌ WRONG
# Reload Nginx
docker compose exec nginx nginx -s reload
Mixed Content Warnings
Symptoms: Some assets load via HTTP on HTTPS page
Cause: Hard-coded http:// URLs in HTML/JS
Solution:
// Change absolute URLs to protocol-relative
// ❌ WRONG
const apiUrl = 'http://api.cmlite.org';
// ✅ CORRECT
const apiUrl = 'https://api.cmlite.org';
// ✅ BEST (protocol-relative)
const apiUrl = location.protocol + '//api.cmlite.org';
Renewal Failures
Symptoms: Certbot renewal fails
Diagnosis:
# Test renewal
sudo certbot renew --dry-run
# Check logs
sudo tail -f /var/log/letsencrypt/letsencrypt.log
Common causes:
- Port 80 blocked (HTTP-01 challenge fails)
- DNS not pointing to server (domain validation fails)
- Rate limit hit (5 certs/week per domain)
Solution:
# Check port 80 open
sudo netstat -tulpn | grep :80
# Test HTTP challenge
curl http://cmlite.org/.well-known/acme-challenge/test
# Use DNS-01 challenge instead (no port 80 needed)
sudo certbot certonly --dns-cloudflare ...
Related Documentation
- Docker Compose — Nginx container setup
- Nginx Configuration — Reverse proxy config
- Tunneling — Pangolin tunnel SSL
- Environment Variables — SSL-related env vars