Nginx + Let's Encrypt on a Single VPS: From Fresh Ubuntu to HTTPS in 20 Minutes
Cold-start walkthrough for issuing your first Let's Encrypt certificate with certbot --nginx on a fresh Ubuntu VPS. Covers nginx repo choice, the HTTP-01 server block, and the four files certbot rewrites.
Nginx + Let's Encrypt on a Single VPS: From Fresh Ubuntu to HTTPS in 20 Minutes
You just spun up an Ubuntu VPS. Port 22 is open, SSH works, and your-site.example.com resolves to the box. The next twenty minutes get you to a working https://your-site.example.com plus a mental model of what certbot actually did. No wildcards, no DNS-01, no Ansible. This is the cold-start path.
Three decisions matter on the way: which nginx package you install, the minimal server block that lets Let's Encrypt prove you own the domain, and which certbot installer fits your operator workflow. Get those right and the renewal happens on its own for the next several years.
Pick the nginx that fits your renewal cycle
Ubuntu's main repo ships nginx 1.24+ on 24.04 LTS and 1.18+ on 22.04 LTS. Both receive security backports for the life of the LTS. Running apt install nginx gives you a binary that survives unattended-upgrades without surprise breakage. That is the right default for a single VPS where you do not need bleeding-edge mainline.
The upstream nginx packages at https://nginx.org/en/linux_packages.html ship two channels: stable and mainline. Mainline lands new features and module updates first; stable freezes feature work and cherry-picks fixes. The upstream repo matters when you need a module the distro has not enabled (the brotli filter, http_v3 / QUIC, ngx_http_geoip2) or a directive that only appeared in nginx 1.27+. For a fresh VPS hosting a static site or a single FastAPI app, you do not need any of that.
A practical rule: start with apt install nginx. Switch to the upstream repo later if you hit a missing feature. Flipping back and forth wastes more time than picking either one and sticking with it.
sudo apt update
sudo apt install -y nginx
sudo systemctl enable --now nginx
curl -I http://localhost
That last curl should return HTTP/1.1 200 OK and Server: nginx/.... If port 80 is not open at the firewall, fix that before going further. On Ubuntu with ufw enabled, sudo ufw allow 'Nginx Full' opens 80 and 443 at once.
The minimal server block for HTTP-01
Let's Encrypt's HTTP-01 challenge works by placing a token at http://your-site.example.com/.well-known/acme-challenge/<random> and asking the Let's Encrypt servers to fetch it. If they get the right body back, you have proven control. The protocol is documented at https://letsencrypt.org/docs/challenge-types/. What matters for first-cert setup is that your nginx config must serve port 80 on the exact server_name you are about to request a cert for.
The default nginx config on Ubuntu has a default_server block that accepts any hostname. That is enough for the first certbot run, but it produces brittle configs once you add a second site. Lay down a dedicated server block per domain from day one:
# /etc/nginx/sites-available/your-site.example.com
server {
listen 80;
listen [::]:80;
server_name your-site.example.com;
root /var/www/your-site;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Symlink it into sites-enabled, drop the default, and reload:
sudo ln -s /etc/nginx/sites-available/your-site.example.com \
/etc/nginx/sites-enabled/your-site.example.com
sudo rm /etc/nginx/sites-enabled/default
sudo mkdir -p /var/www/your-site
echo "<h1>placeholder</h1>" | sudo tee /var/www/your-site/index.html
sudo nginx -t && sudo systemctl reload nginx
Visit http://your-site.example.com from your laptop. You should see the placeholder. If you get connection refused or a 404, fix it now. Certbot will fail with a confusing TLS-flavoured error if the plain HTTP path is broken, so verifying HTTP first saves a long debugging detour.
Three ways to install certbot
The Electronic Frontier Foundation maintains certbot and publishes installation guidance at https://eff-certbot.readthedocs.io/en/stable/. On Ubuntu there are three reasonable installers.
Snap is the option the EFF actually recommends. It ships a self-updating certbot that picks up plugin updates without operator intervention. The downside is that snap pulls in snapd and runs a background daemon. On a single-purpose VPS that is fine; on a minimal-footprint server it adds about 200 MB of disk and an extra process tree.
Apt gives you certbot and python3-certbot-nginx from the distro repos. The package is older than snap by a few minor versions, which matters once a year when EFF ships a new plugin or fixes a renewal-related bug. For most operators the gap is invisible. For anyone running DNS-01 with a niche provider, the gap can be the difference between a working plugin and a missing one.
Pipx installs certbot into an isolated Python virtualenv and exposes the binary on PATH. It is the middle ground: newer than apt, fully under your control, no snap daemon. Renewal hooks still work because the systemd unit calls /usr/local/bin/certbot regardless of how it got there.
For a first-time setup, snap is the safest pick. You are optimising for "the cert renews in 60 days without me touching it" rather than for a minimal binary surface:
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
If snap is off the table for any reason (LXC containers without snap support, hardened images that strip it out), sudo apt install certbot python3-certbot-nginx gets you to the same place with a slightly older binary.
The first issuance
With nginx serving the HTTP-01 path and certbot installed, run the nginx plugin:
sudo certbot --nginx \
-d your-site.example.com \
--email you@example.com \
--agree-tos \
--no-eff-email \
--redirect
Each flag earns its place:
--nginxtells certbot to use the nginx plugin, which reads your config to find the right server block and writes the TLS directives back into it.-dlists the domains to include. You can pass multiple-dflags to get a SAN cert covering several names. Stick to one domain for the first run.--emailregisters a recovery contact with Let's Encrypt and enables expiry warnings. Use a real address you check.--no-eff-emailopts you out of the EFF newsletter. Skip if you want their updates.--redirectrewrites the port-80 server block so all HTTP traffic 301s to HTTPS. The alternative is--no-redirect, which leaves port 80 serving plain content.
Certbot prints progress as it negotiates with the ACME server. A successful run takes 5 to 15 seconds depending on Let's Encrypt's load. Be aware of the rate limits at https://letsencrypt.org/docs/rate-limits/: 50 certificates per registered domain per week and 5 duplicate certificates per week. First issuances never hit these; the staging environment exists for testing automation that might.
The four files certbot rewrote
This is the mental model that pays for itself the first time renewal misbehaves. Certbot touches exactly four locations, and knowing them turns a panicked 3 AM debugging session into a five-minute fix.
1. /etc/letsencrypt/live/your-site.example.com/
This directory holds symlinks to the current cert files:
cert.pem -> ../../archive/your-site.example.com/cert1.pem
chain.pem -> ../../archive/your-site.example.com/chain1.pem
fullchain.pem -> ../../archive/your-site.example.com/fullchain1.pem
privkey.pem -> ../../archive/your-site.example.com/privkey1.pem
The actual cert material lives in /etc/letsencrypt/archive/. Renewal writes cert2.pem and privkey2.pem into archive/ and re-points the symlinks. Your nginx config references the live/ paths, so it always serves the latest cert without an editor pass. Back up /etc/letsencrypt/ whole; restoring it on a new VPS replays the renewal config and continues the same cert lineage.
2. /etc/letsencrypt/renewal/your-site.example.com.conf
This file records the renewal parameters: the domains in the cert, the plugin used (installer = nginx, authenticator = nginx), the ACME server URL, the account ID. If you ever need to change the renewal behaviour (move to DNS-01, switch the email address, add a --deploy-hook), edit this file. The format is documented in the certbot reference linked above.
3. The nginx server block at /etc/nginx/sites-available/your-site.example.com
Certbot added these lines:
listen 443 ssl;
listen [::]:443 ssl;
ssl_certificate /etc/letsencrypt/live/your-site.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-site.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
It also moved your original listen 80 lines into a second server block that 301-redirects to HTTPS (because of --redirect). Read the file once after the first issuance. You will recognise the same shape in every future site you provision, which means the next few articles in this series get easier the more familiar this layout becomes.
4. The systemd timer
Snap installs snap.certbot.renew.timer; apt and pipx install certbot.timer. Either way, the unit runs twice daily and calls certbot renew for every cert in /etc/letsencrypt/renewal/. Let's Encrypt issues certs valid for 90 days, and certbot renews when 30 days or fewer remain. Verify with:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
The --dry-run flag exercises the full renewal flow against Let's Encrypt's staging API without consuming production rate limits or rotating your live cert. Run it once on day one. Run it again after any major nginx config change. If it fails, the renewal a month from now will fail too, and you want to know now rather than at expiry.
Verifying the result
Reload nginx after certbot finishes (it usually does this for you, but verify):
sudo nginx -t && sudo systemctl reload nginx
curl -I https://your-site.example.com
The response should include HTTP/2 200 and strict-transport-security if you opted into HSTS during certbot's prompts. Visit the URL in a browser and check the padlock. The cert chain should show "Issued by: R10" or "R11", or whichever Let's Encrypt intermediate is current.
If the browser complains about a name mismatch, certbot wrote the wrong server_name. Edit the file and re-run sudo certbot --nginx -d your-site.example.com to reconfigure without rotating the underlying cert.
What you have, what comes next
Twenty minutes in, you have:
- A working
https://your-site.example.com - A renewal timer that fires twice a day for the life of the VPS
- A four-file mental model that makes the next failure debuggable
- A nginx server block you can copy as a template for the second site
This was the cold-start path. Later articles in this series cover what happens when you outgrow a single cert: wildcard issuance through the DNS-01 challenge, multi-domain SAN certs without downtime, and automating the whole flow from a deployment pipeline so a new subdomain ships with HTTPS the moment its DNS record propagates. Each of those builds on the four-file model above. Get comfortable with it before moving on.