Putting HTTPS on your own server now costs nothing: Let’s Encrypt issues certificates for free (valid for 90 days), and Certbot handles issuance and auto-renewal. All that’s left for you is pairing it with nginx and dodging a few “sticking points” in advance.
I recently ran this whole flow twice — once on an old server mm, once on a newly purchased server oo (new domain, so a fresh certificate too). This post explains both “how to issue” and “how to guarantee it never expires,” and calls out the three places beginners get stuck.
First, two things to understand: why the free cert is “short-lived,” and why validation goes through port 80
The certificate is free, but only lasts 90 days. That’s not stinginess — it’s Let’s Encrypt’s design: it forces you to automate “renewal” instead of signing once and forgetting for three years (paid certs last a year, which is exactly why people forget to renew until they expire). A 90-day validity plus daily automatic checks that renew only when close to expiry removes “certificate expired” as a failure mode at the root. So “auto-renewal” below isn’t optional — it’s half of this free solution.
Proving you actually own the domain relies on the HTTP-01 challenge. Roughly:
- You run certbot on the server, and it tells Let’s Encrypt: “I want a certificate for
example.com”; - Let’s Encrypt returns a one-time token: put content
Xathttp://example.com/.well-known/acme-challenge/X; - certbot automatically serves that path on your server’s port 80;
- Let’s Encrypt fetches that address from the public internet, gets
X, confirms the domain is yours → issues the certificate.
Note two implications that the whole rest of this post revolves around:
- Validation goes through HTTP + port 80, so the domain must be reachable from the public internet to this machine and port 80 must be open — certificate validation doesn’t need 443; 443 only matters after you have the certificate.
- It validates “your domain actually points to this server.” If DNS hasn’t propagated, or 80 is blocked/firewalled, it fails.
Preparation: DNS + two “invisible roadblocks”
Before starting, confirm three things:
nslookup yourdomain.com # domain resolves to this server
ss -tlnp | grep ':80' # something listening on port 80 (nginx)
nginx -v && certbot --version # is certbot installed? (see below)
Install certbot (Ubuntu/Debian, with the nginx plugin):
sudo apt update
sudo apt install certbot python3-certbot-nginx
After installation, certbot --version should print a version. These two “invisible roadblocks” are the ones I actually hit, and their errors were genuinely confusing:
Roadblock one: an unfiled (un-beian’d) domain gets HTTP blocked at the “network layer” by the cloud provider. On mainland-China servers, before a domain completes ICP filing, the cloud provider’s edge redirects HTTP access to it with a 302 to an “please file (beian)” blocking page. Inside the server, curl -H "Host: yourdomain.com" http://127.0.0.1/ clearly returns 200, but when certbot validates from the public internet it gets the blocking page, with an error like this:
certbot --nginx error: Domain yourdomain.com
Type: unauthorized, Invalid response from https://dnspod.qcloud.com/.../webblock.html
This isn’t a config problem — it’s the ICP filing that hasn’t been completed. Go finish the domain filing first; until it takes effect, the HTTP-01 challenge can’t pass (the alternative is DNS-01, see the end of this post).
Roadblock two: cloud firewall and host firewall are two layers. The “cloud security group/firewall” lives in the cloud console (unreachable via SSH); the “host firewall” (ufw/iptables) lives on the server. If port 80 is blocked by the cloud firewall, validation fails; if 443 is blocked, the certificate gets issued but nothing connects. Remember this troubleshooting order: loopback self-test passes → listener is up → host firewall isn’t blocking → then go open the port in the cloud console.
Issuance: two approaches, pick by your preference for control
Certificates end up in /etc/letsencrypt/live/<your-domain>/, containing fullchain.pem (certificate chain) and privkey.pem (private key). nginx references these two symlinks — after renewal the links stay put and point at new files, so you can hard-code this path in your nginx config.
Approach one: certbot --nginx — fully automatic, even edits nginx for you
If your nginx site structure is simple and you don’t mind certbot rewriting config files, one line does it:
sudo certbot --nginx -d example.com -d www.example.com \
--non-interactive --agree-tos --register-unsafely-without-email --redirect
It automatically: temporarily configures the validation path → validates → issues → rewrites nginx to add 443 and the cert → adds the 80-to-443 redirect (--redirect) → sets up the renewal timer. Right for people who want to be live in 5 minutes.
The price is that part of your config file gets taken over by certbot (marked with # managed by Certbot), and later manual restructuring can trip over it. Backing up the config before changing anything is an iron rule.
Approach two: certbot certonly --nginx — issue only, write nginx yourself
If you care about your site structure (like the three-block layout below — www normalized, unknown Hosts 404), use certonly to let certbot temporarily borrow nginx for validation and issue the certificate, then back off immediately; you keep full control of the config:
sudo certbot certonly --nginx -d example.com -d www.example.com
# cert lands in /etc/letsencrypt/live/example.com/
After issuance, write your nginx site in the structure below yourself.
A clean nginx HTTPS structure (three blocks)
This is the structure I use on both machines, with three clearly separated responsibilities:
# Block 1: www normalization — www.xxx → 301 straight to the apex domain
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf; # recommended SSL params installed by certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
return 301 https://example.com$request_uri;
}
# Block 2: apex domain 443 — the actual site
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/www/site;
index index.html;
location / {
try_files $uri $uri/ =404;
}
# ...reverse proxy, static caching, etc. as needed
}
# Block 3: 80 → 443, and only for your own domains
server {
listen 80;
server_name example.com www.example.com;
if ($host = www.example.com) { return 301 https://example.com$request_uri; }
if ($host = example.com) { return 301 https://$host$request_uri; }
return 404; # unknown Hosts get 404 — don't let other people's requests into your site
}
include options-ssl-nginx.conf and ssl_dhparam are recommended configs generated by certbot at install time, saving you a pile of SSL parameter choices. After writing:
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ # → 200, and the certificate is valid
One detail: try_files $uri $uri/ =404 (instead of falling back to the homepage) makes nonexistent paths return a real 404; combined with error_page 404 /404.html;, it’s friendlier for both SEO and troubleshooting.
The “other half” of the free solution: auto-renewal
Issuing is only the beginning. Let’s Encrypt certs expire in 90 days, so renewal must be automated.
The core renewal command is one line: sudo certbot renew. It scans the configs under /etc/letsencrypt/renewal/ and only actually renews when there are fewer than 30 days left, otherwise it exits instantly with “Certificate not yet due for renewal.” So you can safely run it frequently; the normal-case cost is almost zero.
Who schedules it? On Ubuntu, installing certbot brings a systemd timer called certbot.timer:
$ systemctl cat certbot.timer
Description=Run certbot twice daily
OnCalendar=*-*-* 00,12:00:00 # triggers twice daily at 00:00 and 12:00 UTC
RandomizedDelaySec=43200 # plus a random 0–12h delay, so all servers on the internet don't stampede at once
Confirm it’s alive: systemctl is-enabled certbot.timer and is-active should both report active/enabled; systemctl list-timers certbot.timer shows the next trigger time. On older machines (or if you configure it by hand), cron works too, one line, equivalent effect:
0 3 * * * /usr/bin/certbot renew --quiet --deploy-hook "systemctl reload nginx"
Why the --deploy-hook "systemctl reload nginx"? This is the most commonly missed piece: after renewal, the symlinks under live/ point to new files, but the running nginx still remembers the old files — without a reload it won’t swap in the new certificate. The deploy hook runs only after an actual renewal and reloads nginx. Whether this hook is in your scheduled job decides whether the certificate actually takes effect after expiry.
Self-check the whole chain: dry-run
Don’t just wait out the 90 days — simulate a renewal immediately to verify the whole chain works:
sudo certbot renew --dry-run
It goes through Let’s Encrypt’s staging environment, issues nothing real, and uses no production quota, but rehearses validation, renewal, and deploy hooks end to end. Seeing this line means the chain works:
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/example.com/fullchain.pem (success)
For day-to-day status and expiry:
sudo certbot certificates
# Certificate Name: example.com
# Domains: example.com www.example.com
# Expiry Date: 2026-12-03 (VALID: 89 days)
Pitfall quick reference
| Symptom | Cause | Fix |
|---|---|---|
Type: unauthorized ... webblock |
Domain hasn’t completed ICP filing; HTTP intercepted by the cloud provider | Finish the filing first; or use DNS-01 validation |
| certbot validation timeout / can’t reach 80 | Domain doesn’t resolve to this machine, or the cloud firewall blocks 80 | Check with nslookup; open 80 in the cloud console |
| Certificate issued but https won’t open | Cloud firewall blocks 443 | Open 443 in the cloud console; confirm listening with ss -tlnp |
| Renewal shows success but the cert doesn’t change | No deploy-hook to reload nginx | Add --deploy-hook "systemctl reload nginx" |
Want to manage *.example.com too |
HTTP-01 can only validate single domains | Switch to DNS-01 (e.g. certbot-dns-cloudflare) validating via API |
Summary: three steps for free SSL and one takeaway
- Prepare: DNS resolves, 80/443 open in the cloud firewall, ICP filing done (mainland servers);
- Issue:
certbot certonly --nginx -d domain -d www.domain, write nginx in the three-block layout referencing thelive/cert paths; - Renew:
certbot.timer(or cron) runsrenew+ a deploy-hook to reload nginx, then self-check withrenew --dry-run.
One takeaway to close with: the price of “free” isn’t “renew manually every 90 days” — it’s “put renewal into automation.” The former gets forgotten; the latter doesn’t. The moment your dry-run turns green, HTTPS is something you can stop thinking about.