If you want to create an SSL certificate for your own server today, it costs nothing and takes a few seconds. The standard route is Let’s Encrypt. On a server running nginx, you install certbot and run sudo certbot --nginx -d example.com -d www.example.com. Certbot proves you control the domain, fetches the certificate, adds it to your config and sets up automatic renewal. With Caddy as your web server it’s even simpler: you write the domain name into the config and everything else happens on its own.
That’s the short answer. The long one matters more, because creating the certificate rarely goes wrong. What bites you is the renewal three months later. A certificate without working renewal is a time bomb, and most of the traps don’t produce an error. In our test, after a simulated server move, certbot renew printed “No renewals were attempted” and exited with code 0, meaning success, even though no certificate could be renewed anymore. And after a perfectly successful renewal, nginx kept serving the old certificate.
We ran all of this on our own server on November 5, 2026, using Certbot 2.9.0, nginx 1.24, Caddy 2.11.7 and Pebble, the official test certificate authority from Let’s Encrypt. Pebble speaks the same ACME protocol as the real CA but issues certificates no browser trusts. That let us provoke failures without hitting Let’s Encrypt’s rate limits. Every number in this article comes from those runs. If you want the background on what HTTPS is and what a certificate actually proves, read What is HTTPS?. This article is about doing it.
Video: Sarvin Style Coding walks through Certbot with nginx, both directly on a VPS and in Docker (August 2026).
How to create an SSL certificate: the four routes
Before you type a command, think about what the certificate is for. That decides which route fits.
| Route | Best for | Effort | Cost | Trusted by browsers |
|---|---|---|---|---|
| Caddy (automatic) | new servers, reverse proxies, Docker | 1 line of config | $0 | yes |
| Certbot with nginx or Apache | existing nginx/Apache servers | 2 commands | $0 | yes |
| Certbot with DNS challenge | wildcard *.example.com, servers without public port 80 | DNS API access | $0 | yes |
| Self-signed (openssl) | testing, internal networks, development | 1 command | $0 | no, warning |
| Paid certificate (OV/EV) | when contracts or compliance require it | application, vetting | $50–500/year | yes |
For almost every website, a free Let’s Encrypt certificate is the right choice. The encryption is exactly as strong as a paid one. Paid certificates additionally verify the organization behind the domain, but browsers stopped showing that difference years ago. There’s more on DV, OV and EV in our HTTPS article.
A quick note on the name: strictly speaking, “SSL” hasn’t existed for years. The protocol is TLS, and SSL has been switched off everywhere for security reasons. The certificate is the same, though. Whether you say SSL certificate, TLS certificate or HTTPS certificate, you mean an X.509 certificate that binds your domain name to a public key.
What has to be in place first
Let’s Encrypt only issues a certificate once you prove you control the domain. That happens automatically through the ACME protocol. For it to work, three things must be true:
- The DNS record points to your server. An A record (IPv4) and, if you use it, an AAAA record (IPv6) for every name that goes on the certificate, including
www. See What is DNS? for how that works. Watch out for IPv6: if your domain has an AAAA record, Let’s Encrypt prefers to validate over IPv6. If that record points to an old address, validation fails even though IPv4 is correct. - Port 80 is reachable from the internet. The most common validation method (HTTP-01) fetches a file over plain HTTP. A firewall that only opens 443 blocks it. In our test with the port closed, the error read
Type: connectionandconnect: connection refused. Our Linux server setup guide covers which ports to open on a fresh server. - A web server is already running, or port 80 is free if Certbot should start its own temporary server.
How domain validation works
There are three validation methods, called “challenges” in ACME terms:
- HTTP-01: Certbot places a file containing a random token at
http://your-domain/.well-known/acme-challenge/. Let’s Encrypt downloads it. If the content matches, you’ve proven you control the web server. This is the default. - DNS-01: You create a TXT record
_acme-challenge.your-domaincontaining the token. This is the only way to get wildcard certificates, and it works even if the server isn’t reachable from the internet at all. - TLS-ALPN-01: Validation happens on port 443 using a special TLS extension. Caddy likes to use it; with Certbot it rarely matters.

