Why manage Let's Encrypt yourself on a VPS
Let's Encrypt is a free certificate authority that issues TLS certificates validated by an automated challenge (HTTP-01 or DNS-01). On shared hosting, you are stuck with the provider's TLS config: imposed protocol versions, no OCSP stapling, opaque renewal. On a VPS, you own the certbot (or acme.sh), the reverse proxy and the TLS termination. You decide on the protocols (TLS 1.2/1.3 only), the cipher suites, HSTS and OCSP stapling. You can also issue a wildcard certificate (*.your-domain.com) via the DNS-01 challenge, cover several domains in a single certificate (SAN), and wire renewal to your own alerts. In short: total control over transport security, a prerequisite for an A+ score on SSL Labs and maximum browser-side trust.
What you gain by self-hosting the TLS chain
- 100% free certificates, valid for 90 days and renewed automatically without intervention.
- Wildcard
*.your-domain.compossible via the DNS-01 challenge: a single certificate for all your subdomains. - Full control of the config: TLS 1.3, modern cipher suites, HSTS and OCSP stapling.
- Multi-domain SAN certificates (
your-domain.com,www.your-domain.com,api.your-domain.com) on a single IP. - Scriptable and observable renewal: reload hooks, Slack/email alerts before expiry.
- No dependency on a third-party panel: the same recipe works across all your VPSes and environments.
Prerequisites before you start
TLS termination consumes very few resources: a VPS with 1 vCPU / 512 MB to 1 GB of RAM is more than enough to serve HTTPS for one or more sites.
On the network side: your domain name must point to the VPS's public IP (a propagated A/AAAA record). Check propagation before running certbot: dig +short your-domain.com must return your VPS's IP. A challenge that fails due to incomplete DNS propagation counts as an attempt and brings you closer to the ACME rate limits.
Ports 80 and 443 must be open in the firewall — port 80 is essential for the HTTP-01 challenge (certbot places a temporary challenge file there):
ufw allow 80/tcp
ufw allow 443/tcp
ufw reloadFor a wildcard certificate (*.your-domain.com), the HTTP-01 challenge is not sufficient: you will need an API token from your DNS provider (Cloudflare, OVH…) to automate the DNS-01 challenge.
Install certbot and obtain a Nginx certificate
Install certbot and the Nginx plugin
On Debian/Ubuntu:
apt update apt install -y certbot python3-certbot-nginxThe
python3-certbot-nginxplugin reads your Nginx config, injects the TLS configuration and replaces thelisten 80directive with an443 sslblock. If you prefer to manage Nginx manually, use--standaloneor--webrootinstead of--nginx.Point the domain and open the ports
Create an A record
your-domain.com(and AAAA if IPv6) pointing to the VPS's IP, then allow web traffic:ufw allow 80,443/tcpConfirm resolution with
dig +short your-domain.combefore going further — a challenge always fails if DNS is not yet pointing.Issue the certificate with the Nginx plugin
The most direct method — certbot modifies your Nginx vhost and manages renewal:
certbot --nginx -d your-domain.com -d www.your-domain.comCertbot asks for an email address (expiry alerts) and prompts you to accept the ToS. It issues the certificate, modifies
/etc/nginx/sites-available/<vhost>, adds an HTTP→HTTPS redirect and reloads Nginx without downtime.Files are placed in
/etc/letsencrypt/live/your-domain.com/:fullchain.pem(certificate + chain) andprivkey.pem(private key).Verify automatic renewal
Certbot installs a systemd timer that attempts renewal twice a day:
systemctl status certbot.timerTo simulate a renewal without issuing a new certificate:
certbot renew --dry-runIf the simulation succeeds, your renewal chain is operational. Make sure Nginx reloads after an actual renewal — certbot automatically adds a
--deploy-hook "systemctl reload nginx"hook in/etc/letsencrypt/renewal/<domain>.confwhen using the Nginx plugin.
Wildcard certificate with Cloudflare DNS challenge
A *.your-domain.com certificate covers all your subdomains with a single file. It is mandatory if you create subdomains dynamically or if you don't want to issue one certificate per subdomain.
The HTTP-01 challenge cannot validate a wildcard — you need the DNS-01 challenge, which requires certbot to temporarily create a TXT record _acme-challenge.your-domain.com.
With Cloudflare (API token):
apt install -y python3-certbot-dns-cloudflareCreate a Cloudflare credentials file (permissions: 600):
mkdir -p /etc/letsencrypt/cloudflare
chmod 700 /etc/letsencrypt/cloudflare
cat > /etc/letsencrypt/cloudflare/credentials.ini << 'EOF'
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 /etc/letsencrypt/cloudflare/credentials.iniIssue the wildcard certificate:
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
-d your-domain.com \
-d '*.your-domain.com'Certbot creates the TXT record, waits for propagation (10 seconds by default — increase with --dns-cloudflare-propagation-seconds 30), obtains the certificate, then deletes the TXT record.
Alternative with acme.sh:
acme.sh --issue --dns dns_cf -d your-domain.com -d '*.your-domain.com'acme.sh uses the CF_Token environment variable or CF_Key + CF_Email depending on your Cloudflare authentication method.
Nginx TLS hardening: TLS 1.2/1.3, HSTS and cipher suites
The certbot plugin configures a functional HTTPS, but the default configuration is not optimal for an A+ score on SSL Labs. Here are the directives to add to your server block (or in an included file like /etc/nginx/snippets/ssl-params.conf):
# Protocols: TLS 1.2 minimum, TLS 1.3 recommended
ssl_protocols TLSv1.2 TLSv1.3;
# Modern cipher suites (Mozilla Intermediate)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# TLS session
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# HSTS (6 months, subdomains included, preload)
add_header Strict-Transport-Security "max-age=15768000; includeSubDomains; preload" always;
# Additional security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;Redirect all of port 80 to 443 in a separate server block:
server {
listen 80;
server_name your-domain.com www.your-domain.com;
return 301 https://$host$request_uri;
}Reload without downtime: nginx -s reload. Then test on SSL Labs — you should get A+.
Monitoring certificate expiry
Automatic renewal can fail silently: systemd timer disabled, port 80 blocked after a firewall change, Cloudflare API token revoked… An expired certificate in production breaks the service for all your visitors.
Manual check:
echo | openssl s_client -connect your-domain.com:443 -servername your-domain.com 2>/dev/null \
| openssl x509 -noout -datesEmail alert script (to place in cron or systemd):
#!/bin/bash
# Alert if the certificate expires in less than 14 days
cert_file="/etc/letsencrypt/live/your-domain.com/fullchain.pem"
if ! openssl x509 -checkend 1209600 -noout -in "$cert_file" 2>/dev/null; then
echo "ALERT: certificate your-domain.com expires in less than 14 days" \
| mail -s "[SSL] Imminent expiry" [email protected]
fi-checkend 1209600 corresponds to 14 days in seconds (14 × 86,400).
With Uptime Kuma: add a "Certificate Expiry" monitor that alerts at D-14 — no script to maintain.
Troubleshooting: rate limits, blocked port 80, DNS propagation
ACME rate limits: "too many certificates already issued"
Let's Encrypt limits to 5 certificates per registrable domain per week. If you hit this limit during testing, switch to the staging environment:
certbot --nginx --staging -d your-domain.comStaging issues certificates not trusted by browsers, but with no rate limit. Once your configuration is validated, delete the staging certificate and reissue in production.
Port 80 blocked
The HTTP-01 challenge requires Let's Encrypt to reach http://your-domain.com/.well-known/acme-challenge/. Check:
curl -I http://your-domain.com/.well-known/acme-challenge/test
# expected: 404 (the route exists, the file doesn't yet)
ufw status | grep 80If port 80 is blocked by an upstream firewall (cloud provider, Cloudflare in proxy mode), temporarily open it or switch to the DNS-01 challenge.
Cloudflare proxy active
If your domain is behind Cloudflare (orange cloud icon), make sure Cloudflare's SSL mode is Full (Strict) — in Flexible mode, Cloudflare communicates with your server over HTTP and nginx redirecting HTTP→HTTPS produces an infinite loop (HTTP 525/526).
Insufficient DNS propagation
For the DNS-01 challenge, wait at least 60 seconds after creating the TXT record before retrying certbot. Check:
dig TXT _acme-challenge.your-domain.com +shortIf the value does not appear yet, wait before retrying.
Comparison: certbot vs acme.sh vs Caddy automatic HTTPS
Scroll the table
| Criterion | certbot | acme.sh | Caddy (auto HTTPS) |
|---|---|---|---|
| Installation | `apt install certbot` | Bash script, no system dependencies | Single binary, included |
| Nginx/Apache integration | Native plugin (`--nginx`, `--apache`) | Manual `--install-cert` hooks | Manages its own reverse proxy |
| Wildcard DNS-01 | Via DNS plugin (`python3-certbot-dns-*`) | Native, many integrated DNS providers | Via `tls.dns.*` module (Caddy v2) |
| Renewal | Systemd timer (automatic) | Cron job generated at install | Native, no configuration |
| Learning curve | Low — intuitive commands | Medium — many options | Very low — minimalist Caddyfile |
| Ideal use case | Existing dedicated Nginx/Apache server | Complex environments, multi-DNS providers | New projects, integrated reverse proxy |
Test before production
Before going to production, test against Let's Encrypt's staging environment (--staging with Certbot, --server letsencrypt_test with acme.sh): the production limit is 5 issuances per domain per week, and a poorly tuned script can quickly make you hit it.
Also add expiry monitoring: a probe that runs openssl x509 -checkend 604800 (7 days) and triggers an alert if automatic renewal has silently failed — a broken cron is the top cause of an HTTPS certificate expiring in the middle of the night.
Check the certbot releases page on GitHub to verify the available version before writing a tutorial or installation script.