fix(security): unprivileged nginx + security headers on every location

- base image -> nginxinc/nginx-unprivileged:1.28-alpine, digest-pinned
  (BREAKING for the docker path: container port 80 -> 8080; compose
  mapping/healthcheck updated in the same change, cap_add dropped)
- nginx add_header inheritance fix: shared snippets file re-included in
  every location that sets Cache-Control -- previously ALL security
  headers were dropped on real responses (verified live before/after)
- server_tokens off; set_real_ip_from restricted to 127.0.0.1
- expires directives removed (duplicated Cache-Control); gzip_types
  text/html redundancy removed (nginx -t warn)
This commit is contained in:
Bastien Chanot
2026-07-05 14:10:55 +02:00
parent 5a813df015
commit ba13d697a5
4 changed files with 43 additions and 38 deletions
+10 -8
View File
@@ -1,27 +1,29 @@
# Static site for bchanot.fr # Static site for bchanot.fr
# nginx:alpine serves index.html + CV (HTML + PDF). # nginx-unprivileged serves index.html + CV (HTML + PDF) as uid 101 —
# no root master process in the container (tag + digest pinned).
FROM nginx:1.27-alpine FROM nginxinc/nginx-unprivileged:1.28-alpine@sha256:209331cfcaec00da781f5b8a38e0d1c0abd00cb2b51e6ad385a30abbbdb04e15
# Custom nginx config (gzip, cache, security headers). # Custom nginx config (gzip, cache, security headers).
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY nginx-security-headers.conf /etc/nginx/snippets/security-headers.conf
# Site assets. # Site assets — clean the default content, then copy ours.
WORKDIR /usr/share/nginx/html WORKDIR /usr/share/nginx/html
USER root
RUN rm -rf ./* RUN rm -rf ./*
USER nginx
COPY index.html ./ COPY index.html ./
COPY CV_Bastien_Chanot.html ./ COPY CV_Bastien_Chanot.html ./
COPY CV_Bastien_Chanot.pdf ./ COPY CV_Bastien_Chanot.pdf ./
COPY favicon.svg favicon-32.png favicon.ico apple-touch-icon.png ./ COPY favicon.svg favicon-32.png favicon.ico apple-touch-icon.png ./
# Non-root hardening: nginx:alpine already drops privileges to "nginx" user # nginx-unprivileged listens on 8080 (>1024, no NET_BIND_SERVICE needed).
# for worker processes. Master runs as root only to bind port 80 inside EXPOSE 8080
# the container — fine because the host port is the one exposed.
EXPOSE 80
# Basic healthcheck: nginx must serve index.html. # Basic healthcheck: nginx must serve index.html.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1 CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+3 -9
View File
@@ -18,24 +18,18 @@ services:
container_name: bchanot-web container_name: bchanot-web
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:${PORT:-8080}:80" - "127.0.0.1:${PORT:-8080}:8080"
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/"]
interval: 30s interval: 30s
timeout: 3s timeout: 3s
retries: 3 retries: 3
start_period: 5s start_period: 5s
read_only: true read_only: true
tmpfs: tmpfs:
- /var/cache/nginx # nginx-unprivileged writes pid + temp files under /tmp only.
- /var/run
- /tmp - /tmp
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
cap_add:
- CHOWN
- SETGID
- SETUID
- NET_BIND_SERVICE
+12
View File
@@ -0,0 +1,12 @@
# Security headers for bchanot.fr — included at server level AND in every
# location that declares its own add_header: nginx add_header inheritance
# is all-or-nothing (one add_header in a location drops ALL inherited
# headers), so each such location must re-include this file.
# HSTS is intentionally NOT here — it belongs to the TLS-terminating proxy.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
# CSP: inline CSS + JS are allowed (project convention), fonts from Google.
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always;
+18 -21
View File
@@ -1,29 +1,27 @@
# nginx server block for bchanot.fr static site. # nginx server block for bchanot.fr static site.
# Container listens on port 80; host port is configured via docker-compose # Container (nginx-unprivileged) listens on 8080; host port is configured via
# (PORT env var). A host-level reverse proxy (nginx, Traefik, Caddy) should # docker-compose (PORT env var). A host-level reverse proxy (nginx, Traefik,
# terminate TLS and proxy_pass to http://127.0.0.1:${PORT}. # Caddy) should terminate TLS and proxy_pass to http://127.0.0.1:${PORT}.
server { server {
listen 80; listen 8080;
listen [::]:80; listen [::]:8080;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Security headers. HSTS is intentionally NOT set here — leave it to the # Don't advertise the nginx version.
# outer reverse proxy that terminates TLS, otherwise it may be sent over server_tokens off;
# plain HTTP between proxy and container.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
# CSP: inline CSS + JS are allowed (project convention), fonts from Google.
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; img-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" always;
# Forwarded headers — trust the upstream reverse proxy. # Security headers — shared snippet. Re-included in every location that
# sets its own add_header (inheritance is all-or-nothing in nginx).
include /etc/nginx/snippets/security-headers.conf;
# Forwarded headers — trust only the local reverse proxy (the container
# port is bound to 127.0.0.1 in docker-compose).
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;
set_real_ip_from 0.0.0.0/0; set_real_ip_from 127.0.0.1;
# Compression. # Compression.
gzip on; gzip on;
@@ -34,7 +32,6 @@ server {
gzip_types gzip_types
text/plain text/plain
text/css text/css
text/html
text/javascript text/javascript
application/javascript application/javascript
application/json application/json
@@ -44,24 +41,24 @@ server {
# Long cache for the PDF (regenerated rarely, content-hash not used). # Long cache for the PDF (regenerated rarely, content-hash not used).
location ~* \.pdf$ { location ~* \.pdf$ {
expires 7d;
add_header Cache-Control "public, max-age=604800"; add_header Cache-Control "public, max-age=604800";
include /etc/nginx/snippets/security-headers.conf;
} }
# Short cache for HTML so content updates land fast. # Short cache for HTML so content updates land fast.
location ~* \.html$ { location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, max-age=3600, must-revalidate"; add_header Cache-Control "public, max-age=3600, must-revalidate";
include /etc/nginx/snippets/security-headers.conf;
} }
# Long cache for favicon + image assets (rarely change). # Long cache for favicon + image assets (rarely change).
location ~* \.(?:ico|svg|png|jpg|jpeg|gif|webp)$ { location ~* \.(?:ico|svg|png|jpg|jpeg|gif|webp)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable"; add_header Cache-Control "public, max-age=2592000, immutable";
include /etc/nginx/snippets/security-headers.conf;
access_log off; access_log off;
} }
# Logs to stdout/stderr (default in nginx:alpine). # Logs to stdout/stderr (default in nginx images).
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn; error_log /var/log/nginx/error.log warn;