Skip to content
Current coupon:10% offUse it in your order

Website on a VPS: Nginx and a Let's Encrypt certificate (HTTPS)

Install Nginx, serve a static website or set up a reverse proxy for an app (Node.js and others), and get a free Let's Encrypt certificate that renews automatically.

  • advanced
  • ~25 min
  • Updated 2 Oct 2026

Nginx is a fast web server. We'll use it to serve a static website or put it in front of your app as a reverse proxy, and turn on HTTPS with a Let's Encrypt certificate that renews itself.

What you'll need

  • A StormHost VPS with Ubuntu 24.04 or Debian 13
  • A domain pointed at the VPS IP address (an A record, optionally AAAA); see Domain on a VPS and Cloudflare
  • An SSH connection as root (guide)
  • About 25 minutes

How it works#

The browser connects to port 80 (HTTP) or 443 (HTTPS) of your VPS. Based on the domain in the request, Nginx picks the right server block (the configuration of one website) and either sends a file from disk (static website) or passes the request to an app running on another port (reverse proxy).

The HTTPS certificate is issued for free by Let's Encrypt. The Certbot program verifies that you control the domain (Let's Encrypt connects to port 80 and downloads a check file), downloads the certificate, sets it up in Nginx and then renews it on its own.

Prerequisites: DNS and ports#

  1. Check that the domain points to the VPS. On the VPS, run:

    bash
    getent hosts example.cz

    You'll see your VPS's IP address (from the Network tab). If not, fix the DNS and wait for the change to propagate.

  2. Open ports 80 and 443:

    • Firewall in the customer area (if you use the Block everything except allowed mode): rule Inbound · Allow · TCP · port 80,443. Guide: Firewall in the customer area.
    • UFW on the VPS (if you use it): ufw allow 80/tcp and ufw allow 443/tcp.

Cloudflare: get the certificate with the gray cloud

If your domain is managed in Cloudflare, keep the records set to DNS only (gray cloud) while getting the certificate. Once you have it, you can switch to Proxied; see Cloudflare and SSL modes.

Installing Nginx#

Works on both Ubuntu 24.04 and Debian 13:

bash
apt update
apt -y install nginx
  • apt -y install nginx – installs Nginx. The service starts right away and is enabled at VPS boot.

Check: open http://<VPS_IP_ADDRESS> in your browser. Replace <VPS_IP_ADDRESS> with the IPv4 address from the Network tab (for example http://203.0.113.10). You'll see the “Welcome to nginx!” page.

How the configuration is organized#

PathWhat it's for
/etc/nginx/nginx.confThe main configuration. You usually don't change it. It loads the files from the directories below.
/etc/nginx/sites-available/Configurations of individual websites (server blocks). A file here exists but isn't active.
/etc/nginx/sites-enabled/Links (symlinks) to files in sites-available. Nginx only loads what's here.
/etc/nginx/conf.d/Additional configuration; all *.conf files are loaded.
/var/www/The usual place for website files.
/var/log/nginx/Logs: access.log (requests) and error.log (errors).

So you enable a website by creating a file in sites-available and a link to it in sites-enabled. You disable it by deleting the link.

Static website#

A static website is a set of ready-made HTML, CSS, JavaScript and image files.

  1. Create the website directory and a test page:

    bash
    mkdir -p /var/www/example.cz/html
    echo '<h1>It works</h1>' > /var/www/example.cz/html/index.html
    • mkdir -p – creates the directory including its parents.
    • echo '…' > …/index.html – writes a simple page to index.html.

    Later, upload your own website files into this directory over SFTP (guide) or in the Files tab.

  2. Create the website configuration:

    bash
    nano /etc/nginx/sites-available/example.cz
    /etc/nginx/sites-available/example.cznginx
    server {
        listen 80;
        listen [::]:80;
        server_name example.cz www.example.cz;
    
        root /var/www/example.cz/html;
        index index.html;
    
        location / {
            try_files $uri $uri/ =404;
        }
    }
