How to host a static website with nginx on Ubuntu or Debian

Serve a static site with nginx from your own server, with clean URLs, gzip, HTTP/2, the right caching headers and one-command deploys with rsync.

Tested on Ubuntu 24.04 LTS, Debian 12 Oldstable, Debian 13 Stable

Before you start

  • A server running Ubuntu 24.04 or Debian 12 or 13
  • Root or a user with sudo
  • A domain whose A (and AAAA) record points at the server
  • Your site's built files (HTML and CSS and images) on your computer

To host a static website with nginx, install the nginx package, copy your files to /var/www/example.com/html, and add a server block that points root at that folder and answers for your domain. Then add gzip, long cache lifetimes for fingerprinted assets, HTTP/2 once the site has a certificate, and a one-line rsync command to publish new versions.

A static site is plain files: HTML, CSS, JavaScript, images. Anything a static site generator (Hugo, Astro, Eleventy, Jekyll, a npm run build folder) produces works the same way. nginx serves files like these very quickly with almost no memory, so the smallest server is plenty.

The examples use example.com and the server address 203.0.113.10. Replace them with yours.

Install nginx

bash
sudo apt update
sudo apt install -y nginx rsync

The version depends on the distribution, and it matters for one setting later (HTTP/2):

Distribution nginx version
Ubuntu 24.04 1.24.0
Debian 12 1.22.1
Debian 13 1.26.3

Check yours with nginx -v.

Check it worked. Open http://203.0.113.10 in a browser. You should see "Welcome to nginx!". If the page doesn't load, open ports 80 and 443: sudo ufw allow 'Nginx Full' if ufw is on, plus the same ports in any firewall your provider runs in front of the server.

Create the site's folder

Give the site its own folder, owned by the user who will deploy to it, so you never need root to publish:

bash
sudo mkdir -p /var/www/example.com/html
sudo chown -R $USER:$USER /var/www/example.com

nginx runs as www-data and only needs to read the files. The default permissions (755 for folders, 644 for files) allow that.

Put a test page there for now:

bash
echo '<!doctype html><title>example.com</title><h1>It works</h1>' > /var/www/example.com/html/index.html

Write the server block

Create /etc/nginx/sites-available/example.com:

nginx
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /var/www/example.com/html;
    index index.html;

    access_log /var/log/nginx/example.com.access.log;
    error_log  /var/log/nginx/example.com.error.log;

    location / {
        # /about serves about.html or about/index.html; anything else is a 404.
        try_files $uri $uri/ $uri.html =404;
    }

    # Files with a hash in their name (site.3f2a1c.css) never change: cache them for a year.
    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    # Images and fonts without a hash: a week.
    location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?)$ {
        expires 7d;
        access_log off;
    }

    # HTML: the browser may keep a copy but must check for a newer one first.
    location ~* \.html$ {
        add_header Cache-Control "no-cache";
    }

    error_page 404 /404.html;
}

What the parts do:

  • try_files $uri $uri/ $uri.html =404 gives you clean URLs: /about serves about.html or about/index.html, whichever exists. Static site generators produce one or the other.
  • The three location blocks set caching. Change /assets/ to wherever your build tool puts fingerprinted files (/_astro/, /static/, /build/). Only give a year-long immutable lifetime to files whose names change when their content does; otherwise visitors keep an old stylesheet for a year.
  • no-cache doesn't mean "don't cache". It means "check with the server before using the cached copy", which costs one quick 304 Not Modified and guarantees visitors see a new deploy at once.
  • error_page 404 /404.html shows your own not-found page if the build has one, and nginx's plain one if not.

A detail that trips people up: a location that has its own add_header drops every add_header set at the server level. If you add security headers to the server block later, repeat them in the locations that set Cache-Control, or put them in a snippet and include it in each.

Turn the site on and the default site off:

bash
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Check it worked. nginx -t prints syntax is ok and test is successful. Then ask the server for the page with your domain's name, from the server itself:

bash
curl -sI -H 'Host: example.com' http://127.0.0.1/

You should get HTTP/1.1 200 OK and Cache-Control: no-cache. Once DNS points at the server, http://example.com shows "It works".

Turn on gzip for text files

Ubuntu and Debian ship nginx with gzip on, but only for HTML. Add the other text types in their own file, which nginx loads from conf.d:

bash
sudo tee /etc/nginx/conf.d/gzip.conf > /dev/null <<'EOF'
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/css application/javascript application/json image/svg+xml text/plain text/xml application/xml font/ttf;
EOF
sudo nginx -t && sudo systemctl reload nginx

Don't add images (JPEG, PNG, WebP) or woff2 fonts: they are already compressed, and gzipping them again only costs CPU.

Check it worked. Ask for a stylesheet the way a browser does:

bash
curl -sI -H 'Host: example.com' -H 'Accept-Encoding: gzip' http://127.0.0.1/assets/site.3f2a1c.css

Use a real file name from your site. The answer should include Content-Encoding: gzip, Vary: Accept-Encoding and, for a file under /assets/, Cache-Control: public, max-age=31536000, immutable.

Add HTTPS and HTTP/2

Get a free certificate with Certbot's nginx plugin: Set up nginx with free Let's Encrypt HTTPS has the steps, and they work unchanged on this server block. Certbot adds the listen 443 ssl lines and the certificate paths to your file and redirects HTTP to HTTPS. If you'd rather have one certificate that covers every subdomain, see wildcard certificates with the DNS challenge.

Browsers only speak HTTP/2 over HTTPS, so turn it on after Certbot has run. How depends on the nginx version:

nginx 1.25.1 and newer (Debian 13): add a line inside the server block that has listen 443 ssl:

nginx
http2 on;

nginx 1.24 and older (Ubuntu 24.04, Debian 12): the http2 directive doesn't exist yet; nginx -t fails with unknown directive "http2". Add http2 to the listen lines instead:

nginx
listen 443 ssl http2;
listen [::]:443 ssl http2;

Then sudo nginx -t && sudo systemctl reload nginx.

Check it worked.

bash
curl -sI --http2 https://example.com/ | head -1

It prints HTTP/2 200. If it prints HTTP/1.1 200 OK, the http2 setting isn't in the HTTPS server block.

Deploy new versions with rsync

Build the site on your computer, then copy the output folder to the server. rsync sends only the files that changed:

bash
rsync -avz --delete ./dist/ deploy@203.0.113.10:/var/www/example.com/html/
  • The trailing slash on ./dist/ copies the folder's contents, not the folder itself. Use your build tool's output folder (public/, dist/, _site/, build/).
  • --delete removes files from the server that are no longer in the build, so old pages don't linger. Run it once with --dry-run added to see what it would do.
  • deploy is the user who owns /var/www/example.com. Log in with an SSH key, not a password.

No reload is needed: nginx serves the new files on the next request.

Check it worked. Reload the page in the browser. Because HTML is no-cache, you see the new version straight away.

Fix the common problems

403 Forbidden. nginx can't read the files or there's no index.html in the folder. Check that the folder holds index.html (not dist/index.html one level down: the trailing slash in the rsync command matters) and that www-data can read every folder on the way: namei -l /var/www/example.com/html/index.html shows the permissions of each part.

404 Not Found for a page that exists. The site's root is wrong, or the default site is still answering. sudo nginx -T | grep -E 'server_name|root' lists what nginx really loaded. If your domain isn't there, the link in sites-enabled is missing.

nginx: [emerg] unknown directive "http2". Your nginx is older than 1.25.1. Use listen 443 ssl http2; instead, as above.

nginx: [emerg] a duplicate default server for 0.0.0.0:80. Two server blocks both claim default_server. Remove the default site (sudo rm /etc/nginx/sites-enabled/default) or the second default_server.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee