Containers
Content Security Policy with Docker
A container rarely faces the browser directly. A reverse proxy sits in front of it (nginx, Traefik, a Kubernetes ingress, a cloud load balancer), so the question is which of the two layers owns the header. Get it wrong and the browser gets two policies, or the proxy overwrites the one your app built.
A complete report-only config
An nginx proxy in front of an application container, with the proxy owning the policy. It strips any policy the app sends before adding its own, so exactly one reaches the browser. Replace your-app:latest with your image.
If your app generates nonces, reverse the ownership. The app sets the policy, because only it knows which scripts it rendered, and the proxy sets nothing for that header.
# One proxy in front of your application container. The proxy
# owns the policy; nginx.conf explains why it also strips the app's copy.
services:
app:
image: your-app:latest
expose:
- "3000"
proxy:
image: nginx:1.30-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- appserver {
listen 80;
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# One policy, set here. Hide whatever the app sends first: add_header
# does not replace an upstream header, it adds a second one, and the
# browser then applies both policies.
proxy_hide_header Content-Security-Policy-Report-Only;
proxy_hide_header Reporting-Endpoints;
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r; report-to csp-endpoint" always;
add_header Reporting-Endpoints 'csp-endpoint="https://your-ingest-code.ingest.us.headerhawk.com/r"' always;
}
}How this was checked: run with Docker Compose, nginx 1.30.5 in front of a Node.js container that sends its own report-only policy. The browser receives exactly one policy and one Reporting-Endpoints header, the proxy's, and the browser applies the policy and posts a report.
Gotcha
When the app and the proxy both set it, the browser gets two policies
nginx's add_header does not replace a header the upstream sent; it adds another. An app that sets its own policy, behind a proxy that also sets one, sends two Content-Security-Policy-Report-Only headers.
location / {
proxy_pass http://app:3000;
# The app sends a policy too. The browser now gets two.
add_header Content-Security-Policy-Report-Only "default-src 'self'" always;
}The browser applies both. A resource has to satisfy each policy, and each reports on its own, so reports name directives from a policy you may not know exists. proxy_hide_header before add_header, as in the config, leaves one.
Traefik does the opposite. Its headers middleware replaces the header the app sent (on Traefik 3.7.13). You get one policy, the proxy's, and if the app was generating nonces, every nonced script is now a violation.
# Replaces the policy the app sent, nonce and all.
http:
middlewares:
csp:
headers:
customResponseHeaders:
Content-Security-Policy-Report-Only: "default-src 'self'"Neither default is wrong, but they differ, so decide which layer owns the header before the proxy decides for you.
Gotcha
Ingress NGINX snippets are off by default, and the controller is retired
Kubernetes guides to CSP usually reach for the nginx.ingress.kubernetes.io/configuration-snippet annotation to inject an add_header line. The Ingress NGINX controller disables snippet annotations by default, because they let anyone who can create an Ingress read the cluster's secrets (CVE-2021-25742), so those guides fail on a default install.
The controller was also retired in March 2026, with no further releases, bug fixes or security patches.
Set the header in the application container, or in whatever replaced the controller. With the Gateway API, which the Kubernetes project points Ingress NGINX users towards, that is a ResponseHeaderModifier filter. It is an Extended feature, so check that your implementation supports it. Its set replaces a header the app sent, as Traefik does.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: app
spec:
parentRefs:
- name: gateway
rules:
- backendRefs:
- name: app
port: 3000
filters:
- type: ResponseHeaderModifier
responseHeaderModifier:
set:
- name: Content-Security-Policy-Report-Only
value: "default-src 'self'; report-uri https://your-ingest-code.ingest.us.headerhawk.com/r"Now see what it's blocking
A report-only policy blocks nothing. When a browser loads a page and finds something the policy would have refused, it posts a report to the endpoint in report-uri and report-to. Until something listens there, those reports go nowhere.
HeaderHawk is one place to send them. It accepts both report formats on the same URL, groups them by directive and blocked host so a thousand reports about one script read as one issue, and keeps extension and bot reports visible but out of alerts. Replace your-ingest-code in the config above with the code from your site's setup panel, or paste the URL the panel shows.
The Docker integration guide covers that step: where the endpoint goes on Docker, and how to confirm the headers are being served.
A policy is the first of several controls browsers enforce on the code your pages load, including Integrity-Policy, Permissions-Policy, COOP and COEP, and they all report to the same endpoint. What client-side security is covers the rest of that layer.
Start reading the reports
Free for 1 site. Leave the policy in report-only until the reports go quiet, then enforce it.