DirectiveWhat it doesWhen to change
listen 80;Listens on port 80 for IPv4.Never (Certbot adds HTTPS).
listen [::]:80;The same for IPv6.Delete it if the VPS has no IPv6.
server_name example.cz www.example.cz;The domains this block applies to. Nginx uses them to pick the website.List your domains separated by spaces.
root /var/www/example.cz/html;The directory with the website files.If the files live elsewhere.
index index.html;Which file to send when the URL ends with /.For PHP websites, index.php index.html.
location / { … }Rules for all URLs of the website.–
try_files $uri $uri/ =404;Tries to find a file, then a directory; if neither exists, returns a 404 error.For single-page apps (React, Vue), try_files $uri $uri/ /index.html;.
  1. Enable the website, disable the default welcome page, test the configuration and load it:

    bash
    ln -s /etc/nginx/sites-available/example.cz /etc/nginx/sites-enabled/
    rm /etc/nginx/sites-enabled/default
    nginx -t
    systemctl reload nginx
    • ln -s … /etc/nginx/sites-enabled/ – creates the link, so Nginx starts loading the website.
    • rm /etc/nginx/sites-enabled/default – disables the default website (the file stays in sites-available).
    • nginx -t – checks the syntax of all configurations. It must end with syntax is ok and test is successful.
    • systemctl reload nginx – loads the new configuration without dropping connections.

Check: http://example.cz shows the “It works” heading from the test page.

Reverse proxy for an app#

An app (Node.js, Python, Java, a Docker container) usually runs on its own port, for example 3000. You put Nginx in front of it as a reverse proxy: it accepts requests on port 80/443 and forwards them to the app. Benefits: Nginx handles HTTPS, the app doesn't have to be visible from the internet, and one VPS can run several apps under different domains.

Let the app listen on 127.0.0.1 only

Configure the app to listen on 127.0.0.1:3000, not 0.0.0.0:3000. Then it's only reachable through Nginx and nobody can bypass it by connecting to the port directly. With Docker, publish the port as 127.0.0.1:3000:3000 (see Docker and Docker Compose).

Configuration (instead of the static website, or as another file for a different domain, for example app.example.cz):

/etc/nginx/sites-available/app.example.cznginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    listen [::]:80;
    server_name app.example.cz;

    client_max_body_size 20m;

    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;
        proxy_read_timeout 300s;
    }
}
DirectiveWhat it doesWhen to change
map $http_upgrade $connection_upgrade { … }A helper variable for websockets: when the browser asks to upgrade the connection, upgrade is sent, otherwise close. It's needed only once in the whole configuration (a second copy makes nginx -t report a duplicate).Never.
client_max_body_size 20m;The maximum size of an uploaded request. The default is 1 MB; larger uploads fail with error 413.Depending on what the app accepts.
proxy_pass http://127.0.0.1:3000;Where requests are forwarded.Your app's port.
proxy_http_version 1.1;Talks to the app over HTTP/1.1. Older Nginx versions otherwise use HTTP/1.0, which doesn't work with websockets.Never.
proxy_set_header Host $host;The app sees the original domain from the browser.Never.
proxy_set_header X-Real-IP …, X-Forwarded-For …The app sees the visitor's real IP address instead of 127.0.0.1.Never.
proxy_set_header X-Forwarded-Proto $scheme;The app knows the visitor came over HTTPS (important for redirects and cookies).Never.
Upgrade, ConnectionPass websocket requests through (chat, live updates, Socket.IO).Never.
proxy_read_timeout 300s;How long to wait for the app's response. The default 60 s closes idle websockets.Increase it for long-running operations.

Enabling it works the same as for the static website:

bash
ln -s /etc/nginx/sites-available/app.example.cz /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx

To keep the app running permanently and start it automatically after a VPS reboot, set it up as a service: Run an app 24/7 with systemd.

HTTPS with Certbot#

Installing Certbot#

bash
apt -y install certbot python3-certbot-nginx
  • certbot – the Let's Encrypt client.
  • python3-certbot-nginx – a plugin that can verify the domain through Nginx and edit its configuration itself.

Certbot from snap

The Certbot authors recommend installing it via snap, which always has the latest version. The distribution package (Ubuntu 24.04 has Certbot 2.9, Debian 13 has 4.0) works the same for normal use and gets updated with your other packages. If you want snap, follow certbot.eff.org/instructions (opens in a new window) and uninstall the apt packages first so you don't end up with two Certbots.

Getting a certificate#

