If your site is built with Hugo, Jekyll, Eleventy, Astro, or just plain HTML and CSS, you don't need PHP, you don't need a database, and you definitely don't need a 300MB WordPress install. Nginx serving static files is what it was built to do — and it does it at a scale that most CMS setups will never touch.
Who this is for
- Static site generators: Hugo, Jekyll, Eleventy, Astro, Zola, Pelican, Gatsby, Next.js (static export)
- Frontend framework builds: Bootstrap, Tailwind CSS, Bulma, or any hand-rolled HTML/CSS/JS
- Use cases: landing pages, documentation, portfolios, personal blogs, brochure sites, "coming soon" pages — anything that doesn't need server-side logic
The appeal
A static site has no PHP-FPM pool to exhaust, no database to overwhelm, no login panels to brute-force. The attack surface is the filesystem and Nginx itself — both of which are battle-tested. Caching is trivially aggressive because there are no logged-in users to accommodate, no sessions to track, and no dynamically-generated pages that might serve stale content. The same server that struggles under a modest WordPress traffic spike can serve a static site to tens of thousands of concurrent visitors without breaking a sweat.
Step 1: The minimal server block
This is the entire configuration for a static site:
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name mysite.com;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
ssl_certificate /etc/letsencrypt/live/mysite.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mysite.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/mysite.com/chain.pem;
root /var/www/mysite.com/public_html;
index index.html;
access_log /var/log/nginx/mysite.access.log;
error_log /var/log/nginx/mysite.error.log;
location / {
try_files $uri $uri/ =404;
}
}
That's it. No FastCGI, no proxy_pass, no PHP handler. Point root at your build output directory and Nginx handles the rest.
Compare this to a typical CMS server block — no fastcgi_pass, no limit_conn for PHP, no client_max_body_size for uploads, no database connection overhead per request. Each request is a filesystem lookup and a sendfile() call.
Step 2: Clean URLs — drop the .html extension
Most static site generators produce pages as about.html, contact.html, and so on. You probably want visitors to see /about/ instead of /about.html. Nginx can do this with one line:
location / {
try_files $uri $uri.html $uri/ =404;
}
Now a request for /about looks for about.html and serves it transparently. The browser never sees the .html extension.
If your generator outputs clean directories instead (/about/index.html), use:
location / {
try_files $uri $uri/ =404;
}
The trailing $uri/ catches directory-index requests — /about/ looks for /about/index.html automatically.
Step 2.5: Block anything that isn't a GET or HEAD request
A static site has no forms, no API endpoints, no comment submissions — nothing on the server knows what to do with a POST, PUT, PATCH, or DELETE. You can reject those methods at the door rather than letting Nginx silently serve a file in response to a malformed request:
location / {
if ($request_method !~ ^(GET|HEAD)$) {
return 405;
}
try_files $uri $uri.html $uri/ =404;
}
A 405 Method Not Allowed is the correct HTTP response here — it tells the client "I understood what you asked for, but this server doesn't do that." Compare this to a CMS setup, where POST requests to index.php are expected (login forms, search, comments) and a blanket method restriction would break the site. For a static site, there's nothing to break — and blocking non-read methods reduces the attack surface further.
Step 3: Compress everything ahead of time
Nginx can compress responses on the fly with gzip on, but for a static site you can do better: pre-compress your files during the build step and let Nginx serve the compressed versions directly. No CPU spent per request.
During your build, generate .gz versions of your text-based assets — most static site generators and bundlers can do this automatically. Then enable:
gzip_static on;
When a browser requests style.css and sends Accept-Encoding: gzip, Nginx looks for style.css.gz on disk and serves it directly — matching the original's headers and mimetype.
If you've compiled Nginx with the Brotli module, you can serve pre-compressed .br files the same way:
brotli_static on;
Brotli typically produces 15–20% smaller files than gzip at the same compression level. Modern browsers have supported it since 2017.
Step 4: Aggressive caching
Static sites have a superpower: assets that change get new filenames. Most build tools append a content hash — main.a1b2c3d.js — so when the content changes, the filename changes too. This means you can cache everything indefinitely without worrying about stale content.
# Hashed assets — immutable, cache forever
location ~* \.[a-f0-9]{8,}\.(js|css|png|jpg|jpeg|gif|ico|webp|svg|woff2|woff)$ {
expires max;
add_header Cache-Control "public, immutable";
}
# Non-hashed but still static — cache with revalidation
location ~* \.(png|jpg|jpeg|gif|ico|webp|woff2|woff|ttf|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
location ~* \.(css|js|html|xml|txt)$ {
expires 7d;
add_header Cache-Control "public, must-revalidate";
}
The first block catches hashed filenames — immutable tells the browser "this file will never change, don't even ask." The second and third cover non-hashed assets with shorter durations and revalidation.
If your build pipeline hashes everything (recommended), you can simplify to just the first block plus a shorter fallback for .html files.