# How to set up Nginx as a reverse proxy with Let’s Encrypt and Certbot

> Put apps behind Nginx on Ubuntu or Debian with WebSocket support and forwarded headers, then add free Let’s Encrypt HTTPS with Certbot and automatic renewal.

Difficulty: Intermediate\
Tested on: Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12, Debian 13

Nginx is a fast, widely used web server that many administrators already know. As a reverse proxy it sits on ports 80 and 443 and forwards requests to apps listening on local ports, for example a Docker container published on `127.0.0.1:8080`. Certbot, the Let's Encrypt client maintained by the EFF, obtains a free certificate, edits the Nginx configuration to use it and renews it automatically. This guide installs Nginx from your distribution's repository, writes a reverse proxy server block with WebSocket support and the usual forwarded headers, adds HTTPS with the Certbot snap, verifies renewal, and covers hardening, backups, updates and common errors.

> **Note**
>
> If you only need HTTPS in front of a few apps and do not already use Nginx, [Caddy](/guides/caddy-reverse-proxy) does the same with less configuration. Run only one reverse proxy per server, because each needs ports 80 and 443.

## Prerequisites

- A server running **Ubuntu 24.04 LTS**, **Ubuntu 26.04 LTS**, **Debian 12** or **Debian 13**.
- A non-root user with `sudo` rights and SSH key login: [Secure a new Linux server](/guides/secure-a-new-linux-server) and [Set up SSH keys](/guides/ssh-keys).
- A domain name with an **A record** (and an **AAAA record** if the server has working IPv6) pointing at the server for each hostname, such as `app.example.com`. Let's Encrypt prefers IPv6 when an AAAA record exists, so remove AAAA records the server cannot answer on.
- An app listening on a local port. For Docker apps, publish ports on `127.0.0.1` only: see [Docker Compose basics](/guides/docker-compose-basics).
- Ports 80 and 443 not used by another web server.

Neither Nginx nor Certbot publishes minimum hardware requirements. The values below are a conservative starting point for the proxy alone; size the server for the apps behind it.

| Resource | Minimum (official) | Suggested starting point |
|---|---|---|
| CPU | Not published | 1 vCPU, shared with your apps |
| RAM | Not published | 256 MB free for Nginx and Certbot |
| Disk | Not published | 1 GB free for logs, snaps and certificates |

## Step 1 — Install Nginx

This guide uses the **nginx package from Ubuntu or Debian**. It receives security fixes through your distribution's security updates (and `unattended-upgrades` on Ubuntu), uses the `sites-available` and `sites-enabled` layout that Certbot's nginx plugin handles well, and ships ufw application profiles. The versions are older than upstream (for example 1.24 on Ubuntu 24.04 and 1.28 on Ubuntu 26.04), but they have every feature a reverse proxy needs.

```bash
sudo apt update
sudo apt install nginx
systemctl status nginx --no-pager
curl -I http://localhost
```

The service starts automatically, and `curl` returns `HTTP/1.1 200 OK` from the default welcome page.

> **Tip**
>
> Need a newer Nginx? The nginx.org repository publishes current stable and mainline builds for Ubuntu 22.04, 24.04 and 26.04 and Debian 11, 12 and 13, with setup steps on nginx.org's Linux packages page. Its packages are laid out differently from the distribution ones (there is no `sites-available` directory), so adapt the paths in this guide if you choose it.

## Step 2 — Open the firewall

The distribution package installs ufw profiles for Nginx. `Nginx Full` opens ports 80 and 443:

```bash
sudo ufw app list
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status verbose
```

On Debian, install ufw first with `sudo apt install ufw`. If `ufw app list` shows no Nginx profiles, allow `80/tcp` and `443/tcp` instead. Keep port 80 open after HTTPS works: Let's Encrypt uses it for the HTTP-01 challenge at every renewal.

## Step 3 — Add a WebSocket helper map

Many self-hosted apps use WebSockets for live updates. To proxy them, Nginx must forward the `Upgrade` header and set `Connection` to `upgrade` only when the client asked for an upgrade. A `map` in the `http` context does that once for all sites. Ubuntu's and Debian's `nginx.conf` include every file in `/etc/nginx/conf.d/`, so create one there:

```bash
sudo nano /etc/nginx/conf.d/websocket-upgrade.conf
```

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

## Step 4 — Create a server block for your app

Create a configuration file named after the hostname:

```bash
sudo nano /etc/nginx/sites-available/app.example.com
```

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    # largest upload the app accepts; the Nginx default is 1m
    client_max_body_size 50m;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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 X-Forwarded-Host $host;

        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 300s;
    }
}
```

What the directives do:

- `server_name` must match the hostname exactly; Certbot uses it to find the block.
- `proxy_pass` without a path forwards the request URI unchanged to the app.
- `proxy_http_version 1.1` is required for WebSockets on Nginx versions before 1.29.7, which includes every distribution package listed above. Newer versions use 1.1 by default.
- `Host` passes the original hostname instead of `127.0.0.1`. The `X-Forwarded-*` and `X-Real-IP` headers tell the app the visitor's address and that the original request used HTTPS.
- `client_max_body_size` raises the upload limit. With the default of 1 MB, larger uploads fail with `413 Request Entity Too Large`.
- `proxy_read_timeout` keeps idle WebSocket and long-polling connections open for 5 minutes instead of the default 60 seconds.

> **Warning**
>
> If you add a `proxy_set_header` inside a nested `location`, Nginx drops every `proxy_set_header` inherited from the outer level. Repeat the full set of headers in each `location` that defines its own.

Enable the site, test the syntax and reload:

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

`nginx -t` must report `syntax is ok` and `test is successful`. The `curl` request should return your app's response over plain HTTP. For each further app, repeat this step with a new file, hostname and port.

## Step 5 — Install Certbot

The Certbot team recommends installing Certbot as a snap. Ubuntu Server includes snapd. On Debian, install it first, then log out and back in (or reboot) so the snap paths are set:

```bash
sudo apt update
sudo apt install snapd
```

Remove any Certbot package from apt so that the `certbot` command runs the snap, then install Certbot and link the command:

```bash
sudo apt remove certbot
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot
certbot --version
```

If Certbot was never installed with apt, the first command just reports that the package is not installed.

## Step 6 — Get a certificate with the nginx plugin

Request a certificate for the hostname and let Certbot update the server block:

```bash
sudo certbot --nginx -d app.example.com
```

The first run asks for an email address and for your agreement to the Let's Encrypt terms. Certbot then proves control of the domain over port 80, saves the certificate under `/etc/letsencrypt/live/app.example.com/`, adds `listen 443 ssl` and the certificate paths to your server block, and creates a separate port 80 block that redirects all HTTP requests to HTTPS. To cover several hostnames in one certificate, repeat `-d`, for example `-d example.com -d www.example.com`.

Verify the result:

```bash
sudo certbot certificates
curl -I http://app.example.com
curl -I https://app.example.com
```

`certbot certificates` lists the domains, the expiry date and the file paths. The HTTP request returns `301 Moved Permanently` to the HTTPS address, and the HTTPS request returns your app's response.

## Step 7 — Check automatic renewal

The Certbot snap installs a systemd timer that runs `certbot renew` regularly. Each run renews certificates that are close to expiry; since Certbot 4.0, that means less than a third of their lifetime is left. Check the timer and simulate a renewal against the Let's Encrypt staging environment:

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

The dry run ends with `Congratulations, all simulated renewals succeeded`. Because the certificate was installed with the nginx plugin, Certbot reloads Nginx after every real renewal.

Automatic renewal matters more every year. Let's Encrypt certificates are valid for 90 days today; Let's Encrypt has announced 64-day certificates from February 2027 and 45-day certificates from February 2028. It also stopped sending expiry reminder emails in June 2025, so check `sudo certbot certificates` now and then or use an external certificate monitor.

## Step 8 — Harden the HTTPS site

A few small changes make the setup tighter:

- **Hide the version number.** In `/etc/nginx/nginx.conf`, uncomment `server_tokens off;` in the `http` block.
- **Turn on HTTP/2.** On Nginx 1.25.1 and newer (Ubuntu 26.04, Debian 13), add `http2 on;` to the HTTPS server block. On Ubuntu 24.04 and Debian 12, add `http2` after `ssl` on the `listen 443 ssl` lines that Certbot wrote (IPv4 and IPv6) instead. Check your version with `nginx -v`.
- **Consider HSTS carefully.** HSTS tells browsers to use HTTPS only for this hostname for a set time. Start with a short lifetime inside the HTTPS server block and raise it only after everything works:

```nginx
add_header Strict-Transport-Security "max-age=300" always;
```

> **Warning**
>
> Once a browser has seen an HSTS header, it refuses plain HTTP for that hostname until `max-age` expires, even if you remove the header or the certificate breaks. Do not add `includeSubDomains` or `preload` unless every subdomain serves valid HTTPS. Certbot's `--hsts` option sets a long max-age immediately, so prefer the manual approach above.

After each change, run `sudo nginx -t` and `sudo systemctl reload nginx`.

## Back up and restore

Two directories hold the whole setup:

- `/etc/nginx/` — `nginx.conf`, `conf.d/` and all server blocks,
- `/etc/letsencrypt/` — certificates, private keys, the ACME account and the renewal configuration.

Archive both, keeping the symbolic links inside `/etc/letsencrypt/live/` intact:

```bash
sudo mkdir -p /opt/backups
sudo tar czf /opt/backups/nginx-letsencrypt-$(date +%F).tar.gz /etc/nginx /etc/letsencrypt
```

To restore on a new server, install Nginx and Certbot as in Steps 1 and 5, unpack the archive, then test the configuration and renewal:

```bash
sudo tar xzf /opt/backups/nginx-letsencrypt-2026-10-09.tar.gz -C /
sudo nginx -t
sudo systemctl reload nginx
sudo certbot renew --dry-run
```

The archive contains private keys, so keep it with restricted permissions and copy it off the server.

## Update Nginx and Certbot

Nginx updates come with your normal system updates. Read the changelog of your distribution package for anything that touches your configuration, then upgrade:

```bash
sudo apt update
sudo apt upgrade
nginx -v
sudo nginx -t
```

Snaps refresh automatically in the background, so Certbot stays current. Check the installed version and force a refresh if needed:

```bash
snap list certbot
sudo snap refresh certbot
```

## Troubleshooting

### 502 Bad Gateway

Nginx cannot reach the app. Look in `/var/log/nginx/error.log` for `connect() failed (111: Connection refused) while connecting to upstream`, then test the app directly with `curl -I http://127.0.0.1:8080`. Check that the container or service runs and that the port in `proxy_pass` matches the published port.

### 413 Request Entity Too Large

The upload is larger than `client_max_body_size`. Raise the value in the server block, for example to `100m`, then test and reload Nginx. Some apps have their own upload limit as well.

### WebSockets fail or disconnect after about a minute

Check that the `map` from Step 3 exists, that the `location` sends both the `Upgrade` and `Connection` headers, and that `proxy_http_version 1.1` is set. Idle connections are closed after `proxy_read_timeout`, 60 seconds by default; raise it or let the app send WebSocket pings.

### Could not automatically find a matching server block

Certbot found no server block whose `server_name` matches the `-d` hostname. Fix the `server_name` line, make sure the site is linked into `sites-enabled`, run `sudo nginx -t` and `sudo systemctl reload nginx`, then run Certbot again.

### Certbot fails with Timeout during connect or a DNS problem

Let's Encrypt could not reach `http://app.example.com/.well-known/acme-challenge/` from the internet. Check the A and AAAA records with `dig +short`, the ufw rules, any external firewall, and that port 80 is served by Nginx. Repeated failures count against the rate limit of 5 failed validations per hostname per hour, so fix the cause before retrying, or test with `--dry-run` first.

### Redirect loop or the app generates http:// links

The app does not know the original request used HTTPS. Make sure `X-Forwarded-Proto` is set as in Step 4 and configure the app to trust the proxy at `127.0.0.1`. If a CDN sits in front of the server, set its TLS mode so that it connects to the server over HTTPS, not HTTP.

## Next steps

- Prefer less configuration? Compare with [Caddy](/guides/caddy-reverse-proxy).
- Manage proxy hosts in a web interface with [Nginx Proxy Manager](/guides/nginx-proxy-manager).
- Learn how app guides use containers in [Docker Compose basics](/guides/docker-compose-basics).
- Find a server for your apps on the [Docker hosting](/docker-hosting) page.
- Read Nginx's own [WebSocket proxying notes](https://nginx.org/en/docs/http/websocket.html) and the [Certbot user guide](https://eff-certbot.readthedocs.io/en/stable/using.html).

## Frequently asked questions

### Should I install Certbot with snap or apt?

The Certbot team recommends the snap for most users, because it always carries the current Certbot release and renews itself. Ubuntu and Debian also package Certbot, but distribution packages tend to fall behind on LTS releases.

### Do I need to reload Nginx after Certbot renews a certificate?

No. When the certificate was installed with the nginx plugin, Certbot reloads Nginx after each successful renewal. Use a deploy hook only if other services also read the certificate files.

### Why does my app still see 127.0.0.1 as the client address?

Nginx passes the visitor address in the X-Real-IP and X-Forwarded-For headers. The app must be told to trust the proxy at 127.0.0.1 and read those headers; most frameworks and self-hosted apps have a trusted proxy setting for this.

### Can I get a wildcard certificate with certbot --nginx?

No. Wildcard certificates require the DNS-01 challenge, which needs a DNS plugin or manual TXT records. The nginx plugin uses the HTTP-01 challenge, so request one certificate per hostname or list several names with -d.

### Will Let’s Encrypt email me before a certificate expires?

No. Let’s Encrypt stopped sending expiration emails in June 2025. Rely on the renewal timer, test it with certbot renew --dry-run and monitor your certificates yourself.

---

Source: <https://hyperdc.com/guides/tutorials/nginx-reverse-proxy-certbot>\
Updated: 2026-10-09
