====== Traefik ======
; URL : [[https://traefik.smns-bw.org/|https://traefik.smns-bw.org/]]
; User : smns-tr
; Passwort : U2FsdGVkX18v7/dGh8b4MGuIQ8RPzXqjcWSlZ/AaEFgCQeNQcE1AYQxEWGLdiM9Y
; Production : hetzner:/opt/traefik/
├── traefik
│ ├── configfiles
│ │ ├── config.yml
│ │ ├── middleware-chains.yml
│ │ ├── middlewares.yml
│ │ ├── tls-opts.yml
│ ├── docker-compose.yml
│ ├── .env
│ ├── traefik.log
│ ├── traefik.yml
│ ├── access.log
In .env steht die URL traefik.smns-bw.org sowie die Zugangsdaten für diese Seite für docker-compose.yml
In die einzelnen docker-compose.yml Files der Container kommt dann sowas (Beispiel Sammlungskatalog):
labels:
- "traefik.http.routers.webportal.rule=Host(`${URL}`)"
- "traefik.http.routers.webportal.entrypoints=https"
- "traefik.http.routers.webportal.tls=true"
- "traefik.http.routers.webportal.tls.certresolver=leresolver"
- "traefik.http.routers.webportal.middlewares=secure-collections@file"
- "traefik.http.services.webportal.loadbalancer.server.port=6543"
- "traefik.http.services.webportal.loadbalancer.sticky=true"
- "traefik.http.services.webportal.loadbalancer.sticky.cookie.name=collections.smns-bw.org"
- "traefik.http.services.webportal.loadbalancer.sticky.cookie.httpOnly=true"
- "traefik.http.services.webportal.loadbalancer.sticky.cookie.secure=true"
- "traefik.docker.network=proxy"
Damit der Docker.sock nicht nach außen exposed ist, ist zusammen mit Traefik ein docker-socket aufgesetzt, der von hier stammt: [[https://github.com/wollomatic/socket-proxy|https://github.com/wollomatic/socket-proxy]]
services:
traefik:
container_name: traefik
image: "traefik:latest"
restart: always
# read_only: true
command:
- --configfile=/traefik.yml
mem_limit: 2G
cpus: 0.75
ports:
- "80:80"
- "443:443"
volumes:
- "./acme.json:/acme.json"
- "./traefik.yml:/traefik.yml:ro"
- "./configfiles:/configfiles:ro"
# - ".logs/traefik.log:/traefik.log"
# - ".logs/access.log:/access.log"
- "./logs:/logs:rw"
depends_on:
- "dockerproxy"
security_opt:
- "no-new-privileges:true"
networks:
- "proxy"
- "docker-proxynet"
labels:
- "traefik.http.routers.traefik.entrypoints=https"
- "traefik.http.routers.traefik.rule=Host(`traefik.smns-bw.org`)"
- "traefik.http.routers.traefik.tls=true"
- "traefik.http.routers.traefik.tls.certresolver=leresolver"
# API service
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=secure-traefik@file"
- "traefik.http.services.traefik.loadbalancer.sticky=true"
- "traefik.http.services.traefik.loadbalancer.sticky.cookie.httpOnly=true"
- "traefik.http.services.traefik.loadbalancer.sticky.cookie.secure=true"
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:8082/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
dockerproxy:
build:
context: .
container_name: socket-proxy
command:
- '-loglevel=DEBUG'
- '-allowfrom=traefik,172.31.0.1'
- '-listenip=0.0.0.0'
- '-allowGET=/v1\..{1,2}/(version|containers/.*|events.*)'
- '-shutdowngracetime=5'
restart: unless-stopped
user: "65534:998"
read_only: true
mem_limit: 64M
cap_drop:
- ALL
security_opt:
- no-new-privileges
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- "proxy"
- "docker-proxynet"
healthcheck:
test: [ "CMD", "nc", "-z", "localhost", "2375" ]
interval: 1m
timeout: 3s
retries: 3
error-pages-webportal:
container_name: error-webportal
image: nginx:alpine
restart: always
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./mime.types:/etc/nginx/mime.types
- ./error-pages-webportal:/usr/share/nginx/html:ro
labels:
- "traefik.http.routers.error-pages-webportal.entrypoints=https"
- "traefik.http.routers.error-pages-webportal.tls=true"
- "traefik.http.routers.error-pages-webportal.tls.certresolver=leresolver"
- "traefik.http.routers.error-pages-webportal.rule=Host(`error-webportal.smns-bw.org`)"
# - "traefik.http.routers.error-pages-webportal.middlewares=secure-global@file"
- "traefik.http.services.error-pages-webportal.loadbalancer.sticky=true"
- "traefik.http.services.error-pages-webportal.loadbalancer.sticky.cookie.httpOnly=true"
- "traefik.http.services.error-pages-webportal.loadbalancer.sticky.cookie.secure=true"
- "traefik.http.services.error-pages-webportal.loadbalancer.server.port=80"
networks:
- "proxy"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/healthz.html"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
proxy:
external: true
docker-proxynet:
driver: bridge
internal: true
\\
entryPoints:
http:
address: ":80"
http:
redirections:
entryPoint:
to: ':443'
scheme: https
https:
address: ":443"
# http3:
# advertisedPort: 443
postgres:
address: ":5434"
ssh:
address: ":666"
healthcheck:
address: ":8082"
log:
level: INFO
filePath: "/logs/traefik.log"
format: json
accessLog:
filePath: "/logs/access.log"
bufferingSize: 100
api: {}
ping:
entryPoint: healthcheck
providers:
docker:
# endpoint: "tcp://socket-proxy:2375"
endpoint: "tcp://dockerproxy:2375"
# endpoint: "unix:///var/run/docker.sock"
watch: true
network: proxy
# exposedByDefault: false
file:
directory: "/configfiles"
watch: true
certificatesResolvers:
leresolver:
acme:
email: "it@smns-bw.org"
storage: "/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
tlsChallenge: {}
experimental:
plugins:
traefik-plugin-cookie-path-prefix:
moduleName: "github.com/SchmitzDan/traefik-plugin-cookie-path-prefix"
version: "v0.0.3"
\\
http:
routers:
webmin:
entryPoints:
- "https"
rule: Host(`webmin.smns-bw.org`)
service: webmin
middlewares:
- "secure-webmin"
tls:
certResolver: leresolver
options: tls-opts
traefik:
rule: Host(`traefik.smns-bw.org`)
service: cookies
smns_stats:
rule: Host(`statistics.smns-bw.org`)
# rule: "PathPrefix(`/statistics`)"
service: smns_stats
entryPoints:
- "https"
middlewares:
- "secure-collections"
tls:
certResolver: leresolver
options: tls-opts
services:
webmin:
loadBalancer:
servers:
- url: "http://172.18.0.1:10000/"
passHostHeader: true
sticky:
cookie:
secure: true
httpOnly: true
sameSite: lax
smns_stats:
loadBalancer:
servers:
- url: "http://172.18.0.1:8050"
passHostHeader: true
cookies:
loadBalancer:
sticky:
cookie:
secure: true
httpOnly: true
sameSite: lax
\\
http:
middlewares:
secure-global:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-base"
secure-traefik:
chain:
middlewares:
- "traefik-auth" # auth first, so errors still get headers/CSP
- "security-headers-base"
- "cors-default"
- "CSP-base"
secure-idservice:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-idservice"
secure-biocase:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-biocase"
secure-webmin:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-webmin"
secure-prestashop:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-prestashop"
secure-ent:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-ent"
secure-collections:
chain:
middlewares:
- "webportal-errors" # moved before headers so error pages get hardened
- "security-headers-hsts-subdomains"
- "security-headers-allowframes"
- "cors-collections"
- "CSP-collections"
secure-digiphyll:
chain:
middlewares:
- "security-headers-base"
- "security-headers-allowframes"
- "cors-default"
- "CSP-digiphyll"
secure-dokuwiki:
chain:
middlewares:
- "security-headers-hsts-subdomains"
- "security-headers-allowframes"
- "cors-default"
- "CSP-dokuwiki"
secure-geometroidea:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-geometroidea"
secure-h5p:
chain:
middlewares:
- "security-headers-base"
- "security-headers-allowframes"
- "cors-default"
- "CSP-h5p"
secure-awstats:
chain:
middlewares:
- "security-headers-base"
- "cors-default"
- "CSP-awstats"
secure-librechat:
chain:
middlewares:
- "security-headers-noindex"
- "cors-default"
- "CSP-librechat"
secure-pictures:
chain:
middlewares:
- "security-headers-base"
- "security-headers-allowframes"
- "cors-default"
- "CSP-base"
\\
http:
middlewares:
default-whitelist:
ipWhiteList:
sourceRange:
- "127.0.0.1/32"
- "172.19.0.0/24"
traefik-auth:
basicAuth:
users:
- "smns-tr:$2y$10$mUF04Rf7852wnP5xGxewte1hW4X/LcEN2c7of5xPOEyzbAXGVrEAi"
# Core security baseline used by most services
security-headers-base:
headers: &SEC_BASE
addVaryHeader: true
# Response header hardening
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "same-origin"
permissionsPolicy: fullscreen=(self "https://smns-bw.org"), geolocation=*, midi=(), camera=(), usb=(), magnetometer=(), accelerometer=(), vr=(), speaker=(), ambient-light-sensor=(), gyroscope=(), microphone=(), payment=()
# HSTS
forceSTSHeader: true
stsSeconds: 31536000
stsPreload: false
# Keep includeSubdomains off in base to avoid accidental lock-in; use variant below when desired.
# stsIncludeSubdomains: false
# X-Frame-Options — deny by default (use CSP frame-ancestors for granular control)
frameDeny: true
# customFrameOptionsValue: "SAMEORIGIN" # optional alternative if you want XFO too
# TLS/Proxy awareness
sslRedirect: true # FIX: belongs at headers root (not inside sslProxyHeaders)
sslProxyHeaders:
X-Forwarded-Proto: "https"
hostsProxyHeaders:
- "X-Forwarded-Host"
# Standard response scrubbing
customResponseHeaders: &RESP_BASE
Server: "" # FIX: unify case
X-Powered-By: "" # FIX: unify case
X-Robots-Tag: "index, follow" # override via -noindex variant
# Upstream request adjustments (to your apps)
customRequestHeaders: &REQ_BASE
X-Forwarded-Proto: "https" # OK to keep here
# Variant: noindex
security-headers-noindex:
headers:
<<: *SEC_BASE
customResponseHeaders:
<<: *RESP_BASE
X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
# Variant: allow framing (prefer CSP frame-ancestors to control who can frame you)
security-headers-allowframes:
headers:
<<: *SEC_BASE
frameDeny: false
# customFrameOptionsValue is mostly ignored by modern browsers except SAMEORIGIN/DENY.
# "ALLOW-FROM" is deprecated; rely on CSP if you need granular control.
customFrameOptionsValue: "SAMEORIGIN"
# Variant: HSTS include subdomains, longer max-age
security-headers-hsts-subdomains:
headers:
<<: *SEC_BASE
stsIncludeSubdomains: true
stsSeconds: 63072000
# Optional: shared Cache-Control for static-ish services
cache-public:
headers:
customResponseHeaders:
Cache-Control: "public" # FIX: was (incorrectly) in customRequestHeaders
# CORS: compose these with the security headers as needed
cors-default:
headers: &CORS_DEFAULT
accessControlAllowMethods:
- GET
- OPTIONS
- PUT
accessControlAllowHeaders: "*"
accessControlMaxAge: 100
addVaryHeader: true
cors-collections:
headers:
<<: *CORS_DEFAULT
accessControlAllowOriginList:
- https://pydeepzoom.smns-bw.org
- https://pictures.smns-bw.org
# Base CSP used by services that only need same-origin + *.smns-bw.org
CSP-base:
headers:
contentSecurityPolicy: &CSP_BASE >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
# If you still reference CSP-global anywhere, just point it to the base:
CSP-global:
headers:
contentSecurityPolicy: *CSP_BASE
# Deltas below (only where different from base)
CSP-dokuwiki:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org *.google.com;
base-uri 'self';
form-action 'self';
img-src 'self' https://www.dokuwiki.org https://www.gravatar.com data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
CSP-idservice:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' https://physalia.evolution.uni-bonn.de data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org https://physalia.evolution.uni-bonn.de;
CSP-librechat:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' blob: data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
# Fixed: removed invalid 'unsafe-inline' and 'unsafe-eval' from -elem directives
CSP-awstats:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' https://chart.googleapis.com;
connect-src 'self' https://www.gstatic.com/charts/;
font-src 'self';
style-src 'self' 'unsafe-inline';
style-src-elem https://www.gstatic.com/charts/;
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
script-src-elem https://www.google.com/jsapi https://www.gstatic.com/charts/;
CSP-h5p:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors https://www.naturkundemuseum-bw.de;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
CSP-biocase:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' https://unpkg.com/ https://*.tile.osm.org data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline' https://unpkg.com/leaflet@1.3.3/dist/leaflet.css;
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org https://unpkg.com/leaflet@1.3.3/dist/leaflet.js https://code.jquery.com/jquery-1.7.min.js;
CSP-prestashop:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
img-src 'self' data:;
connect-src 'self';
font-src 'self';
style-src 'self' 'unsafe-inline';
style-src-elem https://market.smns-bw.org;
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://market.smns-bw.org;
# Base + broader font-src
CSP-webmin:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
base-uri 'self';
img-src 'self' data:;
font-src 'self' *.smns-bw.org data:;
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
# Fixed: stray "data" token removed, keep single "data:" at end
CSP-collections:
headers:
contentSecurityPolicy: >
default-src 'none';
frame-ancestors 'self' *.smns-bw.org pictures.smns-bw.org;
frame-src *.smns-bw.org;
base-uri 'self';
form-action 'self';
style-src 'self' 'unsafe-inline' *.smns-bw.org https://cloud.ccm19.de;
connect-src 'self' https://cloud.ccm19.de https://matomo.naturkundemuseum-bw.de/;
font-src 'self' *.smns-bw.org;
img-src 'self' *.smns-bw.org https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://cloud.ccm19.de data:;
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://pydeepzoom.smns-bw.org https://matomo.naturkundemuseum-bw.de https://cloud.ccm19.de;
# Fixed: removed invalid 'unsafe-inline' from connect-src and from script/style -elem
CSP-ent:
headers:
contentSecurityPolicy: >
default-src 'none';
base-uri 'self';
form-action 'self';
style-src-elem https://ent.smns-bw.org/static/css/;
connect-src 'self' https://matomo.naturkundemuseum-bw.de;
script-src-elem https://matomo.naturkundemuseum-bw.de https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js https://openseadragon.github.io/openseadragon/openseadragon.js;
img-src 'self' https://openseadragon.github.io/openseadragon/images/;
CSP-digiphyll:
headers:
contentSecurityPolicy: >
default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
frame-ancestors 'self' *.smns-bw.org;
frame-src *.smns-bw.org https://144.41.33.40/;
base-uri 'self';
form-action 'self';
img-src 'self' https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://unpkg.com/ data:;
connect-src 'self';
font-src 'self' data:;
style-src 'self' 'unsafe-inline' data:;
script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org https://mathjax.rstudio.com/latest/ http://144.41.33.40/ data:;
# Fixed: removed invalid 'unsafe-eval' from style-src
CSP-geometroidea:
headers:
contentSecurityPolicy: >
default-src 'self';
style-src 'self' 'unsafe-inline' *.smns-bw.org https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css https://fonts.googleapis.com/css2;
font-src 'self' https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/webfonts/ https://fonts.gstatic.com/s/nunito/v25/;
img-src 'self' data:;
my-traefik-plugin-cookie-path-prefix:
plugin:
traefik-plugin-cookie-path-prefix:
prefix: smns
webportal-errors:
errors:
status:
service: error-pages-webportal@docker
- "404-503"
query: "/{status}.html"
# another-service-errors:
# errors:
# status:
# - "404-503"
# service: error-pages-another-service
# query: "/error-pages-another-service/{status}.html"
\\
tls:
options:
tls-opts:
minVersion: VersionTLS12
cipherSuites:
- "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
- "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
- "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
- "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
- "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305"
- "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305"
curvePreferences:
- CurveP521
- CurveP384
# sniStrict: true
===== Traefik security headers and CSP (DRY config) =====
Last updated: 25.09.2025
==== High‑level design ====
* One baseline for security headers: security-headers-base
* Small header variants you can compose: -noindex, -allowframes, -hsts-subdomains, cache-public
* Separate CORS middlewares: cors-default, cors-collections
* One base CSP: CSP-base (+ per‑service CSP middlewares that only add what’s different)
* Chains compose the above into per‑service “one-liners” you reference from docker-compose labels.
==== Security headers ====
=== Baseline (security-headers-base) ===
Sets the shared hardening and sane defaults. Highlights:
* addVaryHeader: true
* contentTypeNosniff: true
* browserXssFilter: true
* referrerPolicy: same-origin
* permissionsPolicy: fullscreen=(self "https://smns-bw.org"), geolocation=*, midi=(), camera=(), usb=(), magnetometer=(), accelerometer=(), vr=(), speaker=(), ambient-light-sensor=(), gyroscope=(), microphone=(), payment=()
* HSTS: forceSTSHeader: true, stsSeconds: 31536000, stsPreload: false
* X-Frame-Options: frameDeny: true by default (prefer CSP frame-ancestors for allowlists)
* TLS/proxy awareness: sslRedirect: true; sslProxyHeaders.X-Forwarded-Proto: https
* Scrub response headers (customResponseHeaders): Server: "", X-Powered-By: "", X-Robots-Tag: "index, follow"
* Upstream request header: customRequestHeaders.X-Forwarded-Proto: https
Rationale:
* Default deny framing (clickjacking defense); enable per service via -allowframes and control who via CSP.
* Keep includeSubdomains off by default to avoid accidental HSTS lock-in; use the variant when needed.
==== Variants you can compose ====
* security-headers-noindex
* Same as base, but X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
* security-headers-allowframes
* Same as base, but frameDeny: false (use CSP frame-ancestors to specify who can frame)
* security-headers-hsts-subdomains
* Same as base, but stsIncludeSubdomains: true, stsSeconds: 63072000
* cache-public
* Adds Cache-Control: public to customResponseHeaders
==== CORS middlewares ====
* cors-default
* accessControlAllowMethods: GET, OPTIONS, PUT
* accessControlAllowHeaders: "*"
* accessControlMaxAge: 100
* addVaryHeader: true
* cors-collections
* Inherits cors-default + accessControlAllowOriginList:
* https://pydeepzoom.smns-bw.org
* https://pictures.smns-bw.org
==== Content Security Policy (CSP) ====
=== Base CSP (CSP-base) ===
Applies to most services unchanged.
CSP-base: headers: contentSecurityPolicy: &CSP_BASE > default-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org; frame-ancestors 'self' *.smns-bw.org; frame-src *.smns-bw.org; base-uri 'self'; form-action 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval' *.smns-bw.org;
Notes:
* Only one CSP header is honored. Don’t stack multiple CSP middlewares in a single chain; “last header wins.”
* Use Report-Only during testing by duplicating to contentSecurityPolicyReportOnly.
==== How to add a new service ====
* If defaults are enough:
* Use secure-global chain.
* If it needs framing:
* Use security-headers-allowframes in the chain and set CSP frame-ancestors to the exact allowlist.
* If it needs special CORS:
* Add or create a cors- middleware and include it in the chain.
* If it needs special CSP:
* Copy CSP-base to CSP- and append only the minimal deltas (new hosts/tokens).
* Wire it in docker-compose:
* traefik.http.routers..middlewares=secure-@file
==== Verification (quick commands) ====
* Check headers:
* curl -sI https:// | egrep -i "strict-transport-security|x-frame-options|x-content-type-options|referrer-policy|permissions-policy|x-robots-tag"
* Check CSP is present (and only once):
* curl -sI https:// | grep -i "content-security-policy"
* Check CORS (preflight example):
* curl -sI -X OPTIONS https:/// -H "Origin: https://example.com" -H "Access-Control-Request-Method: GET"
* Check robots indexing:
* curl -sI https:/// | grep -i x-robots-tag
==== Operational notes ====
* Prefer CSP frame-ancestors over X-Frame-Options for precise embedding control.
* Consider a Report-Only CSP during rollouts (duplicate middleware with contentSecurityPolicyReportOnly).
* HSTS includeSubdomains is opt-in via variant to avoid unintentional hard lock-in.
* If a page doesn’t render: check the browser console for CSP violations first; add only the specific host/type needed.
==== Appendix: Anchor usage ====
We used YAML anchors to stay DRY:
* &SEC_BASE / *SEC_BASE for baseline headers.
* &CSP_BASE / *CSP_BASE for base CSP string.
This keeps the source config compact while allowing targeted overrides.
===== Traefik v3 Healthcheck (Docker) =====
=== Overview ===
This page describes how to set up a robust Docker healthcheck for Traefik v3.x.
It covers recent Traefik changes, the “gotchas” with TLS, and provides full configuration (compose and YAML) for reliable service monitoring.
=== Why Do I Need a Special Healthcheck for Traefik 3? ===
* As of Traefik 3, the /ping endpoint (Traefik's native health endpoint) can only be bound to a non-TLS (HTTP/plaintext) entrypoint.
* Any attempt to bind /ping to a TLS entrypoint (e.g., :443) causes it to be unavailable and will not log an error!
* Many guides and blog posts referencing Traefik 2.x are now out of date.
* Docker healthchecks are only updated when containers are recreated.
=== Step-by-Step Setup ===
== 1. Add a dedicated HTTP (non-TLS) entrypoint for health ==
Add this to your ''traefik.yml'':
entryPoints:
healthcheck:
address: ":8082"
ping:
entryPoint: healthcheck
* Use any unused high port (8082 is common and outside process-bound port ranges).
* Do not enable TLS or configure HTTP redirection for this entrypoint.
== 2. Update ''docker-compose.yml'' healthcheck section ==
healthcheck:
test: [ "CMD", "wget", "--spider", "http://localhost:8082/ping" ]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
== 3. Recreate the container (!important) ==
After editing the healthcheck, you must remove and recreate the container to apply the updated check.
docker compose down docker compose up -d
or for just the traefik service:
docker compose rm traefik docker compose up -d traefik
== 4. Confirm it's working ==
* Check status with:
docker inspect traefik | grep Health -A 10
* Look for:
* ''"Status": "healthy"''
* The test pointing at ''[[http://localhost:8082/ping|http://localhost:8082/ping]]''
* You can also exec into the container:
wget --spider http://localhost:8082/ping
* and expect “remote file exists” or HTTP 200.
=== Troubleshooting ===
* If you see '''404 Not Found''' or status stays ''unhealthy'', check:
* The entryPoint in ping and traefik.yml matches (healthcheck)
* Logs for ping endpoint registration (grep -i ping )
* Healthcheck in the running container is updated (see ''docker inspect'')
* If the healthcheck is still using the old endpoint (e.g., port 443), the container must be removed and recreated.
=== FAQ ===
* Q: Why not use /ping on :443?
* A: Traefik 3.x forbids it; /ping only works on a non-TLS (HTTP) entrypoint.
* Q: Do I need to expose port 8082 externally?
* A: No; healthchecks run inside the container.
* Q: Can I combine ping and redirect on the same entrypoint?
* A: No; keep your healthcheck entrypoint plain.
=== References ===
* [[https://doc.traefik.io/traefik/operations/ping/|Official Traefik Ping v3 docs]]
* [[https://doc.traefik.io/traefik/reference/dynamic-configuration/docker/|Traefik & Docker Reference]]
* [[https://docs.docker.com/reference/compose-file/services/#healthcheck|Docker Compose healthcheck docs]]
Authored for SMNS IT by Chattie and AI Programmer