One detail from our test that surprises people: the CA doesn’t check just once. Our nginx access log showed three requests for the same file per validation. The real Let’s Encrypt additionally validates from several locations around the world (“multi-perspective validation”). If your server geo-blocks certain countries, validation can fail even though it works from where you’re sitting.
Route 1: create an SSL certificate with Certbot and nginx
This is the classic setup on Ubuntu and Debian. Install Certbot and the nginx plugin:
sudo apt update
sudo apt install certbot python3-certbot-nginx
Your nginx needs a server block with the right server_name, even if it only listens on port 80 for now:
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example;
}
Then request the certificate:
sudo certbot --nginx -d example.com -d www.example.com
The first time, Certbot asks for an email address and agreement to the terms. Then it validates the domain, stores the certificate under /etc/letsencrypt/live/example.com/, adds listen 443 ssl and the certificate paths to your server block, sets up an HTTP-to-HTTPS redirect and reloads nginx. That’s it.
If you run nginx as a reverse proxy in front of an app, our nginx reverse proxy guide has the full config. For Apache the process is identical, just with python3-certbot-apache and certbot --apache.
If Certbot shouldn’t touch your config: webroot
Some admins don’t want a tool editing their nginx config. That’s what certonly with webroot mode is for. Certbot only places the challenge file in a directory your web server already serves:
sudo certbot certonly --webroot -w /var/www/example \
-d example.com -d www.example.com
Then you add the paths yourself:
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example;
}
In our test, issuing a two-name certificate via webroot took 2.4 seconds, standalone mode 3.1 seconds. Speed is not a factor.
Webroot has two failure modes that look very different:
- The directory doesn’t exist: Certbot aborts immediately with
/var/www/wrong does not exist or is not a directorywithout even contacting the CA. That’s the harmless case. - The directory exists, but nginx doesn’t serve it. This happens when
-wpoints somewhere other than therootin yourserverblock, or when alocationrule sends/.well-known/to your app. Certbot writes the file, Let’s Encrypt gets a 404, and the error saysType: unauthorizedwithreturned 404. It sounds like a permissions problem, but it’s a path problem.
The second case is nasty because it often works the first time and only fails at renewal, after someone has reorganized the nginx config. A robust fix is a dedicated ACME location that always points to the same directory regardless of what the rest of the site does:
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
}
With -w /var/www/letsencrypt in Certbot, this works for every site on the server, whether it’s WordPress, a Node app or a reverse proxy behind it.
Standalone: when no web server is running yet
certbot certonly --standalone -d example.com starts its own small web server on port 80 for validation. That’s handy for servers without a web server, like a mail server that just needs a certificate. The catch comes later: at renewal time, port 80 must be free again. If nginx is running there by then, renewal fails. That’s exactly what happened in our dry run (the test used port 5002; on a real server it’s port 80):
Failed to renew certificate app.gmtest.example with error:
Could not bind TCP port 5002 because it is already in use by another process
If you started with standalone and later installed a web server, switch renewal to webroot, or add --pre-hook "systemctl stop nginx" --post-hook "systemctl start nginx". The second option means a few seconds of downtime on every renewal.
Route 2: create an SSL certificate with Caddy, no Certbot needed
Caddy is a web server that handles HTTPS itself. The complete config for a site with a certificate looks like this:
example.com {
root * /var/www/example
file_server
}
Or as a reverse proxy in front of an app:
app.example.com {
reverse_proxy localhost:3000
}
On startup Caddy fetches the certificate, redirects HTTP to HTTPS and renews in time. There’s no timer, no hook and no reload to forget. Against our test CA, Caddy was serving the site over HTTPS 5.6 seconds after starting, and a plain HTTP request automatically got a 308 redirect to HTTPS.
On our own server, Caddy manages over a thousand certificates, including the one for getmind.io. At the time of writing ours was issued by Let’s Encrypt (intermediate YE1) on September 6, 2026 and is valid until December 5, 2026, the full 90 days. Caddy renews once a third of the lifetime remains. When to pick Caddy and when nginx is covered, with our own benchmarks, in Caddy vs Nginx.
Two things still matter with Caddy:
- The data directory is important. Caddy stores certificates and keys in
~/.local/share/caddyor/var/lib/caddy/.local/share/caddy. If Caddy runs in Docker without a volume for/data, every container restart fetches a new certificate, and eventually you hit the rate limit. That’s why the volume is always included in our Docker Compose examples. - Wildcards need a DNS module. For
*.example.comyou have to build Caddy with your DNS provider’s plugin (xcaddy build --with github.com/caddy-dns/cloudflare). The standard binary only does HTTP and TLS-ALPN validation.
Since August 2025, nginx also has an official route without Certbot: the ngx_http_acme_module from the nginx.org package repositories. It fetches and renews certificates inside nginx itself but, as far as we know, doesn’t do wildcards yet. If you use Ubuntu’s nginx package you don’t have the module and stay with Certbot.
Route 3: create a wildcard certificate with the DNS challenge
A wildcard certificate for *.example.com covers all first-level subdomains. That’s convenient when new subdomains keep appearing. Let’s Encrypt issues wildcards only via DNS validation. We tried with HTTP-01 and Certbot exited with code 1:
Client with the currently selected authenticator does not support
any combination of challenges that will satisfy the CA.
The message doesn’t tell you that the asterisk in the name is the problem. The first time you see it, it’s easy to spend an hour looking in the wrong place.
The right way is a DNS plugin for your provider. For Cloudflare:
sudo apt install python3-certbot-dns-cloudflare
# API token with "Zone:DNS:Edit" for this one zone only
sudo mkdir -p /root/.secrets
echo "dns_cloudflare_api_token = YOUR_TOKEN" | sudo tee /root/.secrets/cloudflare.ini
sudo chmod 600 /root/.secrets/cloudflare.ini
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.com -d '*.example.com'
Important: *.example.com does not cover example.com itself, nor a.b.example.com. That’s why both names are in the command. The single quotes around the asterisk stop the shell from expanding it as a file pattern.
If there’s no plugin for your DNS provider, manual hooks help. That’s a small script that sets the TXT record via API (--manual-auth-hook) and removes it again (--manual-cleanup-hook). Certbot passes the domain in $CERTBOT_DOMAIN and the token in $CERTBOT_VALIDATION. In our test with Pebble’s DNS test server, a wildcard plus the apex domain went through in 2.4 seconds. With a real DNS provider it takes longer, because the record has to reach all nameservers first. The plugins wait 10 to 60 seconds by default for that.
A word from experience: a DNS API token that can edit the whole zone is a very powerful key. Whoever has it can point your domain to a server they control. Give the token permission for the single zone it needs and store it with 600 permissions. We’ve also had a token that simply wasn’t valid for a particular zone (Cloudflare error 9109). In that case HTTP-01 via webroot is the fallback, and that’s perfectly fine.
Route 4: create a self-signed certificate
For an internal test system, a development environment or a device on your home network that’s never reachable from the internet, you can issue a certificate yourself. One command does it:
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout internal.key -out internal.crt -days 365 \
-subj "/CN=internal.local" \
-addext "subjectAltName=DNS:internal.local"
That took us under a hundredth of a second. The certificate encrypts just as well as one from Let’s Encrypt, but nobody except you vouches for it. Browsers show a warning, and tools like curl refuse the connection (exit code 60 in our test) until you explicitly trust it, for example with curl --cacert internal.crt.
The most important line is -addext "subjectAltName=...". Since Chrome 58 (2017), modern browsers only look at the Subject Alternative Name (SAN) field, not the old Common Name. Our test shows why this trap is so persistent: curl 8.5 also accepted the certificate without a SAN, because it still falls back to the Common Name. If you only test with curl, you see green while Chrome fails with NET::ERR_CERT_COMMON_NAME_INVALID.
For local development, mkcert is more convenient. It creates a local CA, installs it in your system and browser trust stores, and issues certificates that work without warnings. Never share that local CA’s key, because with it someone could create valid certificates for any domain on your machine.
Self-signed certificates are not a solution for a public website. Visitors have to click past a security warning, and that trains them to ignore warnings.
cert.pem or fullchain.pem? The chain you can’t see
After issuance, /etc/letsencrypt/live/example.com/ contains four files:
| File | Contents | Use for |
|---|---|---|
cert.pem | only your certificate | almost never directly |
chain.pem | only the intermediate | OCSP stapling, some apps |
fullchain.pem | your certificate + intermediate | this one in nginx, Apache, HAProxy |
privkey.pem | the private key | secret, permissions 600 |
A browser only trusts a short list of root certificates. Your certificate isn’t signed directly by a root, though, but by an intermediate. The server has to send that intermediate along, or the client can’t complete the chain.

