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
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:
nginx -V 2>&1 | grep -o with-http_v3_moduleOutput
with-http_v3_moduleIf 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.)
Back up the configuration
Shell · your usersudo cp -a /etc/nginx /etc/nginx.bakSee which streams are available
Shell · your userdnf module list nginxSwitch to the 1.26 stream
Shell · your usersudo dnf module switch-to nginx:1.26Test the configuration, then restart
Shell · your usersudo 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/:
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:
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: nosniffNow add HTTP/3 the way most examples show it, with the Alt-Svc header inside the server block:
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=86400Both 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.
Create the snippets directory
Shell · your usersudo mkdir -p /etc/nginx/snippetsMove the headers into a snippet
Shell · your usersudo 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:
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 quicaccepts QUIC connections, which is the transport HTTP/3 runs on. The nginx documentation recommends using the same port for HTTP/3 and HTTPS.reuseportgives each worker process its own listening socket, which the QUIC documentation says is needed for QUIC to work properly with multiple workers.Alt-Svctells 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=86400lets 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:
sudo nginx -tOutput
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 failedWarning
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:
sudo nginx -t
sudo systemctl reload nginxThe curl check now shows all three headers:
Output
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
Alt-Svc: h3=":443"; ma=86400Locations 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:
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:
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=604800Repeat the include and the Alt-Svc line inside the location:
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=604800To find every block that sets its own headers, search your configuration:
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:
sudo firewall-cmd --info-service=http3Output
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.
sudo firewall-cmd --permanent --add-service=http3
sudo firewall-cmd --reload
sudo firewall-cmd --list-servicesThe 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:
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:
sudo tail -n 5 /var/log/nginx/access.logRequests 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
- nginx: ngx_http_headers_module (add_header, add_header_inherit)
- nginx: Support for QUIC and HTTP/3
- nginx: ngx_http_v3_module
- nginx: listen directive (quic, reuseport)
- Red Hat Enterprise Linux 9: Setting up and configuring NGINX
- firewalld: firewall-cmd manual
- RFC 7838: HTTP Alternative Services (Alt-Svc)