From a bare server to a site served over HTTPS with a certificate that renews itself. This assumes you have already worked through securing a new server — in particular that you have a non-root user with sudo and a firewall running.
Commands are shown for Debian and Ubuntu, with notes for Fedora, RHEL, Rocky and Alma where they differ. The two families lay Nginx out differently, and that difference causes most of the confusion.
1. Install and start it
sudo apt install nginx # Debian, Ubuntu
sudo dnf install nginx # Fedora, RHEL, Rocky, Alma
sudo systemctl enable --now nginx
systemctl status nginxenable --now starts it and sets it to start at boot. Doing only enable is the single most common systemd mistake — see systemctl.
2. Open the firewall
# Debian, Ubuntu
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Fedora, RHEL, Rocky, Alma
sudo firewall-cmd --permanent --add-service=http --add-service=https
sudo firewall-cmd --reloadThen confirm from your own machine, not the server:
curl -I http://your-server-ipA 200 and Nginx’s welcome page means the whole chain works. A timeout means a firewall — possibly your cloud provider’s security group, which is separate from ufw. curl and networking basics cover diagnosing this.
3. Know where things live
| Debian, Ubuntu | Fedora, RHEL | |
|---|---|---|
| Main config | /etc/nginx/nginx.conf | /etc/nginx/nginx.conf |
| Site configs | /etc/nginx/sites-available/ | /etc/nginx/conf.d/ |
| Enabled sites | /etc/nginx/sites-enabled/ (symlinks) | everything in conf.d/ |
| Default web root | /var/www/html | /usr/share/nginx/html |
| Logs | /var/log/nginx/access.log, error.log | |
The sites-available / sites-enabled pattern is a Debian convention, not part of Nginx. You write the file in sites-available and symlink it into sites-enabled to turn it on, which makes disabling a site a matter of removing one link. On Red Hat systems every .conf file in conf.d is simply active.
4. Create the site
sudo mkdir -p /var/www/example.com/html
sudo chown -R $USER:$USER /var/www/example.com/html
sudo chmod -R 755 /var/www/example.com
echo '<h1>It works</h1>' > /var/www/example.com/html/index.htmlOwning the files as yourself and letting Nginx read them is right for static content. Nginx’s worker processes run as www-data (Debian) or nginx (Red Hat) and only need read access — they should not own your files, and nothing here needs 777. See file permissions.
Now the server block. On Debian and Ubuntu, /etc/nginx/sites-available/example.com:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
}On Fedora or RHEL, save the same block as /etc/nginx/conf.d/example.com.conf and skip the next step.
5. Enable it and test the config
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default # optional: drop the welcome page
sudo nginx -t # ALWAYS do this
sudo systemctl reload nginxnginx -t before every reload. It parses the configuration and tells you the file and line number of any error. Reloading a broken config leaves the old one running, but restarting with one will take the site down — and -t costs a second. That asymmetry is a property of Ubuntu’s nginx.service, not of reloads in general: the packager put the validator in ExecStartPre=, which runs after systemd has already stopped the old process, so a bad file is fatal on restart and harmless on reload. haproxy.service is the reverse — its validator is in ExecReload= and it has no ExecStartPre=, so there it is the restart that is safe to attempt and the reload that catches a bad file. And nginx -t is a second, earlier parse of a file that can change before the reload runs.
Use reload rather than restart: it picks up the new configuration without dropping connections that are in flight — those are handed to a worker marked is shutting down, which goes on serving the old configuration until they finish. Ubuntu’s shipped config leaves worker_shutdown_timeout unset and nginx’s own default is no timeout at all, so that old generation can outlive the reload by a long way. If you verify the change with curl straight afterwards, you may be answered by a worker still holding the previous file.
6. Point DNS at the server
Create an A record for example.com pointing at the server’s IP, and another for www. Then verify from your machine:
dig +short example.com
curl -I http://example.comDNS must be working before the next step. Let’s Encrypt validates your control of the domain by fetching a file over HTTP, so a name that does not yet resolve will simply fail. If dig returns nothing, wait — propagation is a matter of the record’s TTL, not something you can hurry. Networking basics covers dig.
7. Add HTTPS
# Debian, Ubuntu
sudo apt install certbot python3-certbot-nginx
# Fedora, RHEL, Rocky, Alma (EPEL required on RHEL-family)
sudo dnf install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.comCertbot asks for an email address, obtains the certificate, edits your server block to serve HTTPS, and offers to redirect HTTP to HTTPS. Accept the redirect.
It is worth reading the diff it makes to your config rather than treating it as magic — it adds a second server block on port 443 with the certificate paths, and rewrites the port 80 block to redirect.
Certificates last ninety days, so renewal must be automatic. The package installs a systemd timer to handle it. Confirm it is actually there:
systemctl list-timers | grep certbot
sudo certbot renew --dry-runDo not skip the dry run. It performs the whole renewal against the staging environment. Discovering that renewal is broken now takes a minute; discovering it in ninety days means a browser warning on a live site.
Two corrections to the two paragraphs above, both of which have gone stale. Ninety days is now true of only one of Let’s Encrypt’s three certificate profiles — the tlsserver profile has issued 45-day certificates since 13 May 2026, and there is a six-day one. And the dry run tests the conversation with the certificate authority but not the step that actually breaks: it does not run deploy hooks, so it cannot tell you whether anything reloads your server after a renewal. Run it anyway; just do not treat a green dry run as proof. The Life of a Certificate follows one through all six stages from an ACME account to the certificate your server is actually serving, and gives you the one check that does prove renewal works end to end.
8. Check the result
curl -I https://example.com # expect 200
curl -I http://example.com # expect 301 to https
curl -sI https://example.com | grep -i strict # is HSTS set
sudo tail -f /var/log/nginx/example.com.access.logA couple of hardening lines worth adding inside the HTTPS server block:
server_tokens off; # stop advertising the version
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options SAMEORIGIN;server_tokens off belongs in the http block of nginx.conf if you want it site-wide. Then nginx -t and reload, as always.
When it does not work
Read error.log first. It names the file, the reason and the client, and answers most of these immediately:
sudo tail -50 /var/log/nginx/error.log
sudo journalctl -u nginx -n 50 --no-pager| Symptom | Usual cause |
|---|---|
| 403 Forbidden | Nginx cannot read the files, or there is no index.html. Check permissions on every directory in the path — the worker needs x on each to traverse it. |
| 404 on every page | root points at the wrong directory, or a different server block is matching first. |
| 502 Bad Gateway | Nginx is proxying to an application that is not running or is on a different port. |
| Still the welcome page | The default site is still enabled and matching first. Remove sites-enabled/default. |
| Port 80 already in use | Apache is probably installed and running. sudo ss -tulpn | grep :80 will name it. |
| Works locally, not remotely | Firewall — host or cloud provider. |
On Fedora, RHEL, Rocky and Alma there is one extra trap: SELinux. Files served from a non-standard location need the right context, and Nginx cannot make outbound connections unless you allow it. Symptoms look exactly like a permissions problem while ls -l shows everything is fine:
sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
sudo restorecon -Rv /var/www/example.com
sudo setsebool -P httpd_can_network_connect 1 # only if proxying
sudo ausearch -m avc -ts recent # what SELinux actually blockedDisabling SELinux entirely is the advice you will find first and the one to resist — ausearch tells you precisely what was denied, and the fix is usually one line.
Where to go next
- Serving an application rather than static files means a
proxy_passto something listening on localhost — and that application should bind to127.0.0.1, not0.0.0.0, so only Nginx can reach it. - Multiple sites are just more server blocks with different
server_namevalues. One IP address serves as many as you like. - Log rotation is configured by the package, but a busy site is a classic cause of a full disk — see disk space.
- Back it up. The config in
/etc/nginx, the content, and the certificates in/etc/letsencrypt.
Related
- Securing a new Linux server — do this first.
- systemctl — enabling, reloading and reading service logs.
- File permissions — the cause of most 403 errors.
- Networking basics —
ss -tulpn,dig, and firewall versus bind address. - curl and wget — testing headers, redirects and response codes.
- ln — the symlink behind
sites-enabled.
