# 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.

Source: https://vpsnine.com/tutorials/host-static-website-nginx · Updated: 2026-10-21

Difficulty: beginner · Time: about 25 minutes · Tested on: Ubuntu 24.04 LTS, Debian 12 Oldstable, Debian 13 Stable

Prerequisites:

- 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](https://vpsnine.com/help/firewall-with-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](https://vpsnine.com/help/nginx-and-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](https://vpsnine.com/tutorials/letsencrypt-wildcard-certificate-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](https://vpsnine.com/help/ssh-keys), 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

- [Set up nginx with free Let's Encrypt HTTPS](https://vpsnine.com/help/nginx-and-https) if you haven't yet.
- Serving apps as well as static files? [Caddy as a reverse proxy for several apps](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-apps) handles certificates for each one by itself.
- Keep a copy of the site off the server: [back up your server](https://vpsnine.com/help/backups-you-should-run).