We deliberately configured nginx with cert.pem instead of fullchain.pem. Result with curl: SSL certificate problem: unable to get local issuer certificate, exit code 60. With fullchain.pem: fine immediately. The nasty part: desktop browsers often don’t notice, because they fill in missing intermediates from a cache or download them. The site looks perfect in your own browser while apps, scripts, payment-provider webhooks and some Android devices reject the connection.
You can check the chain your server actually sends with:
openssl s_client -connect example.com:443 -servername example.com </dev/null \
| grep -E "^ ?[0-9] s:|Verify return"
You should see at least two lines (0 s: for your domain, 1 s: for the intermediate) and Verify return code: 0 (ok) at the end. For getmind.io there are four entries, because Let’s Encrypt also sends a cross-signed certificate to the older ISRG Root X2.
Video: Cloud Codes compares a Caddy setup with automatic HTTPS to the classic nginx-plus-Certbot routine (July 2026).
Renewing the certificate: where things really go wrong
A Let’s Encrypt certificate is currently valid for 90 days. When installed from the Ubuntu package, Certbot sets up a systemd timer that runs certbot renew twice a day. It renews once fewer than 30 days remain. You can see the timer with:
systemctl list-timers | grep certbot
How such timers work and how to write your own is covered in Create a systemd service. Sounds like “set and forget”. In our tests, though, there were three places where the automation fails silently.

