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
Arecord, optionallyAAAA); 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#
Check that the domain points to the VPS. On the VPS, run:
bash getent hosts example.czYou'll see your VPS's IP address (from the Network tab). If not, fix the DNS and wait for the change to propagate.
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/tcpandufw allow 443/tcp.
- Firewall in the customer area (if you use the Block everything except allowed mode): rule Inbound · Allow · TCP · port
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:
apt update
apt -y install nginxapt -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#
| Path | What it's for |
|---|---|
/etc/nginx/nginx.conf | The main configuration. You usually don't change it. It loads the files from the directories below. |
/etc/ | Configurations of individual websites (server blocks). A file here exists but isn't active. |
/etc/ | 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.
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.htmlmkdir -p– creates the directory including its parents.echo '…' > …/index.html– writes a simple page toindex.html.
Later, upload your own website files into this directory over SFTP (guide) or in the Files tab.
Create the website configuration:
bash nano /etc/nginx/sites-available/example.cz/etc/ nginx/ nginxsites-available/ example.cz 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; } }
| Directive | What it does | When 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/ | 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;. |
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 nginxln -s … /etc/– creates the link, so Nginx starts loading the website.nginx/ sites-enabled/ rm /etc/– disables the default website (the file stays innginx/ sites-enabled/ default sites-available).nginx -t– checks the syntax of all configurations. It must end withsyntax is okandtest 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):
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;
}
}| Directive | What it does | When 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, Connection | Pass 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:
ln -s /etc/nginx/sites-available/app.example.cz /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginxTo 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#
apt -y install certbot python3-certbot-nginxcertbot– 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#
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 theserver_nameof some website.
On the first run, Certbot asks these questions:
| Question | What to answer | Why |
|---|---|---|
Email (Enter email address…) | Your address | Let'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? | Y | Without 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 N | Only for EFF news; it has no effect on the certificate. |
Result: Successfully deployed certificate for example.cz to /etc/ and Congratulations! You have successfully enabled HTTPS….
What Certbot changed in the configuration#
Open the website configuration (nano /etc/). Lines marked # managed by Certbot were added by Certbot:
| Added line | What 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/ | The certificate (including intermediate certificates). |
ssl_certificate_key /etc/ | The certificate's private key. Never give it to anyone. |
include /etc/ | Certbot's recommended TLS settings (protocols, ciphers). |
ssl_dhparam /etc/ | 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:
systemctl list-timers | grep certbot
certbot renew --dry-runsystemctl list-timers | grep certbot– prints a line withcertbot.timerand 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 withCongratulations, 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:
| Mode | Connection from Cloudflare to the VPS | Recommendation |
|---|---|---|
| Off | No encryption | Don't use. |
| Flexible | Unencrypted HTTP to port 80 | Don't use. Combined with the HTTPS redirect in Nginx it creates an infinite loop (ERR_TOO_MANY_REDIRECTS). |
| Full | Same protocol as the visitor; for HTTPS the VPS certificate isn't verified | Only temporarily. |
| Full (strict) | Same protocol as the visitor; for HTTPS the VPS certificate is verified | Recommended. 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#
Hide the Nginx version. On Debian 13,
/etc/nginx/nginx.confalready containsserver_tokens off;by default. On Ubuntu 24.04, open the file (nano /etc/nginx/nginx.conf) and in thehttpblock remove the#in front of# server_tokens off;. Nginx then won't reveal its version in headers or on error pages.Security headers. You can add these to the
serverblock 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.
After every change, run
nginx -tandsystemctl 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#
curl -I http://example.cz
curl -I https://example.czcurl -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/. 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:
ss -tlnp | grep 3000ss -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/ 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/; only those with a link innginx/ sites-available/ sites-enabledare active. - After every change:
nginx -tandsystemctl reload nginx. - Let the app listen on
127.0.0.1and expose it through Nginx withproxy_pass, including theX-Forwarded-*headers. - Get a certificate with
certbot --nginx -d …; it renews itself, and you can check that withcertbot renew --dry-run. - With Cloudflare, use Full (strict) mode, never Flexible.
Further reading#
- nginx: Beginner's Guide (opens in a new window)
- nginx: WebSocket proxying (opens in a new window)
- nginx: ngx_http_proxy_module (opens in a new window)
- Certbot: User Guide (opens in a new window)
- Let's Encrypt: Rate Limits (opens in a new window)
- Cloudflare: SSL/TLS encryption modes (opens in a new window)