Server blocks are Nginx's equivalent of Apache virtual hosts: each server { ... } block defines one site, and Nginx chooses the right one by matching the request's port and Host header against listen and server_name. In this tutorial you will configure two sites on Ubuntu 24.04, one serving static files and one acting as a reverse proxy to an application, add a catch-all default server that rejects unknown host names, redirect www to the bare domain and secure everything with Let's Encrypt.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root sudo user.
  • Nginx installed and allowed through UFW (sudo apt install nginx and sudo ufw allow 'Nginx Full').
  • Two domain names, each with A records for the bare name and www pointing to your server's IP address. This guide uses your_domain for the static site and app_domain for the application.
  • For the reverse proxy example, an application listening on 127.0.0.1:3000. Step 5 shows a one-line test server if you do not have one yet.

How Nginx picks a server block

Knowing the selection rules avoids most surprises:

  1. Nginx first narrows the candidates to the server blocks whose listen directive matches the IP address and port the request arrived on.
  2. Among those, it compares the Host header with server_name in this order: exact name, longest wildcard starting with * (such as *.your_domain), longest wildcard ending with *, then the first matching regular expression.
  3. If nothing matches, the request goes to the block marked default_server for that port. Without one, the first block loaded (in alphabetical file order) wins, which may expose a site you did not intend.

Step 1 - Creating the document root

Create a directory for the static site and give your user ownership so you can deploy without sudo:

sudo mkdir -p /var/www/your_domain/html
sudo chown -R "$USER":"$USER" /var/www/your_domain/html

Add a test page:

echo '<h1>your_domain works</h1>' > /var/www/your_domain/html/index.html

The Nginx worker processes run as www-data, which can read the file through the default world-readable permissions.

Step 2 - Creating a catch-all default server

Ubuntu's default site currently answers unknown names with the "Welcome to nginx!" page. Replace it with a server block that closes the connection without a response. Disable the packaged default first:

sudo rm /etc/nginx/sites-enabled/default

Create a new default site:

sudo nano /etc/nginx/sites-available/000-default
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    return 444;
}

server_name _; is simply a name that never matches a real host; the default_server flag is what makes this block the fallback. 444 is a special Nginx code that closes the connection without sending anything, which is cheap and gives scanners nothing to work with.

Enable it, test and reload:

sudo ln -s /etc/nginx/sites-available/000-default /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Verify that a request with no known host name gets no response:

curl -I http://localhost
curl: (52) Empty reply from server

Step 3 - Creating the static site server block

Create a server block for your_domain:

sudo nano /etc/nginx/sites-available/your_domain
server {
    listen 80;
    listen [::]:80;
    server_name www.your_domain;

    return 301 http://your_domain$request_uri;
}

server {
    listen 80;
    listen [::]:80;
    server_name your_domain;

    root /var/www/your_domain/html;
    index index.html;

    access_log /var/log/nginx/your_domain.access.log;
    error_log  /var/log/nginx/your_domain.error.log;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~* \.(css|js|jpg|jpeg|png|gif|svg|webp|woff2)$ {
        expires 30d;
        access_log off;
    }

    location ~ /\. {
        deny all;
    }
}

The first block redirects www.your_domain to the bare domain and keeps the path with $request_uri. The second block serves the site: try_files returns 404 for missing files, static assets get a 30-day cache lifetime, and the last location blocks hidden files such as .git or .env.

Enable the site and reload:

sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Test it with the Host header, which works before DNS has propagated:

curl -H "Host: your_domain" http://localhost
curl -I -H "Host: www.your_domain" http://localhost/about
<h1>your_domain works</h1>
HTTP/1.1 301 Moved Permanently
Server: nginx/1.24.0 (Ubuntu)
Location: http://your_domain/about

Step 4 - Creating a reverse proxy server block

The second site forwards every request to an application listening on a local port. Create the server block:

sudo nano /etc/nginx/sites-available/app_domain
server {
    listen 80;
    listen [::]:80;
    server_name app_domain www.app_domain;

    access_log /var/log/nginx/app_domain.access.log;
    error_log  /var/log/nginx/app_domain.error.log;

    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 "upgrade";
    }
}

The X-* headers tell the application the original client IP and scheme, and the Upgrade and Connection headers allow WebSocket connections. client_max_body_size raises the upload limit from the 1 MB default. Here www.app_domain is served directly instead of redirected, to show that one block can hold several names.

