2026.07 / STATIC SECURITY HEADERS 026
Security Headers for a Static Nginx Site Behind Cloudflare and Coolify
A static HTML site has a pleasantly small attack surface: no login form, no database, no server-side template engine, and no admin panel in the public path. That does not mean the HTTP response should be bare. A few headers can stop browser sniffing, reduce information leakage, make accidental mixed-content mistakes obvious, and give you a tighter default if a third-party script is ever added later.
This is the header baseline I would use for a small static site served by nginx, deployed through Coolify, and routed through Cloudflare. It is not a trick to get a perfect scanner score. It is a practical, rollback-friendly way to make the browser do less surprising things.
Goal: add useful browser security headers at the nginx layer, keep Cloudflare as the edge, avoid breaking analytics/RSS/static assets, and verify the live response before calling the deploy finished.
Where the headers should live
For this setup, I prefer to put the core headers in the nginx config that ships inside the Docker image. Cloudflare can also set or modify headers at the edge, but the app container should be understandable on its own. If I run the image locally, curl it on the VPS, or move the domain later, the same policy comes with the site.
Cloudflare is still useful for TLS, caching, WAF features, and redirect rules. The split is simple:
- nginx: headers that describe how this site should behave in a browser.
- Cloudflare: edge TLS settings, cache behaviour, bot/WAF controls, and domain-level redirects.
- Coolify: build, deploy, route, healthcheck, and rollback orchestration.
That separation keeps the deployment auditable. If a header breaks the page, I can revert a Git commit and redeploy through the same path instead of hunting through dashboard state.
The safe static-site baseline
A useful first pass for a plain HTML/CSS site looks like this:
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://analytics.example.com; connect-src 'self' https://analytics.example.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
Replace analytics.example.com with the analytics host you actually use, or remove those analytics entries if the site has no external script. Do not copy a Content Security Policy that allows every CDN on the internet. The whole point of CSP is to name the few places the browser is allowed to load code and data from.
The always flag matters in nginx because it keeps headers on error responses as well as successful pages. That makes 404s and redirects behave consistently instead of only protecting the happy path.
What each header is doing
X-Content-Type-Options: nosniff tells the browser not to guess a different MIME type from the bytes it received. MDN documents this as a defence against MIME sniffing. For a static site, it pairs well with correct nginx MIME types: CSS should be text/css, JavaScript should be a JavaScript MIME type, XML feeds should be XML, and HTML should be HTML.
Referrer-Policy: strict-origin-when-cross-origin keeps full URLs available for same-origin navigation, but only sends the origin when a visitor leaves for another HTTPS site. That is a good privacy default for a blog: internal clicks remain debuggable, while outbound links do not leak the exact article path plus query string to every destination.
Permissions-Policy disables browser features the site does not need. A static blog does not need camera, microphone, geolocation, or payment APIs. Saying that explicitly reduces the blast radius if a script is ever compromised or embedded by mistake.
X-Frame-Options: DENY is an older but still widely understood way to prevent the page being framed. CSP's frame-ancestors 'none' is the modern equivalent. I usually set both for broad compatibility unless the page intentionally needs to be embedded elsewhere.
Content-Security-Policy is the only header here that is likely to break something during rollout. Start with the real asset list: same-origin CSS and JS, the analytics script if used, image sources, and no objects. If inline styles are needed because the current HTML uses them, keep 'unsafe-inline' temporarily for style-src, then remove it after moving those styles into the stylesheet. Avoid 'unsafe-inline' for scripts unless you have a specific reason and a plan to replace it with hashes or nonces.
Strict-Transport-Security tells browsers to use HTTPS for future visits. Only set it after HTTPS is stable for the domain and any subdomains covered by includeSubDomains. HSTS is intentionally sticky: a bad setting can strand a subdomain that is not ready for HTTPS. For a new domain, I would first deploy a shorter max-age, verify for a few days, and only then move to a one-year policy with preload intent.
A cautious nginx server block
For a tiny static container, the full nginx file can stay boring:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://analytics.example.com; connect-src 'self' https://analytics.example.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always;
location / {
try_files $uri $uri/ =404;
}
}
I deliberately left HSTS out of that container snippet because a Coolify/nginx container often only sees plain HTTP behind the proxy. HSTS is about the public HTTPS origin, so set it only when you are sure the browser-facing domain is HTTPS and the subdomain story is clean. If Cloudflare is terminating TLS at the edge, you can set HSTS at Cloudflare or at the reverse proxy that actually serves the public HTTPS response.
Test locally before pushing
The basic local test is not a browser. It is a header diff:
docker build -t static-site-header-test .
docker run --rm -p 8080:80 static-site-header-test
curl -I http://127.0.0.1:8080/
curl -I http://127.0.0.1:8080/feed.xml
curl -I http://127.0.0.1:8080/posts/example-post.html
Check three things before deployment:
- The expected headers are present on HTML, RSS, sitemap, and 404 responses.
- The MIME types are still correct: CSS is not served as HTML, RSS is not served as plain broken text, and JavaScript is not blocked by CSP.
- The browser console is clean on the homepage and a representative post.
If the console reports CSP violations, do not immediately loosen the policy to *. Read the blocked URL, decide whether that source is genuinely needed, and add the narrowest directive that allows it.
Verify the live site after Coolify deploys
After the Git push and Coolify deployment, test the public URL. The edge can change what you saw locally, especially when Cloudflare is involved:
curl -sSI https://example.com/ | egrep -i 'content-security-policy|strict-transport-security|x-content-type-options|referrer-policy|permissions-policy|x-frame-options|content-type'
curl -sSI https://example.com/feed.xml | egrep -i 'content-type|x-content-type-options|content-security-policy'
curl -sS https://example.com/sitemap.xml | head
That catches the common mistakes: Cloudflare stripping or overriding a header, nginx applying headers only to 200 responses, or a too-strict CSP blocking an analytics script that was not loaded in the local container test.
Roll out CSP in report-only mode if the site is complex
For this small blog style of site, a direct CSP can be fine because the asset graph is tiny. For a larger static site with embedded maps, iframes, third-party forms, or old inline scripts, start with Content-Security-Policy-Report-Only. The browser will report violations without enforcing the block, which gives you a chance to inventory what the page actually loads.
Do not leave report-only mode forever. Treat it as a measurement stage: collect violations, remove accidental dependencies, write the real policy, then enforce it.
The rollback plan
Security headers should never be a one-way door. A practical rollback is:
- Keep header changes in their own Git commit.
- Deploy during a window when you can verify the site immediately.
- If the page breaks, revert that commit and redeploy instead of stacking emergency dashboard edits.
- If HSTS was changed, remember that browsers may cache the old value; that is why the first rollout should use a short
max-age.
The best security work on a small static site is boring and reversible. Add the headers, prove the browser still gets the files it needs, keep the policy as narrow as the site allows, and make the deployment path repeatable through Git and Coolify.