diff --git a/roles/archmanweb/templates/nginx.d.conf.j2 b/roles/archmanweb/templates/nginx.d.conf.j2
index 275f38162b0c6565b0e91b18ac4603c4fe980ce0..3c51fe125b053cc51ee0b48101e43dd4a838f9c0 100644
--- a/roles/archmanweb/templates/nginx.d.conf.j2
+++ b/roles/archmanweb/templates/nginx.d.conf.j2
@@ -49,6 +49,7 @@ server {
     # Client-cache for Django's static assets
     location /static/ {
         expires 30d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public";
         alias {{ archmanweb_dir }}/repo/collected_static/;
diff --git a/roles/archweb/templates/ipxe.archlinux.org.j2 b/roles/archweb/templates/ipxe.archlinux.org.j2
index 2df4e1d6007c854437a4340c8ee5e6e6e6aeaf96..dd6c6f374d165307ef596d2bc1549910bf6eabf2 100644
--- a/roles/archweb/templates/ipxe.archlinux.org.j2
+++ b/roles/archweb/templates/ipxe.archlinux.org.j2
@@ -41,6 +41,7 @@ server {
     # Cache django's css, js and png files.
     location /static/ {
         expires 30d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public";
         alias /srv/http/archweb/collected_static/;
diff --git a/roles/archweb/templates/maintenance-nginx.d.conf.j2 b/roles/archweb/templates/maintenance-nginx.d.conf.j2
index 6eddee94891bc5189a37bcfc1578d3fdde6dd19f..e9f743772ac7276aa3b98853594f23d32c25e1e6 100644
--- a/roles/archweb/templates/maintenance-nginx.d.conf.j2
+++ b/roles/archweb/templates/maintenance-nginx.d.conf.j2
@@ -120,6 +120,7 @@ server {
 
     location = /.well-known/matrix/client {
         default_type application/json;
+        include snippets/headers.conf;
         add_header Access-Control-Allow-Origin *;
         return 200 '{"m.homeserver": {"base_url": "https://{{ matrix_domain }}"}, "m.identity_server": {"base_url": "https://matrix.org"} }';
     }
@@ -167,6 +168,7 @@ server {
     # Cache django's css, js and png files.
     location /static/ {
         expires 30d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public";
         alias {{ archweb_dir }}/collected_static/;
diff --git a/roles/archweb/templates/nginx.d.conf.j2 b/roles/archweb/templates/nginx.d.conf.j2
index 8dcd8d7b0c022166fc85d9570f30590f4706888d..186befa292592a4ae0d96ce462b63a49951608e9 100644
--- a/roles/archweb/templates/nginx.d.conf.j2
+++ b/roles/archweb/templates/nginx.d.conf.j2
@@ -42,6 +42,7 @@ server {
     include snippets/letsencrypt.conf;
 
     location /.well-known/ {
+        include snippets/headers.conf;
         add_header Access-Control-Allow-Origin *;
         return 301 https://$server_name$request_uri;
     }
@@ -67,6 +68,7 @@ server {
     ssl_trusted_certificate /etc/letsencrypt/live/{{ archweb_domain }}/chain.pem;
 
     location /.well-known/ {
+        include snippets/headers.conf;
         add_header Access-Control-Allow-Origin *;
         return 301 https://{{ archweb_domain }}{{ domain['redirect']|default('$request_uri') }};
     }
@@ -120,6 +122,7 @@ server {
 
     location = /.well-known/matrix/client {
         default_type application/json;
+        include snippets/headers.conf;
         add_header Access-Control-Allow-Origin *;
         return 200 '{"m.homeserver": {"base_url": "https://{{ matrix_domain }}"}, "m.identity_server": {"base_url": "https://matrix.org"} }';
     }
@@ -169,6 +172,7 @@ server {
     # Cache django's css, js and png files.
     location /static/ {
         expires 30d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public";
         alias {{ archweb_dir }}/collected_static/;
@@ -189,6 +193,7 @@ server {
 
         uwsgi_cache archwebcache;
         uwsgi_cache_revalidate on;
+        include snippets/headers.conf;
         add_header X-Cache-Status $upstream_cache_status;
 
         limit_req zone=rsslimit burst=10 nodelay;
@@ -202,6 +207,7 @@ server {
         uwsgi_cache archwebcache;
         uwsgi_cache_revalidate on;
         uwsgi_cache_key $cache_key;
+        include snippets/headers.conf;
         add_header X-Cache-Status $upstream_cache_status;
 
         limit_req zone=mirrorstatuslimit burst=10 nodelay;
@@ -235,11 +241,9 @@ server {
         uwsgi_cache archwebcache;
         uwsgi_cache_revalidate on;
         uwsgi_cache_key $cache_key;
+        include snippets/headers.conf;
         add_header X-Cache-Status $upstream_cache_status;
 
-        # re-add HSTS (inheritance from sslsettings.conf broken by above header)
-        add_header Strict-Transport-Security $hsts_header always;
-
         limit_req zone=archweblimit burst=10 nodelay;
     }
 }
diff --git a/roles/archwiki/templates/nginx.d.conf.j2 b/roles/archwiki/templates/nginx.d.conf.j2
index 6a63335447acd5e09f9a990a50210e7b87f2479a..0be7fe3187b7254fdeb86c4466cec916dad46900 100644
--- a/roles/archwiki/templates/nginx.d.conf.j2
+++ b/roles/archwiki/templates/nginx.d.conf.j2
@@ -125,6 +125,7 @@ server {
         fastcgi_cache_use_stale updating;
         fastcgi_cache_lock on;
 
+        include snippets/headers.conf;
         add_header X-Cache $upstream_cache_status;
 {% endblock %}
     }
@@ -143,6 +144,7 @@ server {
     # normal PHP FastCGI handler
     location ~ ^/[^/]+\.php$ {
         if ($challenge) {
+            include snippets/headers.conf;
             add_header Set-Cookie "challenge={{ archwiki_nginx_challenge_value }}; SameSite=Strict";
             return 303 $scheme://$server_name/$request_uri;
         }
@@ -165,12 +167,14 @@ server {
     # MediaWiki assets
     location ~ ^/(?:images|resources/(?:assets|lib|src)|(?:skins|extensions)/.+\.(?:css|js|gif|jpg|jpeg|png|svg|wasm)$) {
         expires 30d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
     }
 
     location /images/ {
         # Add the nosniff header to the images folder (required for mw 1.40+)
+        include snippets/headers.conf;
         add_header X-Content-Type-Options nosniff;
     }
 
diff --git a/roles/aurweb/templates/nginx.d.conf.j2 b/roles/aurweb/templates/nginx.d.conf.j2
index 1373b66231a9c2857fa23e84fdc0f978f8195f57..a594ef98c7e84e8e84ded8fafa3f3390589f80fe 100644
--- a/roles/aurweb/templates/nginx.d.conf.j2
+++ b/roles/aurweb/templates/nginx.d.conf.j2
@@ -110,6 +110,7 @@ server {
     location ~ \.gz$ {
         root    {{ aurweb_dir }}/archives;
         default_type text/plain;
+        include snippets/headers.conf;
         add_header Content-Encoding gzip;
         expires 5m;
     }
@@ -118,6 +119,7 @@ server {
         rewrite ^/static(/.*)$ $1 break;
 
         expires 7d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
     }
diff --git a/roles/fluxbb/templates/nginx.conf.j2 b/roles/fluxbb/templates/nginx.conf.j2
index f4678cf833fa64b6c539b340e578ce7e97b95478..563cfb13e5c4dc0bd973bc5f375ec276bf804400 100644
--- a/roles/fluxbb/templates/nginx.conf.j2
+++ b/roles/fluxbb/templates/nginx.conf.j2
@@ -76,12 +76,14 @@ server {
 
     location ^~ /style/ {
         expires 7d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
     }
 
     location ^~ /img/ {
         expires 7d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
     }
diff --git a/roles/matrix/templates/nginx.d.conf.j2 b/roles/matrix/templates/nginx.d.conf.j2
index a353415c317ec21e46cf25761fdeedf916f8d800..85fdc500f40a4f48dfefc05a57a557e8affca11f 100644
--- a/roles/matrix/templates/nginx.d.conf.j2
+++ b/roles/matrix/templates/nginx.d.conf.j2
@@ -45,6 +45,7 @@ server {
         access_log /var/log/nginx/{{ matrix_domain }}/access.log main;
         access_log /var/log/nginx/{{ matrix_domain }}/access.log.json json_main;
 {% if location.add_cors | default(false) %}
+        include snippets/headers.conf;
         add_header Access-Control-Allow-Origin *;
         add_header Access-Control-Allow-Methods "GET, HEAD, POST, PUT, DELETE, OPTIONS";
         add_header Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization, Date";
diff --git a/roles/mirrorsync/templates/nginx.d.conf.j2 b/roles/mirrorsync/templates/nginx.d.conf.j2
index 22604eb5718d766a42dd8ea847fb333bd937de2d..4eb9cd844612f9fc117f0bd73264937b5d019298 100644
--- a/roles/mirrorsync/templates/nginx.d.conf.j2
+++ b/roles/mirrorsync/templates/nginx.d.conf.j2
@@ -17,6 +17,7 @@ server {
     ssl_certificate_key  /etc/letsencrypt/live/{{ item.value.mirror_domain }}/privkey.pem;
     ssl_trusted_certificate /etc/letsencrypt/live/{{ item.value.mirror_domain }}/chain.pem;
 
+    include snippets/headers.conf;
     add_header X-Served-By "{{ inventory_hostname }}";
 
     autoindex on;
diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml
index 5ff618a573ef8e64e496420bfd3ebc663afc8050..1129b280c9319ffaebe38f6529c5853fd7f125f6 100644
--- a/roles/nginx/tasks/main.yml
+++ b/roles/nginx/tasks/main.yml
@@ -23,6 +23,7 @@
   with_items:
     - letsencrypt.conf
     - sslsettings.conf
+    - headers.conf
   notify:
     - Reload nginx
 
diff --git a/roles/nginx/templates/headers.conf b/roles/nginx/templates/headers.conf
new file mode 100644
index 0000000000000000000000000000000000000000..d427430373b3ebda009fe5518b3cee417e34ae1b
--- /dev/null
+++ b/roles/nginx/templates/headers.conf
@@ -0,0 +1 @@
+add_header Strict-Transport-Security $hsts_header always;
diff --git a/roles/nginx/templates/nginx.conf.j2 b/roles/nginx/templates/nginx.conf.j2
index 584c26406e984abf1a12733bf77688d60a09ec45..29ba71416fae23b104ce8fb717a9387456a74c4d 100644
--- a/roles/nginx/templates/nginx.conf.j2
+++ b/roles/nginx/templates/nginx.conf.j2
@@ -90,6 +90,7 @@ http {
     access_log syslog:server=unix:/dev/log,nohostname,tag=nginx_http main;
 
     include snippets/sslsettings.conf;
+    include snippets/headers.conf;
 
     include nginx.d/*.conf;
 }
diff --git a/roles/nginx/templates/sslsettings.conf b/roles/nginx/templates/sslsettings.conf
index 1c98ad9ffd976a9fbcc41f3733c68f23dcf8132a..43d7f9382c2f6af3c3e81d6f7d4d6f689cceabc3 100644
--- a/roles/nginx/templates/sslsettings.conf
+++ b/roles/nginx/templates/sslsettings.conf
@@ -13,10 +13,9 @@ ssl_session_tickets off;
 ssl_stapling on;
 ssl_stapling_verify on;
 
+# See headers.conf for the HSTS add_header line.
 map $scheme $hsts_header {
     https   "max-age=31536000; includeSubdomains; preload";
 }
 
-add_header Strict-Transport-Security $hsts_header always;
-
 resolver 127.0.0.53;
diff --git a/roles/ping/templates/nginx.d.conf.j2 b/roles/ping/templates/nginx.d.conf.j2
index 0149561ca94dce43b9f2a73d1002f3b21ae6611f..ea37574269ba60617c953c0997f5e3bcf8c524c3 100644
--- a/roles/ping/templates/nginx.d.conf.j2
+++ b/roles/ping/templates/nginx.d.conf.j2
@@ -26,6 +26,7 @@ server {
     # https://man.archlinux.org/man/NetworkManager.conf.5#CONNECTIVITY_SECTION
     location = /nm-check.txt {
         access_log off;
+        include snippets/headers.conf;
         add_header Cache-Control "max-age=0, must-revalidate";
         return 200 'NetworkManager is online\n';
     }
diff --git a/roles/rebuilderd/templates/nginx.d.conf.j2 b/roles/rebuilderd/templates/nginx.d.conf.j2
index 54ec638903ff40a34b3d44cf7afd41efd274e91f..bd4cc6c5a15f1e21b8d6bf1244cbb1b83fed283c 100644
--- a/roles/rebuilderd/templates/nginx.d.conf.j2
+++ b/roles/rebuilderd/templates/nginx.d.conf.j2
@@ -30,6 +30,7 @@ server {
     ssl_trusted_certificate /etc/letsencrypt/live/{{ rebuilderd_domain }}/chain.pem;
 
     # Security headers
+    include snippets/headers.conf;
     add_header X-Frame-Options "SAMEORIGIN" always;
     add_header X-Xss-Protection "1; mode=block" always;
     add_header Referrer-Policy "same-origin";
@@ -37,13 +38,11 @@ server {
     add_header Content-Security-Policy "default-src 'self';";
     add_header X-Content-Type-Options "nosniff" always;
 
-    # Apply HSTS header again, since adding a header removes previous headers
-    add_header Strict-Transport-Security $hsts_header;
-
     root {{ rebuilder_website_loc }};
 
     location ~* (css|js|svg)$ {
         expires 30d;
+        include snippets/headers.conf;
         add_header Pragma public;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
     }
diff --git a/roles/syncrepo/templates/nginx.d.conf.j2 b/roles/syncrepo/templates/nginx.d.conf.j2
index e5925f8372dd52c5b4d2f87165e233be71ff27db..c5743639e531a6341ce45b8f06a291d46b6e9137 100644
--- a/roles/syncrepo/templates/nginx.d.conf.j2
+++ b/roles/syncrepo/templates/nginx.d.conf.j2
@@ -19,6 +19,7 @@ server {
     ssl_trusted_certificate /etc/letsencrypt/live/{{ domain }}/chain.pem;
 
 {% if 'geo_mirrors' in group_names and domain == geo_mirror_domain %}
+    include snippets/headers.conf;
     add_header X-Served-By "{{ inventory_hostname }}";
 {% endif %}