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 nginx

enable --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 --reload

Then confirm from your own machine, not the server:

curl -I http://your-server-ip

A 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, UbuntuFedora, 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.html

Owning 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 nginx

nginx -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.com

DNS 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.com

Certbot 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-run

Do 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.log

A 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
SymptomUsual cause
403 ForbiddenNginx 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 pageroot points at the wrong directory, or a different server block is matching first.
502 Bad GatewayNginx is proxying to an application that is not running or is on a different port.
Still the welcome pageThe default site is still enabled and matching first. Remove sites-enabled/default.
Port 80 already in useApache is probably installed and running. sudo ss -tulpn | grep :80 will name it.
Works locally, not remotelyFirewall — 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 blocked

Disabling 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_pass to something listening on localhost — and that application should bind to 127.0.0.1, not 0.0.0.0, so only Nginx can reach it.
  • Multiple sites are just more server blocks with different server_name values. 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