liminfo

Nginx Reverse Proxy SSL Certificate Setup

A practical guide to enabling HTTPS on a Node.js app with Nginx, and automatically issuing/renewing free Let's Encrypt SSL certificates using certbot

nginx reverse proxySSL certificate setupHTTPS configurationLet's Encryptcertbotnginx ssl configreverse proxy setupfree SSL certificateHTTPS redirectsecurity headers

Problem

A Node.js app server (Express) is running on port 3000 over HTTP. For production deployment, HTTPS needs to be applied to the domain (example.com), with Nginx placed in front as a reverse proxy. A free SSL certificate (Let's Encrypt) should be used, and automatic certificate renewal must also be configured. Additionally, automatic HTTP to HTTPS redirection and security headers (HSTS, X-Frame-Options, etc.) need to be set up.

Required Tools

Nginx

A high-performance HTTP server and reverse proxy. Handles static file serving, load balancing, and SSL termination.

Certbot (Let's Encrypt)

The official client that automatically issues and renews free SSL/TLS certificates from the Let's Encrypt CA.

Node.js (Express)

The backend application server. Runs over HTTP behind Nginx, while SSL is handled by Nginx.

UFW / iptables

Firewall management tools. Ports 80 (HTTP) and 443 (HTTPS) must be opened for certbot authentication and HTTPS access.

Solution Steps

1

Install Nginx and configure the firewall

Install Nginx on Ubuntu/Debian and open the required ports in the firewall. Port 80 is needed for certbot's HTTP-01 challenge, and port 443 is needed for the HTTPS service. If the firewall configuration is missed, certbot certificate issuance will fail, so be sure to verify this first.

# Install Nginx (Ubuntu/Debian)
sudo apt update
sudo apt install nginx -y

# Check Nginx status
sudo systemctl status nginx

# Allow Nginx profiles in UFW firewall
sudo ufw allow 'Nginx Full'   # Opens ports 80 + 443 simultaneously
sudo ufw status

# Or specify individual ports
# sudo ufw allow 80/tcp
# sudo ufw allow 443/tcp
2

Configure basic reverse proxy

Create an Nginx site configuration file to set up the reverse proxy to the Node.js app. The proxy_set_header directives must forward original request information (Host, IP, etc.) to the backend. At this stage, configure HTTP (port 80) only first, then add HTTPS after obtaining the SSL certificate.

# /etc/nginx/sites-available/example.com

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

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # Required headers: forward original request information
        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;

        # WebSocket support (if needed)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_cache_bypass $http_upgrade;
    }
}
3

Issue Let's Encrypt SSL certificate with Certbot

Install certbot and use the Nginx plugin to automatically issue an SSL certificate. The certbot --nginx command issues the certificate and automatically modifies the Nginx configuration file at the same time. The domain's DNS A record must point to the server IP for authentication to succeed.

# Install certbot and nginx plugin
sudo apt install certbot python3-certbot-nginx -y

# Enable site and test configuration
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

# Issue SSL certificate (automatically modifies nginx config)
sudo certbot --nginx -d example.com -d www.example.com

# During the interactive prompts:
# - Enter email (for expiration notifications)
# - Agree to terms of service
# - Choose HTTP -> HTTPS redirect (recommended: Yes)

# Verify certificate issuance
sudo certbot certificates
4

HTTPS redirect and final Nginx configuration

Review the configuration automatically set by certbot and add further optimizations as needed. Redirect all HTTP (80) requests to HTTPS (443), and optimize SSL protocols and cipher suites. Using an upstream block also enables load balancing across multiple Node.js instances.

# /etc/nginx/sites-available/example.com (final version)

# upstream (prepared for load balancing)
upstream nodejs_backend {
    server 127.0.0.1:3000;
    # server 127.0.0.1:3001;  # When adding more instances
}

# HTTP -> HTTPS redirect
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

# HTTPS server
server {
    listen 443 ssl;
    server_name example.com www.example.com;

    # SSL certificates (automatically configured by certbot)
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # SSL optimization
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
        proxy_pass http://nodejs_backend;
        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 'upgrade';
        proxy_cache_bypass $http_upgrade;
    }
}
5

Add security headers and configure automatic certificate renewal

Add security headers to defend against attacks such as XSS and clickjacking. Let's Encrypt certificates expire every 90 days, so automatic renewal must be set up via cron or a systemd timer. Installing the certbot package via apt usually registers a systemd timer automatically, but always verify this.

# Add security headers inside the HTTPS server block
# (outside the location block, inside the server block)

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'" always;

# Apply configuration
sudo nginx -t && sudo systemctl reload nginx

# Verify certbot automatic renewal
sudo systemctl status certbot.timer

# Test renewal manually (simulation without actual renewal)
sudo certbot renew --dry-run

# If setting up via cron directly (when systemd timer is unavailable)
# sudo crontab -e
# 0 3 * * * certbot renew --quiet --post-hook "systemctl reload nginx"

Core Code

Final configuration file with Nginx reverse proxy + Let's Encrypt SSL + security headers all applied. Running certbot --nginx to issue the certificate will automatically add the ssl-related lines.

# /etc/nginx/sites-available/example.com
# ========================================
# Core: Nginx + SSL + Reverse Proxy Complete Setup
# ========================================

upstream nodejs_backend {
    server 127.0.0.1:3000;
}

# HTTP -> HTTPS 301 redirect
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name example.com www.example.com;

    # Let's Encrypt SSL
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # Security headers
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    # Reverse proxy
    location / {
        proxy_pass http://nodejs_backend;
        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        'upgrade';
    }
}

Common Mistakes

Certbot HTTP-01 challenge authentication failure due to port 80 not being open in the firewall

Certbot verifies domain ownership by accessing port 80 from Let's Encrypt servers. Always open the port with sudo ufw allow 80/tcp. Also check cloud security groups (AWS Security Group, GCP Firewall Rules) in addition to the OS firewall.

Backend cannot recognize host information due to missing proxy_set_header Host $host

Without the Host header, req.hostname in the Node.js app will show 127.0.0.1, and virtual host routing will fail. Also set X-Real-IP, X-Forwarded-For, and X-Forwarded-Proto headers to forward original client information.

Certificate expires after 90 days due to missing renewal cron/timer

Let's Encrypt certificates expire every 90 days. Check that the auto-renewal timer is active with sudo systemctl status certbot.timer. If not, register certbot renew --quiet --post-hook "systemctl reload nginx" in your crontab.

Security vulnerability from including TLSv1.0 and TLSv1.1 in ssl_protocols

TLS 1.0 and 1.1 have known vulnerabilities and have been deprecated by major browsers. Only specify ssl_protocols TLSv1.2 TLSv1.3;

Related liminfo Services