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
sudo apt update
sudo apt install -y nginx rsyncThe 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:
sudo mkdir -p /var/www/example.com/html
sudo chown -R $USER:$USER /var/www/example.comnginx 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:
echo '<!doctype html><title>example.com</title><h1>It works</h1>' > /var/www/example.com/html/index.htmlWrite the server block
Create /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;
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 =404gives you clean URLs:/aboutservesabout.htmlorabout/index.html, whichever exists. Static site generators produce one or the other.- The three
locationblocks set caching. Change/assets/to wherever your build tool puts fingerprinted files (/_astro/,/static/,/build/). Only give a year-longimmutablelifetime to files whose names change when their content does; otherwise visitors keep an old stylesheet for a year. no-cachedoesn't mean "don't cache". It means "check with the server before using the cached copy", which costs one quick304 Not Modifiedand guarantees visitors see a new deploy at once.error_page 404 /404.htmlshows 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:
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 nginxCheck 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:
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:
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 nginxDon'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:
curl -sI -H 'Host: example.com' -H 'Accept-Encoding: gzip' http://127.0.0.1/assets/site.3f2a1c.cssUse 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:
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:
listen 443 ssl http2;
listen [::]:443 ssl http2;Then sudo nginx -t && sudo systemctl reload nginx.
Check it worked.
curl -sI --http2 https://example.com/ | head -1It 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:
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/). --deleteremoves files from the server that are no longer in the build, so old pages don't linger. Run it once with--dry-runadded to see what it would do.deployis 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
- Set up nginx with free Let's Encrypt HTTPS if you haven't yet.
- Serving apps as well as static files? Caddy as a reverse proxy for several apps handles certificates for each one by itself.
- Keep a copy of the site off the server: back up your server.