Trap 1: the new certificate is on disk, nginx serves the old one
nginx only reads certificate files at startup or reload. When Certbot renews in certonly mode, the new certificate lands on disk but nginx keeps the old one in memory. We measured it: after certbot renew --force-renewal the file had the new serial 67E4…, while nginx kept serving the old 176E…. You only notice when the old one expires, up to 30 days later.
The --nginx plugin reloads nginx on its own. With certonly you need a deploy hook:
sudo certbot certonly --webroot -w /var/www/letsencrypt -d example.com \
--deploy-hook "systemctl reload nginx"
With the hook, the new serial reached nginx immediately in our test. Certbot saves the hook in /etc/letsencrypt/renewal/example.com.conf (as renew_hook), so it runs on every future renewal. For existing certificates you can set it globally as an executable script in /etc/letsencrypt/renewal-hooks/deploy/. The same applies to every other service that uses the certificate: Postfix, Dovecot, HAProxy, a Node app. Each one needs its reload.
Trap 2: the dry run waits two and a half minutes
The most important command after any change is the dry run:
sudo certbot renew --dry-run
It runs the renewal against Let’s Encrypt’s staging server without replacing real certificates. In our test it first sat silent for 155 seconds. The log explained why: Non-interactive renewal: random delay of 152.6 seconds. When renewing without a terminal, Certbot deliberately waits a random time of up to eight minutes, so that not every server in the world hits Let’s Encrypt at the same second. In a script or CI pipeline that looks exactly like a hung process. For manual tests from scripts there’s --no-random-sleep-on-renew. Leave the delay in place for the timer.
The dry run also immediately uncovered the standalone trap from earlier. Of two certificates, one succeeded and the other failed on the occupied port. That’s exactly what it’s for.
Trap 3: server migration, and Certbot says “all good”
This is the most expensive trap, and we’ve walked into it ourselves. During a server move, a certificate was copied to the new server as files. The site worked, the padlock was green. What was missing was the renewal config. Three months later the certificate expired and the site was unreachable for several hours.
We recreated both typical migration mistakes:
- Copied only the
live/directory (withcp -rL, so real files): nginx can read the files and everything works.certbot renewprintsNo renewals were attempted.and exits with code 0.certbot certificatessaysNo certificates found.As far as Certbot is concerned, the certificate doesn’t exist, because the file underrenewal/is missing. Monitoring that only checks the timer’s exit code will never see an error here. - Copied
live/withcp -a: the files inlive/are only symlinks to../../archive/. Withoutarchive/they point nowhere and nginx won’t even start. That’s the better failure, because you notice it right away.

Our rule since that outage:
- When moving servers, issue fresh certificates on the new server. That takes seconds once DNS points there.
- If that’s not possible, transfer the entire
/etc/letsencryptdirectory, witharchive/,live/,renewal/,accounts/andrenewal-hooks/, for example withtarorrsync -a. Check webroot paths and hooks afterwards. - Then always run
certbot certificates(must list the certificate) andcertbot renew --dry-run(must report success). - Monitor the expiry date independently of Certbot (see the next section).
Monitoring expiry: a check that doesn’t depend on Certbot
Monitoring that asks Certbot whether Certbot works will miss exactly the failures Certbot doesn’t notice. So we check from the outside what the server actually sends:
#!/bin/bash
# cert-check.sh: warns when a served certificate is about to expire
set -euo pipefail
WARN_DAYS=14
for host in example.com www.example.com shop.example.com; do
end=$(echo | timeout 10 openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2) || { echo "ERROR $host: no connection"; continue; }
days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
if [ "$days" -lt "$WARN_DAYS" ]; then
echo "WARNING $host: only $days days left"
fi
done
Run the script once a day and send the output somewhere you’ll actually read it: email, a messenger or your monitoring. How to set up a daily job is in Create a cron job. The 14-day threshold is deliberately below the 30 days at which Certbot renews: by the time the warning fires, automation has been failing for two weeks, and you still have two weeks left.
For a single file on disk there’s a shorter version: openssl x509 -in fullchain.pem -noout -checkend 1209600 returns exit code 1 if the certificate expires within 14 days. But remember trap 1: the file can be new while the server still serves the old certificate. That’s why we prefer checking over the network.
2027: Let’s Encrypt moves to 64 days
If you create an SSL certificate today, you should know about the upcoming changes. On October 7, 2026, Let’s Encrypt announced that starting February 10, 2027, all new certificates will be valid for only 64 days by default. By February 2028 the lifetime drops further to 45 days. The reason is an industry rule under which no public certificate may be valid for more than 47 days from March 2029. At the same time, Let’s Encrypt is cutting the period during which a completed domain validation can be reused from 30 to 10 days.
What that means for you:
- With Caddy or default Certbot you don’t need to do anything. Both renew relative to the lifetime or well in advance. Certbot renews at 30 days remaining, so with 64-day certificates after about 34 days.
- Hard-coded intervals need updating. Let’s Encrypt explicitly recommends grepping scripts, cron jobs and runbooks for numbers like 60, 80 or 83 days. A cron job that “renews every 60 days” will renew too late with 64-day certificates as soon as anything goes wrong once.
- Semi-manual workflows become twice as frequent. If you still copy certificates by hand into a hosting panel, a firewall or a device, you’ll soon be doing it every few weeks. Now is the time to automate that.
- Modern clients ask the CA when to renew. The mechanism is called ACME Renewal Information (ARI). It lets Let’s Encrypt tell a client “please renew now”, for example when certificates have to be revoked early. Whether your client supports ARI depends on its version. Ubuntu 24.04 ships Certbot 2.9.0; newer versions are available via Snap.
Your monitoring script should use a relative threshold too. With 45-day certificates, 14 days of warning is almost a third of the lifetime, which still works.
Rate limits: why you test against staging
Let’s Encrypt limits how many certificates you can get in a given time. For normal websites that’s irrelevant, but it matters when you’re experimenting. The most important limits according to the official documentation:
- 50 certificates per registered domain (e.g.
example.comincluding all subdomains) per 7 days, - 5 certificates for the exact same set of names per 7 days,
- 300 new orders per account per 3 hours,
- 5 failed validations per hostname per hour.
Let’s Encrypt can’t reset a limit you’ve hit; it only refills gradually. If you request a real certificate ten times in a row with a broken nginx config, you lock yourself out for a while. That’s why there’s --test-cert (staging server, certificates without browser trust, much higher limits) and --dry-run. Only get the real certificate once that works. It’s also why we did everything for this article against the Pebble test CA.
The second common cause of rate-limit trouble is Docker without a persistent volume. Every container restart fetches a new certificate for the same names, and after five restarts in a week you’re done.
Common errors when creating a certificate and what they mean
| Message | Cause | Fix |
|---|---|---|
Type: connection, connection refused / Timeout during connect | port 80 closed or firewalled | open port 80 (ufw allow 80), check cloud firewall |
Type: unauthorized, returned 404 | challenge file isn’t where the web server serves from | compare -w and root, use a dedicated ACME location |
Type: dns, NXDOMAIN | DNS record missing or not propagated yet | check A/AAAA record, wait |
does not exist or is not a directory | wrong webroot path | fix the path |
Could not bind TCP port 80 | standalone mode, but a web server is running | switch to webroot or plugin |
does not support any combination of challenges | wildcard with HTTP validation | use a DNS plugin |
too many certificates already issued | rate limit | wait, use --test-cert next time |
Browser: ERR_CERT_COMMON_NAME_INVALID | name missing from certificate (SAN) | list every name with -d, set SAN when self-signing |
curl: unable to get local issuer certificate | server doesn’t send the chain | fullchain.pem instead of cert.pem |
When something fails, always check /var/log/letsencrypt/letsencrypt.log. It contains the CA’s full response, including the URL it tried to fetch.
Security: protecting the private key
The certificate itself is public; every visitor receives it. Only privkey.pem is secret. Whoever has the key can impersonate your website. Certbot creates it with 600 permissions and the live/ and archive/ directories with 700. In our test, Certbot 2.9 generated an ECDSA key on the P-256 curve by default. It’s smaller and faster than RSA and supported by every current client.
A few rules that have served us well:
- Never paste the key into chat, email or a Git repository. If a service running as another user needs to read it, use groups or have a deploy hook create a copy with the right permissions.
/etc/letsencryptbelongs in your backups, encrypted. How we structure backups is in the 3-2-1 backup strategy.- If you suspect the key was stolen: revoke the certificate (
certbot revoke --cert-name example.com) and issue a new one. - HTTPS alone doesn’t protect against everything. What comes next, like HSTS and redirects, is in What is HTTPS?, and against automated attacks on logins, Fail2ban helps.
Frequently asked questions
How do I create an SSL certificate?
The easiest way is Let’s Encrypt. On a server with nginx, install certbot and python3-certbot-nginx and run sudo certbot --nginx -d your-domain.com. With Caddy as your web server, writing the domain name into the config is enough. Either way, the domain must point to your server via DNS and port 80 must be reachable.
Is a Let’s Encrypt SSL certificate really free?
Yes, with no hidden costs. Let’s Encrypt is a nonprofit certificate authority funded by donations and sponsors. The encryption is exactly as strong as with a paid certificate. Commercial providers mainly charge for organization vetting, support and warranties.
How long is a Let’s Encrypt certificate valid?
Currently 90 days. From February 10, 2027, Let’s Encrypt issues 64-day certificates by default, dropping to 45 days by February 2028. With automatic renewal via Certbot or Caddy, you don’t need to change anything.
How do I renew an SSL certificate?
With Certbot it happens automatically through a systemd timer that runs certbot renew twice a day. You can test it manually with sudo certbot renew --dry-run. What matters is that the web server is reloaded after renewal. With --nginx, Certbot does that itself; with certonly you need --deploy-hook "systemctl reload nginx".
Can I create an SSL certificate for an IP address?
For a long time only commercial providers offered that. Let’s Encrypt now offers short-lived certificates (6 days) for IP addresses through a separate profile. You need a current ACME client that supports profiles; the Certbot 2.9 in Ubuntu 24.04 doesn’t. We haven’t tested this ourselves. For a normal website, a domain name is almost always the better choice.
How do I create a wildcard certificate?
Only via DNS validation. With Certbot and your DNS provider’s plugin, for example certbot certonly --dns-cloudflare --dns-cloudflare-credentials ~/.secrets/cloudflare.ini -d example.com -d '*.example.com'. You have to list the apex domain separately because *.example.com doesn’t cover it.
What’s the difference between an SSL and a TLS certificate?
There is none. SSL is the protocol’s old name, TLS the current one. In both cases the certificate is the same X.509 certificate. “SSL certificate” just stuck as a term.
How do I create a self-signed certificate?
With openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -keyout key.pem -out cert.pem -days 365 -subj "/CN=internal.local" -addext "subjectAltName=DNS:internal.local". Without the SAN, modern browsers reject the certificate. Self-signed certificates aren’t suitable for public websites because every visitor sees a warning.
Why does my browser say “not secure” even though I have a certificate?
The most common reasons: the certificate has expired, the name you visited (often www.) isn’t on the certificate, the page loads images or scripts over http:// (mixed content), or the certificate is self-signed. Clicking the icon to the left of the address shows the exact cause.
Can I create an SSL certificate with my web host?
With almost every host, yes, usually with one click in the control panel. Behind the scenes that’s Let’s Encrypt as well. This guide is mainly for your own servers and VPSs. If you’re still deciding whether your own server is worth it, see What is a VPS?.
Do I need a new certificate after moving servers?
It’s the safest option. Switch DNS over and let Certbot or Caddy fetch a fresh certificate on the new server. If you want to bring the old one along, copy the entire /etc/letsencrypt directory and then verify with certbot certificates and certbot renew --dry-run. Copying only the certificate files makes renewal fail silently.
Conclusion: creating an SSL certificate is easy, renewing is the real work
Creating an SSL certificate with Certbot or Caddy takes seconds and costs nothing. For new servers we recommend Caddy, because it doesn’t let the renewal and reload pitfalls arise in the first place. On existing nginx or Apache servers, Certbot with the right plugin is the way to go. Wildcard certificates come via the DNS challenge, and self-signed certificates are for testing and internal networks only.
The real work starts after the first green padlock. Our tests showed where automation fails quietly: a reload that never happens, a port that’s now taken, and a server move after which Certbot reports “all good” because it no longer knows the certificate exists. Three habits protect you: run certbot renew --dry-run after every change, always use a deploy hook with certonly, and run an expiry check from the outside that asks the server, not Certbot. With 64-day certificates from February 2027, these habits become more important, not less.
