---
title: "Nginx and Let's Encrypt: free HTTPS on an Ubuntu or Debian VPS"
description: "Set up nginx as a reverse proxy for your app, open ports 80/443 in UFW and get a free Let's Encrypt certificate with certbot, auto-renewal and an HTTPS redirect."
url: https://tihost.io/en/blog/nginx-letsencrypt-https
language: en
section: "Guides"
published: 2026-10-05
updated: 2026-10-05
publisher: Tihost (https://tihost.io)
---

# Nginx and Let's Encrypt: free HTTPS on an Ubuntu or Debian VPS

> **In short:** To serve a site on a VPS over HTTPS, install nginx, write a server block with your domain and a `proxy_pass` to the app, open ports 80 and 443 and run `sudo certbot --nginx -d example.com`. Certbot gets a free Let's Encrypt certificate, sets up the HTTPS redirect and renews the certificate on a systemd timer.

**Key takeaways:**

- Free HTTPS on a VPS comes from nginx plus certbot: the `certbot` and `python3-certbot-nginx` packages from apt, then `sudo certbot --nginx -d example.com -d www.example.com`.
- Nginx acts as a reverse proxy: it accepts requests on ports 80 and 443 and passes them via `proxy_pass` to the app on `127.0.0.1:port`, so the app itself is not exposed to the internet.
- A Let's Encrypt certificate is valid for 90 days, and the certbot package installs a systemd timer that renews it automatically; test renewal with `sudo certbot renew --dry-run`.
- Certbot gets a certificate only if the domain already points at the server's IP and port 80 is open from outside: in UFW that is `sudo ufw allow 'Nginx Full'`.

> Commands are checked for Ubuntu 22.04/24.04 and Debian 12. You need two things first: a sudo user ([first VPS setup](https://tihost.io/en/blog/vps-first-setup)) and a domain whose A record points at the server IP ([how to point a domain](https://tihost.io/en/blog/domain-to-vps)). In the examples `example.com` is your domain and `3000` is your app's port.

## Step 1. Install nginx

Nginx is in the standard Ubuntu and Debian repositories. After installation the service starts right away and is enabled at boot - the status should read `active (running)`:

```bash
sudo apt update
sudo apt install -y nginx
systemctl status nginx --no-pager
```

## Step 2. Open ports 80 and 443 in UFW

Port 80 is needed for Let's Encrypt domain validation and the redirect, 443 for HTTPS itself. The `Nginx Full` profile opens both:

```bash
sudo ufw allow 'Nginx Full'
sudo ufw status
```

If UFW says there is no profile with that name (it happens on Debian), open the ports directly: `sudo ufw allow 80,443/tcp`. The SSH rule must stay in place - check it in the `ufw status` output.

## Step 3. Create a server block for the domain

A server block describes one site in nginx. Create `/etc/nginx/sites-available/example.com` (for example with `sudo nano /etc/nginx/sites-available/example.com`) with this content - nginx will accept requests for the domain and pass them to the app on `127.0.0.1:3000`:

`/etc/nginx/sites-available/example.com`:

```
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name example.com www.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}
```

- `Host` passes the domain name from the request to the app - without it the app sees `127.0.0.1`.
- `X-Real-IP` and `X-Forwarded-For` carry the visitor's real IP; otherwise every request in the app logs comes from nginx.
- `X-Forwarded-Proto` tells the app whether the request came over HTTP or HTTPS: frameworks use it to build correct links and avoid redirect loops.
- `Upgrade` and `Connection`, together with the `map` block, are needed for WebSocket. The `map` block is declared once per server: for a second site copy only the `server` block.

> The app listens on `127.0.0.1` only, not `0.0.0.0` - then it is reachable from outside only through nginx. Publish a Docker container the same way, `127.0.0.1:3000:3000`, or the port opens around UFW (details in the [Docker guide](https://tihost.io/en/blog/install-docker-ubuntu-debian)). For a plain static site, replace the `location` block with `root /var/www/example.com;`.

## Step 4. Enable the site and test the configuration

A site is enabled with a link in `sites-enabled`. Remove the stock `default` site so it does not catch requests. `nginx -t` checks the syntax before reloading - if it prints `syntax is ok` and `test is successful`, the configuration is safe to apply:

```bash
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
curl -I http://example.com
```

The last command should return your app's response over HTTP. A `502 Bad Gateway` means nginx works but the app on port 3000 is not running.

## Step 5. Get a Let's Encrypt certificate with certbot

Certbot is EFF's official Let's Encrypt client, and the `python3-certbot-nginx` plugin edits the nginx configuration for you. List every name the certificate should cover with `-d`; `--redirect` turns on the HTTP to HTTPS redirect right away:

```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx --redirect -d example.com -d www.example.com
```

On the first run certbot asks for an email and for accepting the Let's Encrypt terms. It then checks that the domain points at this server (a request to port 80), gets the certificate and adds `listen 443 ssl` and the key paths to the server block. More options are on [certbot.eff.org](https://certbot.eff.org/).

## Step 6. Check auto-renewal

A Let's Encrypt certificate is valid for 90 days. The certbot package from apt installs a systemd timer that checks certificates twice a day and renews those close to expiry; after renewal the plugin reloads nginx itself. Make sure renewal will succeed:

```bash
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
sudo certbot certificates
```

`--dry-run` goes through the whole procedure against the Let's Encrypt staging server without touching the real certificate. The second command shows the `certbot.timer` timer and its next run, the third shows the expiry dates of issued certificates.

## Step 7. Check HTTPS and the redirect

An HTTP request should return `301 Moved Permanently` with an `https://` address in the `Location` header, and an HTTPS request should return the app's response with no certificate errors:

```bash
curl -I http://example.com
curl -I https://example.com
```

## Common certbot and nginx errors

| What you see | Cause | What to do |
| --- | --- | --- |
| `DNS problem: NXDOMAIN` or validation hits someone else's IP | DNS has not updated yet or the A record points elsewhere | Check `dig +short example.com` against a public resolver and wait until it returns the server IP |
| `Timeout during connect (likely firewall problem)` | Port 80 is closed from outside: a UFW rule, nginx not listening on 80, or a misconfigured Cloudflare proxy | `sudo ufw allow 'Nginx Full'`, `sudo ss -tlnp` - nginx must listen on `:80` |
| `too many certificates` or `too many failed authorizations` | Let's Encrypt limits on issuances or failed validations were hit | Experiment with `--dry-run` and wait for the limit to reset; current values are in the [Let's Encrypt documentation](https://letsencrypt.org/docs/rate-limits/) |
| `502 Bad Gateway` | The app is not running or listens on another port | `curl http://127.0.0.1:3000` on the server; check the port in `proxy_pass` |
| `conflicting server name` in `nginx -t` | The same domain is defined in two server blocks | Keep the domain in a single file in `sites-enabled` |

Nginx is a convenient front for any app: a [Node.js service](https://tihost.io/en/blog/nodejs-app-on-vps), a [Telegram bot](https://tihost.io/en/blog/telegram-bot-on-vps) webhook (Telegram accepts webhooks over HTTPS only) or [Docker](https://tihost.io/en/blog/install-docker-ubuntu-debian) containers. Nginx with a couple of small sites is fine on 1 vCPU and 2 GB of RAM; a Tihost VPS with full root access is deployed in about 2 minutes.

**Launch a server in 2 minutes.** AMD Ryzen 9, NVMe and DDoS protection in Germany, Finland and Poland. Pay with crypto or card. [Order a Server](https://tihost.io/login)

## FAQ

### How much does a Let's Encrypt certificate cost?

A Let's Encrypt certificate is free: it is issued by a non-profit certificate authority, and certbot gets and renews it automatically. Browsers trust it just like paid DV certificates.

### How often do I need to renew a Let's Encrypt certificate?

A Let's Encrypt certificate is valid for 90 days, but you do not renew it by hand: the systemd timer from the certbot package renews it ahead of time. Test renewal with `sudo certbot renew --dry-run`.

### Why does certbot say Timeout during connect?

Let's Encrypt cannot reach the server on port 80. Open the ports with `sudo ufw allow 'Nginx Full'`, check that nginx is running and that the domain points at this server's IP.

### Can I get a certificate for an IP address without a domain?

The usual certbot and nginx setup needs a domain: the certificate is issued for a name that points at the server. You can buy a domain from any registrar and point it with an A record in minutes.

### How is certbot from apt different from certbot from snap?

The certbot.eff.org site recommends snap, where certbot updates faster. The `certbot` and `python3-certbot-nginx` packages from apt on Ubuntu and Debian work too and update with the system - for a single site there is no difference.

### What is a reverse proxy in nginx?

A reverse proxy is a server that accepts requests from the internet and passes them to an app inside the server. In nginx this is the `proxy_pass http://127.0.0.1:3000;` directive, while nginx itself handles HTTPS, compression and static files.

---

Updated 2026-10-05 · https://tihost.io/en/blog/nginx-letsencrypt-https
