# Nginx configuration for an A grade: TLS, headers, caching

> One commented nginx server block, built up section by section, that passes getReport's TLS, security header, HTTP/2, compression and caching checks, plus the add_header trap that undoes it.

Updated 2026-09-25 · Other platforms · HTML version: https://getreport.app/guides/nginx-configuration-for-an-a-grade

Nginx does exactly what its configuration says and nothing more, which is why a fresh install scores poorly on any report: TLS 1.0 may still be allowed, no security headers are sent, only HTML is compressed, and static files carry no cache lifetime. This guide builds one server block that passes every TLS, header, compression and caching check in getReport, explains each part, and then shows the whole file. It assumes nginx 1.25.1 or newer and a certificate from Let's Encrypt; allow an hour including testing.

## Quick answer

- `listen 443 ssl;` plus `http2 on;` (the `http2` flag on `listen` is deprecated since 1.25.1). Add `listen 443 quic reuseport;` and an `Alt-Svc` header for HTTP/3 if your build has the QUIC module.
- `ssl_protocols TLSv1.2 TLSv1.3;` and a modern cipher list.
- Port 80 answers with one `return 301` to the final `https://` host; www redirects in the same single hop.
- Security headers live in a snippet file you `include` in every block that has its own `add_header`, because nginx drops inherited headers otherwise.
- `gzip` with `gzip_types` and `gzip_vary on`, Brotli through the `ngx_brotli` module.
- `Cache-Control: public, max-age=31536000, immutable` for versioned assets, a week for images, `no-cache` for HTML.
- `nginx -t` before every reload, then check the result with the [security headers checker](https://getreport.app/tools/security-headers).

## Why the defaults cost you points

Nginx ships conservative so it runs anywhere. Distribution packages differ in what they enable, and most leave the security headers, cache lifetimes and anything beyond HTML compression to you. Each gap is its own finding in a report: TLS 1.0 accepted is a fail in the security module, a missing HSTS or CSP header is a warning, uncompressed CSS and JavaScript cost points in best practices, and static files without a lifetime are re-downloaded by every returning visitor.

None of these takes more than a few lines to fix. The difficulty is that nginx's configuration has inheritance rules that quietly undo work: a header set for the whole server disappears from any `location` that sets a header of its own. That one rule causes most "I added the header but the checker says it is missing" support threads, and the layout below is designed around it.

## How getReport checks it

> **Free tool:** [Security headers checker](https://getreport.app/tools/security-headers): Check HSTS, CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy and cookie flags on any site. Free, no signup, with a fix for every missing header.

The security headers checker grades the final response after redirects. The TLS findings come from a separate handshake: getReport connects once with TLS 1.2 or newer, offering `h2` and `http/1.1`, and records the protocol, version and certificate; then it tries a handshake limited to TLS 1.0 and 1.1 and expects the server to refuse it. The [HTTP/2 test](https://getreport.app/tools/http2-test) adds the `Alt-Svc` header check for HTTP/3 and loads the page in Chromium, which is where the compression and caching findings come from: every text response is checked for a `Content-Encoding`, and every script, stylesheet, image and font for a positive `max-age`, `immutable` or a future `Expires`.

> **Check: The server only accepts TLS 1.2 or newer.** TLS 1.0 and 1.1 have known weaknesses and every major browser dropped them in 2020. A server that still accepts them lets an attacker downgrade a connection to one they can break.
>
> 1. Set the minimum protocol to TLS 1.2 (nginx: ssl_protocols TLSv1.2 TLSv1.3; Apache: SSLProtocol -all +TLSv1.2 +TLSv1.3).
> 2. Behind Cloudflare or another CDN, set "Minimum TLS Version" to 1.2 in its dashboard.

> **Check: Strict-Transport-Security header is set.** HSTS tells browsers to always use HTTPS for your site, so after the first visit a typed address or an old http:// link never starts on an unencrypted connection. Only the preload list covers the very first visit.
>
> 1. Send the header: Strict-Transport-Security: max-age=31536000; includeSubDomains.
> 2. Start with a shorter max-age (e.g. 86400) if you are not sure every subdomain supports HTTPS.

> **Check: Text files are compressed.** HTML, CSS and JavaScript shrink by 70–90% with gzip or Brotli. Sending them uncompressed wastes visitors' data and seconds, especially on mobile.
>
> 1. Enable Brotli (or gzip) for text types in your server, hosting panel or CDN; Cloudflare and most hosts have it as one switch.
> 2. Check the files in the technical detail; third-party ones need the vendor to fix it.

> **Check: Static files have a cache lifetime.** Without a cache lifetime, every visit downloads the same logo, CSS and scripts again. Returning visitors should get them from their browser in 0 ms.
>
> 1. Send Cache-Control: public, max-age=31536000, immutable for versioned images, CSS, JS and fonts.
> 2. Most caching plugins and CDNs set this for you; check the "browser cache" or "edge TTL" setting.

![The text compression finding opened: eight text files sent uncompressed, with why it matters, the two fix steps and a Show technical detail link that lists the files](https://getreport.app/guides/img/nginx-configuration-for-an-a-grade/compression.webp "On a default nginx, CSS and JavaScript typically make up the list; the technical detail names each file and its size.")

The technical detail under the finding lists each uncompressed file with its size. If your own HTML is on the list, compression is off entirely; if only CSS, JavaScript and SVG are, `gzip_types` is missing.

## Step by step

The examples use `example.com` as the final host (no www) and Debian or Ubuntu paths: site files in `/etc/nginx/sites-available/` linked into `sites-enabled/`, reusable pieces in `/etc/nginx/snippets/`. On other systems, put the site file in `/etc/nginx/conf.d/example.com.conf`.

### 1. TLS: protocols, ciphers, sessions

One snippet, included by every `server` that listens on 443:

```nginx
# /etc/nginx/snippets/tls.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
```

The cipher list is the TLS 1.2 part of Mozilla's "intermediate" recommendation: forward secrecy and authenticated encryption only, and every browser from the last ten years is covered. TLS 1.3 ciphers are not configurable here and need no attention. The session cache lets a returning visitor skip part of the handshake. If several sites share the server, put these lines in the `http` block instead: for names on the same IP and port, nginx negotiates the protocol version with the settings of the default server, so one old site can re-enable TLS 1.0 for all of them. [TLS versions](https://getreport.app/learn/tls-versions) explains why 1.0 and 1.1 had to go.

OCSP stapling used to be on every checklist. Let's Encrypt stopped running OCSP in 2025 and its certificates no longer name an OCSP server, so `ssl_stapling on` only produces a warning in the error log. With a commercial certificate that still has one, add `ssl_stapling on;`, `ssl_stapling_verify on;` and a `resolver` line.

### 2. HTTP/2 and HTTP/3

Since nginx 1.25.1, HTTP/2 is its own directive, `http2 on;`, and `listen 443 ssl http2` prints a deprecation warning. HTTP/3 needs a build with the QUIC module; check with `nginx -V 2>&1 | grep -o with-http_v3_module`. If it is there:

```nginx
listen 443 quic reuseport;
listen [::]:443 quic reuseport;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
```

`reuseport` may appear only once per address and port across all server blocks, so other sites on the same machine use `listen 443 quic;` without it. Open UDP port 443 in the firewall too; QUIC is not TCP. Browsers only try HTTP/3 after they have seen the `Alt-Svc` header, which is why the HTTP/2 test reports h2 as negotiated on the first handshake and h3 as advertised.

### 3. One redirect to the final host

A request for `http://www.example.com/page` should reach `https://example.com/page` in one hop, not three. Two small server blocks do it:

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include snippets/tls.conf;
    return 301 https://example.com$request_uri;
}
```

The certificate must cover both names. If your final host is www, swap the names. Certbot's `--nginx` plugin adds its own redirect `if` blocks; replace them with the port 80 block above so there is only one rule.

### 4. Security headers in a snippet

```nginx
# /etc/nginx/snippets/headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
```

`always` adds the header to error responses too; without it, a 404 or 500 page goes out bare. Delete the `Alt-Svc` line if you have no QUIC build. The CSP starts in report-only mode, which the check counts as present; move to enforcing once the browser console is quiet.

Now the trap. nginx inherits `add_header` from the enclosing level **only if the current level has no `add_header` of its own**. A `location` that adds `Cache-Control` loses HSTS, CSP and the rest. The fix is to `include snippets/headers.conf;` again in every such location, which the full file below does.

For HSTS preload, raise the header to `max-age=63072000; includeSubDomains; preload` once every subdomain serves HTTPS, then submit the domain at [hstspreload.org](https://hstspreload.org/). Removal from the list takes months, so do this last.

Finally, in the `http` block of `/etc/nginx/nginx.conf`, `server_tokens off;` changes `Server: nginx/1.26.2` to `Server: nginx`, which passes the version-leak finding.

### 5. Compression

Debian and Ubuntu already have `gzip on;` in `nginx.conf`, and declaring it twice is an error, so edit the "Gzip Settings" block there rather than adding a file:

```nginx
# /etc/nginx/nginx.conf, inside http { }
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/css text/plain text/xml application/javascript application/json application/xml application/rss+xml application/manifest+json image/svg+xml;
```

`text/html` is always compressed and listing it only earns a warning. `gzip_vary on` sends `Vary: Accept-Encoding`, so a proxy or CDN never hands a compressed copy to a client that cannot read it. Images, fonts in WOFF2, video and archives are compressed already; adding them wastes CPU.

For Brotli, install the module (`libnginx-mod-http-brotli-filter` on recent Debian and Ubuntu) and add the same list:

```nginx
# /etc/nginx/nginx.conf, inside http { }, below the gzip block
brotli on;
brotli_comp_level 5;
brotli_types text/css text/plain text/xml application/javascript application/json application/xml application/rss+xml application/manifest+json image/svg+xml;
```

Browsers that ask for Brotli get it; everything else falls back to gzip. [Text compression: gzip and Brotli](https://getreport.app/guides/text-compression-gzip-brotli) compares the two.

### 6. Cache lifetimes by location

Files whose URL changes when their content changes (a hash in the name, or WordPress's `?ver=` query) can be kept for a year. Unversioned images get a week. HTML is revalidated on every visit.

```nginx
location ~* \.(?:css|js|mjs)$ {
    include snippets/headers.conf;
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2)$ {
    include snippets/headers.conf;
    add_header Cache-Control "public, max-age=604800";
    try_files $uri =404;
}
```

Use either `add_header Cache-Control` or `expires`, not both: `expires 7d;` writes its own `Cache-Control` and you end up with two. If your CSS and JS are not versioned, give them the week instead of the year.

### 7. WordPress: PHP-FPM, a page cache and the login

For a PHP site, `try_files` sends unknown paths to `index.php` and a FastCGI cache keeps rendered pages for anonymous visitors. The cache zone is declared once in the `http` context:

```nginx
# /etc/nginx/conf.d/fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/wordpress levels=1:2 keys_zone=WORDPRESS:50m inactive=60m max_size=512m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=6r/m;
```

The server block then skips the cache for POST requests, query strings, admin and cart paths, and anyone with a login, password-post or cart cookie, and rate-limits `wp-login.php` to six attempts a minute per IP. The full file shows both. [Rate limiting and bot protection](https://getreport.app/guides/rate-limiting-and-bot-protection-on-a-small-site) covers `xmlrpc.php` and what to do at the CDN.

### 8. The whole file

```nginx
# /etc/nginx/sites-available/example.com  (ln -s into sites-enabled/)
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include snippets/tls.conf;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    listen 443 quic reuseport;          # remove both quic lines without the QUIC module
    listen [::]:443 quic reuseport;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include snippets/tls.conf;

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

    include snippets/headers.conf;      # applies wherever a location adds no header

    # Never serve dotfiles (.env, .git), but keep /.well-known/ for ACME and security.txt
    location ~ /\.(?!well-known/) {
        deny all;
    }

    # WordPress page cache: skip for anything personal
    set $skip_cache 0;
    if ($request_method = POST) { set $skip_cache 1; }
    if ($query_string != "") { set $skip_cache 1; }
    if ($request_uri ~* "/wp-admin/|/wp-login\.php|/xmlrpc\.php|/cart/|/checkout/|/my-account/") { set $skip_cache 1; }
    if ($http_cookie ~* "wordpress_logged_in|wp-postpass|comment_author|woocommerce_items_in_cart|wp_woocommerce_session") { set $skip_cache 1; }

    location / {
        include snippets/headers.conf;
        add_header Cache-Control "no-cache";
        try_files $uri $uri/ /index.php?$args;
    }

    location = /wp-login.php {
        limit_req zone=wplogin burst=5 nodelay;
        limit_req_status 429;
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_cache WORDPRESS;
        fastcgi_cache_valid 200 301 10m;
        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;
        include snippets/headers.conf;
        add_header X-Cache $upstream_cache_status;
    }

    location ~* \.(?:css|js|mjs)$ {
        include snippets/headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2)$ {
        include snippets/headers.conf;
        add_header Cache-Control "public, max-age=604800";
        try_files $uri =404;
    }

    error_page 404 /404.html;
    location = /404.html {
        internal;
    }
}
```

The `/wp-login.php` location has no `add_header`, so it inherits the server-level headers. For a static site, drop the PHP parts and the `set`/`if` lines, and change the first `try_files` to `$uri $uri/ =404` so the custom `404.html` answers missing paths; WordPress renders its own 404 through `index.php`. Match the PHP-FPM socket to your installed version (`ls /run/php/`), and create the cache folder with `mkdir -p /var/cache/nginx/wordpress`.

### 9. Test, then reload

```bash
sudo nginx -t && sudo systemctl reload nginx
```

`nginx -t` catches syntax errors and duplicate directives before they take the site down; `reload` keeps existing connections alive.

## Platform notes

### Behind Cloudflare or another CDN

Visitors see the CDN's TLS, HTTP/3 and compression, not yours; the report grades what the edge sends. Keep the headers here (the CDN passes them through), set the CDN to Full (strict), and allow its IP ranges if you rate-limit.

### Managed hosts with nginx

Many WordPress hosts run nginx without giving you the config. Headers then go through the host's panel or the PHP snippet in [Security headers from zero to A](https://getreport.app/guides/security-headers-from-zero), and the page cache is the host's own.

## Verify

1. **SSL check**: TLS 1.0/1.1 refused, certificate chain valid, HTTP redirects to HTTPS, HSTS set.
2. **Security headers checker**: every header passes, `Server: nginx` without a version. Check an image URL and a 404 URL with `curl -sI` too; they must carry the same headers.
3. **HTTP/2 test**: h2 negotiated, h3 advertised if you enabled QUIC, the compression finding passes, the cache finding lists no static file without a lifetime.
4. **Full report**: the redirect chain finding shows at most one hop from `http://www.example.com`.

## Common mistakes

- **`add_header` in a location drops the server headers.** Symptom: headers present on `/` but missing on images or PHP pages. Fix: `include snippets/headers.conf;` in every location with its own `add_header`.
- **HSTS with `includeSubDomains` before every subdomain has HTTPS.** Symptom: an old subdomain becomes unreachable in browsers that saw the header. Fix: move it to HTTPS first, or leave the directive out until then.
- **Compressing images and fonts.** Symptom: higher CPU, no smaller files. Fix: keep `gzip_types` to text formats and SVG.
- **A year-long `Cache-Control` on HTML.** Symptom: returning visitors see yesterday's page. Fix: `no-cache` on HTML, long lifetimes only for versioned assets.
- **No `Vary: Accept-Encoding`.** Symptom: a proxy serves compressed bytes to a client that did not ask for them. Fix: `gzip_vary on;`.
- **`listen 80` that serves the site.** Symptom: http-to-https fails and both schemes are indexed. Fix: port 80 does nothing but `return 301`.
