Linux (AlmaLinux)

Enable HTTP/3 on nginx without losing your security headers

Turn on HTTP/3 in nginx on AlmaLinux 9, keep HSTS and your other headers on every response, and open the UDP port that HTTP/3 needs.

Checked against the official documentation on

On this page10 sections
  1. 01Before you start
  2. 02Check that your nginx can do HTTP/3
  3. 03The trap: how add_header inheritance works
  4. 04Keep your headers in one snippet
  5. 05Add HTTP/3 to the server block
  6. 06Locations with headers of their own
  7. 07Open UDP port 443
  8. 08Check that HTTP/3 is really used
  9. 09A shortcut in newer nginx
  10. 10Sources

This guide is for anyone running a site over HTTPS with nginx on AlmaLinux 9 who wants to add HTTP/3. At the end your site will answer over HTTP/3, every response will still carry the security headers it had before, and you will know how to check both.

HTTP/3 itself takes three lines of configuration. The trouble is what one of those lines does to the headers you already send. nginx accepts the change, nginx -t reports success, the site keeps working, and your Strict-Transport-Security header quietly stops being sent. Nothing in any log tells you. The second trap is the firewall: HTTP/3 runs over UDP, so a server that only allows TCP port 443 never serves a single HTTP/3 request, and browsers fall back to HTTP/2 without complaint.

Checked on

AlmaLinux 9.8, nginx 1.26.3 from the AlmaLinux AppStream nginx:1.26 module stream, firewalld 1.3. Firewall commands checked with firewall-offline-cmd and against the firewall-cmd manual.

Before you start

You need:

  • AlmaLinux 9 with a user that can run sudo.
  • nginx already serving your site over HTTPS, with a working certificate.
  • firewalld running (the AlmaLinux default).

The examples use example.com. Replace it with your own domain.

Check that your nginx can do HTTP/3

HTTP/3 support arrived in nginx 1.25.0. It also has to be compiled in. Ask your nginx:

Shell · your user
nginx -V 2>&1 | grep -o with-http_v3_module

Output

with-http_v3_module

If that prints nothing, your nginx cannot serve HTTP/3. On AlmaLinux 9 the default AppStream nginx is 1.20.1. The AppStream nginx:1.26 module stream is built with HTTP/3 support, so you can switch to it without adding any third-party repository.

Warning

Switching module streams replaces the nginx package with a newer major version. Back up your configuration first, and test it before you restart. To go back to the default 1.20 package, run sudo dnf module reset nginx and then sudo dnf distro-sync nginx, and restore your files from the backup. (nginx:1.20 is not a module stream, so switch-to cannot return to it.)

  1. Back up the configuration

    Shell · your user
    sudo cp -a /etc/nginx /etc/nginx.bak
  2. See which streams are available

    Shell · your user
    dnf module list nginx
  3. Switch to the 1.26 stream

    Shell · your user
    sudo dnf module switch-to nginx:1.26
  4. Test the configuration, then restart

    Shell · your user
    sudo nginx -t
    sudo systemctl restart nginx

Run the nginx -V check again. It should now print with-http_v3_module.

The trap: how add_header inheritance works

The nginx documentation for add_header says the directives "are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level."

So nginx does not merge headers from different levels. A level either inherits everything from above, or it uses only its own add_header lines. Adding a single header to a level that had none throws away everything it used to inherit.

Here is what that looks like. Say your security headers live at the http level, in a file that nginx loads from /etc/nginx/conf.d/:

/etc/nginx/conf.d/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;

Your server block has no headers of its own, so it inherits both:

Shell · your user
curl -sI https://example.com/ | grep -iE 'strict-transport|x-content-type|alt-svc'

Output

Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff

Now add HTTP/3 the way most examples show it, with the Alt-Svc header inside the server block:

/etc/nginx/conf.d/example.com.conf
server {
    listen 443 ssl;
    listen 443 quic reuseport;
    server_name example.com;

    ssl_certificate     /etc/pki/nginx/example.com.crt;
    ssl_certificate_key /etc/pki/nginx/private/example.com.key;

    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    root /var/www/example;
}

nginx -t passes. Run the same curl again:

Output

Alt-Svc: h3=":443"; ma=86400

Both security headers are gone. The server block now has an add_header of its own, so it no longer inherits anything from the http level.

Keep your headers in one snippet

The fix is to stop relying on inheritance for headers that must always be there. Put them in a snippet file that is not in conf.d (files there are loaded at the http level automatically), and include it in every server block.

Warning

Once the file leaves conf.d, no server block inherits those headers any more. Every server block on the machine needs the include line, not only the one you are adding HTTP/3 to. Do not reload nginx until you have added it everywhere. To see which files define server blocks, run sudo grep -rln 'server_name' /etc/nginx/. To undo, move the file back into conf.d.

  1. Create the snippets directory

    Shell · your user
    sudo mkdir -p /etc/nginx/snippets
  2. Move the headers into a snippet

    Shell · your user
    sudo mv /etc/nginx/conf.d/security-headers.conf /etc/nginx/snippets/security-headers.conf

