====== 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