Enable the site and reload:

sudo ln -s /etc/nginx/sites-available/app_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 5 - Testing the proxy

If your application is not running yet, start a throwaway test server on port 3000 with Python, which is preinstalled on Ubuntu 24.04. Run it in a second terminal, because it stays in the foreground:

mkdir -p ~/proxytest && echo '<h1>app_domain via proxy</h1>' > ~/proxytest/index.html
python3 -m http.server 3000 --bind 127.0.0.1 --directory ~/proxytest

From the first terminal, request the site through Nginx:

curl -H "Host: app_domain" http://localhost
<h1>app_domain via proxy</h1>

The Python server logs a request coming from 127.0.0.1, which confirms Nginx forwarded it. Stop the test server with Ctrl+C and replace it with your real application, ideally running as a systemd service so it starts at boot. If you stop it now and request the site again, Nginx returns 502 Bad Gateway, which is expected when the backend is down.

Confirm Nginx sees all three blocks:

sudo nginx -T 2>/dev/null | grep -E '^\s*server_name'
    server_name _;
    server_name app_domain www.app_domain;
    server_name www.your_domain;
    server_name your_domain;

nginx -T prints the full merged configuration, which is the quickest way to check what Nginx actually loaded.

Step 6 - Adding shared settings with a snippet

Settings that every site should have belong in a snippet rather than being copied into each block. Create one for security headers:

sudo nano /etc/nginx/snippets/security-headers.conf
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;

Add include snippets/security-headers.conf; inside the main server block of both your_domain and app_domain, just below the server_name line. Then test, reload and check:

sudo nginx -t
sudo systemctl reload nginx
curl -sI -H "Host: your_domain" http://localhost | grep -E 'X-Content|X-Frame|Referrer'
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin

Step 7 - Enabling HTTPS for each server block

Install Certbot with the Nginx plugin:

sudo apt install certbot python3-certbot-nginx

Request a certificate for each site. Certbot finds the server blocks by server_name and edits them:

sudo certbot --nginx -d your_domain -d www.your_domain
sudo certbot --nginx -d app_domain -d www.app_domain

Certbot adds listen 443 ssl and the certificate directives to each matching block and turns the port 80 blocks into redirects to HTTPS. Afterwards, edit the www.your_domain block in /etc/nginx/sites-available/your_domain so it redirects straight to https://your_domain$request_uri, avoiding an extra hop.

For the HTTPS side of the catch-all, add a default_server block on port 443 too. It needs a certificate to complete the handshake, so the simplest option is ssl_reject_handshake, available since Nginx 1.19.4:

sudo nano /etc/nginx/sites-available/000-default

Add this block below the existing one:

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;

    ssl_reject_handshake on;
}

Test, reload and verify renewal:

sudo nginx -t
sudo systemctl reload nginx
sudo certbot renew --dry-run

Check both sites over HTTPS:

curl -I https://your_domain
curl -I https://app_domain
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)

Troubleshooting

The wrong site answers for a domain. The name does not match any server_name on that port, so the default server responds. Check the list with sudo nginx -T | grep server_name and confirm the site's symlink exists in sites-enabled.

a duplicate default server for 0.0.0.0:80. Two blocks carry default_server for the same port, usually the Ubuntu default site and your own. Remove one of them from sites-enabled.

conflicting server name "your_domain" on 0.0.0.0:80, ignored. The same name appears in two blocks on the same port. Nginx uses the first one and ignores the other; remove the duplicate.

could not build server_names_hash. Uncomment server_names_hash_bucket_size 64; in the http block of /etc/nginx/nginx.conf.

502 Bad Gateway on the proxied site. The application is not listening on 127.0.0.1:3000. Check with sudo ss -tlnp | grep 3000 and read /var/log/nginx/app_domain.error.log, which shows connect() failed (111: Connection refused) in that case.

Conclusion

You now have Nginx serving a static site and a proxied application from separate server blocks, with a default server that rejects unknown host names on HTTP and HTTPS, a www redirect, shared headers in a snippet and certificates that renew automatically. Next, consider adding rate limiting to the proxied application, running the backend as a systemd service, and reviewing the Nginx installation and configuration guide for global tuning.