Prerequisites
- A domain with an A record pointing at your instance's IPv4 address. Check with
dig +short app.example.com. - Ports 80 and 443 open in the portal firewall and in the guest firewall.
- An application listening on
127.0.0.1:3000(Node, Go, Python, anything).
Install nginx and Certbot
sudo apt install -y nginx certbot
# AlmaLinux / Rocky:
sudo dnf install -y epel-release && sudo dnf install -y nginx certbot
sudo systemctl enable --now nginx
Ubuntu and Debian packages include a certbot.timer systemd unit. Confirm it with systemctl list-timers | grep certbot. On RHEL-family systems, enable it yourself: sudo systemctl enable --now certbot-renew.timer.
Step 1: an HTTP-only block for validation
sudo mkdir -p /var/www/acme
sudo tee /etc/nginx/conf.d/app.conf >/dev/null <<'EOF'
server {
listen 80;
server_name app.example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://$host$request_uri; }
}
EOF
sudo nginx -t && sudo systemctl reload nginx
On Debian and Ubuntu, remove the default site so it does not answer for your name: sudo rm /etc/nginx/sites-enabled/default.
Step 2: issue the certificate
sudo certbot certonly --webroot -w /var/www/acme \
-d app.example.com --email [email protected] --agree-tos --no-eff-email
Success ends with a path under /etc/letsencrypt/live/app.example.com/. If it fails with a timeout, port 80 is blocked somewhere. If it fails with a 404, the root path in nginx does not match -w.
Step 3: the TLS server block
Append this to the same file:
upstream app_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:TLS:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=300" always;
add_header X-Content-Type-Options nosniff always;
client_max_body_size 20m;
location /static/ {
alias /srv/app/static/;
expires 7d;
access_log off;
}
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_read_timeout 60s;
}
}
The http2 on; directive needs nginx 1.25.1 or newer. Debian 12 ships 1.22, so on that release write listen 443 ssl http2; instead and drop the separate line. Check with nginx -v.
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://app.example.com | head -5
You want HTTP/2 200 (or whatever status your app returns) and the HSTS header in the output.
Step 4: reload nginx after renewal
Certbot replaces the files, but nginx keeps the old certificate in memory until it reloads. Add a deploy hook so it happens automatically:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
nginx -t -q && systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run
The dry run talks to the staging environment and should finish with "Congratulations, all simulated renewals succeeded". Deploy hooks do not fire on a dry run, so test the script on its own once: sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh.
Telling your app it is behind a proxy
The app now sees every request coming from 127.0.0.1 over plain HTTP. Tell your framework to trust the forwarded headers from loopback only: app.set('trust proxy', 'loopback') in Express, ProxyFix in Flask, SECURE_PROXY_SSL_HEADER in Django. Without this, redirects point at http:// and rate limiters see one client.
Bind the app to 127.0.0.1, not 0.0.0.0. Otherwise anyone can reach port 3000 directly and skip TLS and every header nginx adds.
Pitfalls
- Using
cert.peminstead offullchain.pem. Browsers may cope;curl, mobile apps and API clients will reject the missing intermediate. - Proxying WebSockets without
proxy_set_header Upgrade $http_upgrade;andConnection "upgrade"on that location. Use a separatelocationfor the socket path. - Large uploads failing with 413: raise
client_max_body_size. - Renewal silently failing because you later added a redirect that also catches
/.well-known/. Keep the ACME location first in the port 80 block.