bash
certbot --nginx -d example.cz -d www.example.cz
  • --nginx – verification through Nginx and automatic editing of its configuration.
  • -d example.cz -d www.example.cz – the domains you want the certificate for. Each must have a DNS record pointing to the VPS and must be listed in the server_name of some website.

On the first run, Certbot asks these questions:

QuestionWhat to answerWhy
Email (Enter email address…)Your addressLet's Encrypt sends important notices there. Since 2025 it no longer sends expiry reminders; renewal is handled automatically.
Please read the Terms of Service… Do you agree?YWithout agreeing to the terms you won't get a certificate.
Would you be willing… to share your email address with the Electronic Frontier Foundation…Y or NOnly for EFF news; it has no effect on the certificate.

Result: Successfully deployed certificate for example.cz to /etc/nginx/sites-enabled/example.cz and Congratulations! You have successfully enabled HTTPS….

What Certbot changed in the configuration#

Open the website configuration (nano /etc/nginx/sites-available/example.cz). Lines marked # managed by Certbot were added by Certbot:

Added lineWhat it does
listen 443 ssl;The website also listens on port 443 with TLS.
listen [::]:443 ssl ipv6only=on;The same for IPv6. Certbot adds it when the block contains listen [::]:80. It adds the ipv6only=on option (listen on IPv6 for IPv6 connections only) only for the first website on the VPS; for the others the line is without it.
ssl_certificate /etc/letsencrypt/live/example.cz/fullchain.pem;The certificate (including intermediate certificates).
ssl_certificate_key /etc/letsencrypt/live/example.cz/privkey.pem;The certificate's private key. Never give it to anyone.
include /etc/letsencrypt/options-ssl-nginx.conf;Certbot's recommended TLS settings (protocols, ciphers).
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;Parameters for the key exchange.
a new server block with return 301 https://…Redirects HTTP to HTTPS. Certbot adds it automatically.

Don't edit these lines by hand unless you know exactly why. Certbot relies on them when renewing.

Automatic renewal#

A Let's Encrypt certificate is valid for 90 days (as of October 2026; Let's Encrypt is gradually shortening the validity). The Certbot package installed a timer that checks the certificates twice a day and renews those with roughly a third of their validity left. The Nginx plugin reloads Nginx after a renewal by itself.

Check that the timer exists and that renewal works:

bash
systemctl list-timers | grep certbot
certbot renew --dry-run
  • systemctl list-timers | grep certbot – prints a line with certbot.timer and the time of the next run.
  • certbot renew --dry-run – tries a renewal against the staging server without touching the real certificates. It must end with Congratulations, all simulated renewals succeeded.

certbot certificates lists your certificates and their validity.

Let's Encrypt limits

Let's Encrypt issues at most 5 certificates for the same set of domains within 7 days, and after 5 failed domain validations within an hour it blocks you temporarily. While debugging, use --dry-run (staging server) and only run the real command once the test passes.

Cloudflare and SSL modes#

If your website runs through the Cloudflare proxy (orange cloud), two connections are encrypted: between the visitor and Cloudflare, and between Cloudflare and your VPS. The second one is controlled by the SSL/TLS mode in Cloudflare:

ModeConnection from Cloudflare to the VPSRecommendation
OffNo encryptionDon't use.
FlexibleUnencrypted HTTP to port 80Don't use. Combined with the HTTPS redirect in Nginx it creates an infinite loop (ERR_TOO_MANY_REDIRECTS).
FullSame protocol as the visitor; for HTTPS the VPS certificate isn't verifiedOnly temporarily.
Full (strict)Same protocol as the visitor; for HTTPS the VPS certificate is verifiedRecommended. Your Let's Encrypt certificate is valid, so it works.

After switching to Proxied, run certbot renew --dry-run and check that renewal also works through the Cloudflare proxy. If it fails, make sure you aren't blocking port 80 with your own rules in Cloudflare. A permanent solution for advanced users is to verify the domain via DNS with the python3-certbot-dns-cloudflare plugin (see the Certbot documentation (opens in a new window)).

Basic security#

  1. Hide the Nginx version. On Debian 13, /etc/nginx/nginx.conf already contains server_tokens off; by default. On Ubuntu 24.04, open the file (nano /etc/nginx/nginx.conf) and in the http block remove the # in front of # server_tokens off;. Nginx then won't reveal its version in headers or on error pages.

  2. Security headers. You can add these to the server block with port 443:

    nginx
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options SAMEORIGIN always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;
    • X-Content-Type-Options nosniff – the browser won't guess the file type (protection against disguised scripts).
    • X-Frame-Options SAMEORIGIN – the website can't be embedded in someone else's page in a frame (protection against clickjacking).
    • Referrer-Policy strict-origin-when-cross-origin – other websites only receive the domain, not the full page URL.
    • always – the header is sent with error responses too.
  3. After every change, run nginx -t and systemctl reload nginx.

Think before enabling HSTS

The Strict-Transport-Security (HSTS) header tells browsers to use only HTTPS for the domain, even months ahead. If HTTPS then stops working (expired certificate, moving the domain), the website won't load. Only turn it on once HTTPS runs reliably.

Verification#

bash
curl -I http://example.cz
curl -I https://example.cz
  • curl -I – downloads only the response headers.

The first command returns HTTP/1.1 301 Moved Permanently with the header Location: https://example.cz/, the second HTTP/1.1 200 OK (or HTTP/2 200). In the browser you'll see a padlock next to the address.

Troubleshooting#

nginx -t reports an error#

The output contains the file and line number, for example unexpected "}" in /etc/nginx/sites-enabled/example.cz:12. Most often a semicolon is missing at the end of a directive, or there's an extra or missing curly brace. Fix the line and run the test again. Don't restart Nginx with a broken configuration; it wouldn't start.

502 Bad Gateway#

Nginx is running but the app behind it isn't responding. Check that the app is running and listening on the port from proxy_pass:

bash
ss -tlnp | grep 3000
  • ss -tlnp – listening TCP ports with program names.

If nothing is printed, the app isn't running or listens elsewhere. Details are in /var/log/nginx/error.log (a line with connect() failed (111: Connection refused)).

504 Gateway Time-out#

The app responded too late. Increase proxy_read_timeout, or find out why the app is slow.

413 Request Entity Too Large#

The uploaded file is larger than client_max_body_size (default 1 MB). Increase the value in the server block and reload the configuration.

“Welcome to nginx!” shows instead of your website#

The default website is still active, or the domain in server_name doesn't match the address in the browser. Delete /etc/nginx/sites-enabled/default and check server_name.

Nginx won't start: Address already in use#

Port 80 or 443 is taken by another program, typically Apache. Find out which one: ss -tlnp | grep -E ':80 |:443 '. Stop and disable Apache (systemctl disable --now apache2), or uninstall it.

Certbot: Could not automatically find a matching server block#

No website has the domain you're requesting a certificate for in its server_name. Add it to server_name, run nginx -t, systemctl reload nginx and Certbot again.

Certbot: Timeout during connect (likely firewall problem) or Connection refused#

Let's Encrypt couldn't reach port 80. Check the DNS (getent hosts example.cz), the firewall in the customer area and UFW. If the domain has an AAAA record, it must point to this VPS's IPv6 address and the website must listen on IPv6 too (listen [::]:80;).

Cloudflare: error 521, 522, 525 or 526#

  • 521 – the VPS refused the connection: Nginx isn't running or the firewall blocks port 443.
  • 522 – the connection timed out: the VPS is unreachable or the firewall drops packets.
  • 525 – the TLS handshake failed: Nginx has no valid HTTPS configuration on port 443.
  • 526 – the certificate on the VPS is invalid or expired, in Full (strict) mode. Check certbot certificates.

ERR_TOO_MANY_REDIRECTS#

Typically Cloudflare in Flexible mode combined with the HTTPS redirect. Switch Cloudflare to Full (strict).

Summary

  • Websites live in /etc/nginx/sites-available/; only those with a link in sites-enabled are active.
  • After every change: nginx -t and systemctl reload nginx.
  • Let the app listen on 127.0.0.1 and expose it through Nginx with proxy_pass, including the X-Forwarded-* headers.
  • Get a certificate with certbot --nginx -d …; it renews itself, and you can check that with certbot renew --dry-run.
  • With Cloudflare, use Full (strict) mode, never Flexible.

Search the guides