Add HTTP/3 to the server block

Now write the server block with the snippet included next to the Alt-Svc header:

/etc/nginx/conf.d/example.com.conf
server {
    listen 443 ssl;
    listen 443 quic reuseport;
    server_name example.com;

    ssl_certificate     /etc/pki/nginx/example.com.crt;
    ssl_certificate_key /etc/pki/nginx/private/example.com.key;

    include /etc/nginx/snippets/security-headers.conf;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    root /var/www/example;
}

What each new line does:

  • listen 443 quic accepts QUIC connections, which is the transport HTTP/3 runs on. The nginx documentation recommends using the same port for HTTP/3 and HTTPS.
  • reuseport gives each worker process its own listening socket, which the QUIC documentation says is needed for QUIC to work properly with multiple workers.
  • Alt-Svc tells browsers that HTTP/3 is available on port 443. Browsers first connect over HTTP/1.1 or HTTP/2, see this header, and use HTTP/3 on later requests. ma=86400 lets them remember that for a day.

reuseport may appear only once for each address and port in your whole configuration. If you host several sites, put listen 443 quic reuseport; in exactly one server block and plain listen 443 quic; in all the others. If two blocks both say reuseport, the test fails:

Shell · your user
sudo nginx -t

Output

nginx: [emerg] duplicate listen options for 0.0.0.0:443 in /etc/nginx/conf.d/second.example.com.conf:3
nginx: configuration file /etc/nginx/nginx.conf test failed

Warning

Keep that in mind when you remove a site later. If you delete the one server block that holds reuseport, move it to another block in the same change.

Test and reload:

Shell · your user
sudo nginx -t
sudo systemctl reload nginx

The curl check now shows all three headers:

Output

Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
Alt-Svc: h3=":443"; ma=86400

Locations with headers of their own

The same rule applies one level down. A location block with its own add_header stops inheriting from the server block. A common case is a cache header for static files:

nginx
location /static/ {
    add_header Cache-Control "public, max-age=604800";
}

Requests under /static/ now get only Cache-Control. The security headers and Alt-Svc from the server block are gone:

Shell · your user
curl -sI https://example.com/static/app.css | grep -iE 'strict-transport|x-content-type|alt-svc|cache-control'

Output

Cache-Control: public, max-age=604800

Repeat the include and the Alt-Svc line inside the location:

nginx
location /static/ {
    include /etc/nginx/snippets/security-headers.conf;
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    add_header Cache-Control "public, max-age=604800";
}

Output

Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
Alt-Svc: h3=":443"; ma=86400
Cache-Control: public, max-age=604800

To find every block that sets its own headers, search your configuration:

Shell · your user
sudo grep -rn 'add_header' /etc/nginx/

Every server or location block that appears there needs the include line too.

Open UDP port 443

HTTP/3 uses UDP, not TCP. The https service in firewalld opens TCP port 443 only. firewalld 1.3 ships a separate http3 service for UDP port 443:

Shell · your user
sudo firewall-cmd --info-service=http3

Output

http3
  ports: 443/udp
  protocols: 
  source-ports: 
  modules: 
  destination: 
  includes: 
  helpers: 

Add it permanently, then reload so the permanent rules become active:

Warning

firewall-cmd --reload replaces the running rules with the permanent ones. Any change you made earlier without --permanent is lost. If you rely on such a change, add it again with --permanent before you reload.

Shell · your user
sudo firewall-cmd --permanent --add-service=http3
sudo firewall-cmd --reload
sudo firewall-cmd --list-services

The list should include both https and http3.

Note

If your server sits behind a firewall that your hosting provider manages (a security group or a network firewall in a control panel), UDP port 443 has to be allowed there as well. A closed UDP port produces no error anywhere: browsers simply keep using HTTP/2.

Check that HTTP/3 is really used

A header saying HTTP/3 is available proves nothing about whether it works, so check the server end and the request itself.

nginx should be listening on UDP port 443:

Shell · your user
sudo ss -ulnp | grep ':443'

You should see one or more lines for nginx. With reuseport there is one socket per worker process.

Next, visit your site in a browser, then reload the page. The first visit learns about HTTP/3 from the Alt-Svc header, and the reload can use it. Then look at the nginx access log:

Shell · your user
sudo tail -n 5 /var/log/nginx/access.log

Requests that arrived over HTTP/3 show the protocol as HTTP/3.0, for example "GET / HTTP/3.0" 200. If every line still says HTTP/1.1 or HTTP/2.0 after a reload, go back to the firewall section.

Note

The curl that ships with AlmaLinux 9 is built without HTTP/3 support (curl -V does not list HTTP3), so it cannot test this step. Use a browser, or a client from another machine that supports HTTP/3.

A shortcut in newer nginx

nginx 1.29.3 added the add_header_inherit directive. Setting it to merge makes a level add its own headers to the inherited ones instead of replacing them. AlmaLinux 9 AppStream does not ship that version, so the snippet approach above is what works with the packages you get from AlmaLinux today. If you use a newer nginx, read that directive's documentation, including how it is itself inherited, before you rely on it.

Sources