JH
AboutServicesExperienceCase StudiesBlogsContact
Hire Me
DevOpsNov 20264 min read

Nginx Reverse Proxy With Free SSL on Ubuntu

Put Nginx in front of a Node.js app, then add free Let's Encrypt SSL with Certbot, an HTTPS redirect and automatic renewal.

Nginx Reverse Proxy With Free SSL on Ubuntu

Your app listens on port 3000, but visitors expect your domain on port 80 (and later 443). Nginx sits in front as a reverse proxy: it accepts public traffic and forwards it to the app, which stays private on localhost.

This guide is a copy-paste script for an Ubuntu server: install Nginx, proxy your domain to the app, then add a free Let's Encrypt certificate with Certbot so everything runs on HTTPS.

Install Nginx

Run the steps as root (or prefix each command with sudo):

bash
sudo su -

apt update
apt install -y nginx
systemctl status nginx --no-pager

Open the Firewall

The Nginx Full profile opens ports 80 and 443. The name is case-sensitive, and allow SSH before enabling the firewall, or you will lock yourself out:

bash
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status

Allow WebSocket Upgrades

Next.js dev tools, Socket.IO and similar libraries use WebSockets. This map (created once per server) sends Connection: upgrade only when the client asks for an upgrade:

bash
cat <<'EOF' > /etc/nginx/conf.d/connection-upgrade.conf
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
EOF

Write the Server Block

Set your domain and app port, then generate the site config:

bash
SITE_DOMAIN=example.com
SITE_APP_PORT=3000

cat <<EOF > /etc/nginx/sites-available/${SITE_DOMAIN}
server {
    listen 80;
    listen [::]:80;

    server_name ${SITE_DOMAIN} www.${SITE_DOMAIN};

    location / {
        proxy_pass http://127.0.0.1:${SITE_APP_PORT};
        proxy_http_version 1.1;

        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection \$connection_upgrade;
        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_cache_bypass \$http_upgrade;
    }
}
EOF

The Mistake That Breaks It

The heredoc is unquoted (<<EOF) so that bash fills in ${SITE_DOMAIN} and ${SITE_APP_PORT}. But bash would also replace Nginx's own variables, such as $host and $remote_addr, with empty strings. You would get proxy_set_header Host ; and nginx -t would fail. Escaping them as \$host keeps them for Nginx.

Two smaller choices:

  • 127.0.0.1 instead of localhost: localhost can resolve to the IPv6 address ::1, and the proxy fails if your app only listens on IPv4.
  • $connection_upgrade instead of a fixed 'upgrade': normal requests keep using regular keep-alive connections.

Enable the Site

bash
ln -s /etc/nginx/sites-available/${SITE_DOMAIN} /etc/nginx/sites-enabled/

# Optional: remove the default welcome page
rm -f /etc/nginx/sites-enabled/default

# Long or many domain names need a bigger hash bucket
sed -i 's/# server_names_hash_bucket_size 64;/server_names_hash_bucket_size 64;/' /etc/nginx/nginx.conf

nginx -t && systemctl reload nginx

Always run nginx -t first: it validates the config, and a broken config never reaches the running server.

Start Your App and Test

Start the app on the port you chose, for example npm run start -- -p 3000 for Next.js, and keep it running with a process manager such as PM2 or a systemd service. Then check from your machine:

bash
curl -I http://example.com

A 200 (or your app's redirect) means Nginx is forwarding traffic. A 502 Bad Gateway means Nginx works but nothing is listening on the app port.

Add SSL: Before You Start

  • DNS: example.com and www.example.com must point (A and, if you use IPv6, AAAA records) to this server.
  • Port 80 is open: Let's Encrypt validates the domain over HTTP. The Nginx Full firewall profile opens both 80 and 443.
  • The site already works over HTTP: curl -I http://example.com returns your app.

Install Certbot

bash
sudo apt update
sudo apt install -y certbot python3-certbot-nginx

The python3-certbot-nginx plugin lets Certbot read and edit your Nginx config.

Get the Certificate

bash
sudo certbot --nginx -d example.com -d www.example.com

Certbot asks for an email address (for expiry warnings) and for you to accept the terms. It then proves you control the domain, saves the certificate under /etc/letsencrypt/live/example.com/, and updates your server block to listen on port 443 with that certificate.

It normally also adds a redirect from HTTP to HTTPS. If your site still answers on plain HTTP, run the same command again with --redirect.

Check the result:

bash
sudo nginx -t
curl -I https://example.com

Renewal Is Automatic

Certificates are valid for 90 days, but you do not need to renew them by hand. The apt package installs a systemd timer that runs certbot renew twice a day and renews any certificate that expires within 30 days. With the Nginx plugin, Nginx is reloaded after a renewal.

Confirm the timer exists:

bash
systemctl list-timers | grep certbot

Test the whole renewal process without changing anything:

bash
sudo certbot renew --dry-run

--dry-run only simulates renewal against the Let's Encrypt staging servers. Run it once after setup, and again whenever you change the Nginx config, to be sure renewals will keep working.

Tell Your App It Is Behind HTTPS

The reverse proxy already sends X-Forwarded-Proto, so the app can tell that the original request was HTTPS. Frameworks use this for secure cookies and absolute URLs. In Express, enable it with app.set('trust proxy', 1); Next.js reads it automatically.

Troubleshooting

  • "Connection refused" or timeout during validation: port 80 is closed in the firewall or at your cloud provider.
  • "DNS problem: NXDOMAIN": the domain doesn't point to this server yet, or the DNS change hasn't propagated.
  • Too many failed attempts: Let's Encrypt rate-limits failures. Debug with --dry-run (or --staging) before trying for real again.
Next ArticlePostgreSQL Streaming Replication With Docker Compose