Skip to content
Infrastructure

Fixing iframe embedding in self-hosted dashboards

By Victor Da Luz
glance homepage traefik iframe csp content-security-policy homelab debugging

I run Glance as my homelab dashboard. It’s flexible enough to pull in almost anything - calendar events, RSS feeds, service health, and iframe widgets for embedding other web UIs. I wanted to embed my Homepage dashboard as an iframe in Glance, alongside my other widgets.

The iframe showed nothing. Just blank space.

The problem

Opening browser developer tools showed the issue clearly. The console had an error along the lines of:

Refused to display 'https://homepage.example.net' in a frame because it set 'X-Frame-Options' to 'SAMEORIGIN'.

Two HTTP headers control whether a browser will allow a page to be embedded in an iframe:

X-Frame-Options is the older mechanism. SAMEORIGIN means only pages on the same origin can embed the resource. DENY blocks all embedding. There’s also ALLOWFROM, but it has inconsistent browser support and is effectively deprecated.

Content-Security-Policy: frame-ancestors is the modern approach. It’s more expressive - you can list multiple specific origins. When both headers are present in a response, browsers honor the CSP frame-ancestors directive and ignore X-Frame-Options.

Running a quick header check confirmed what the browser was complaining about:

curl -I https://homepage.example.net

The response included x-frame-options: SAMEORIGIN. No content-security-policy header at all. Because Glance is on glance.example.net - a different origin - the browser refused to embed it.

The configuration fix

Traefik handles my reverse proxy. Dynamic configs live on the Traefik host as YAML files it watches for changes. Homepage had a dynamic config file at /etc/traefik/dynamic/homepage.yml on the host.

The file had a customResponseHeaders section, but the CSP header was either missing or configured incorrectly. The correct approach is to use Traefik’s dedicated contentSecurityPolicy field rather than trying to set the header manually in customResponseHeaders:

http:
  middlewares:
    homepage-headers:
      headers:
        contentSecurityPolicy: "frame-ancestors 'self' https://glance.example.net;"

This tells the browser that homepage.example.net can be embedded from its own origin ('self') or from glance.example.net. Using the dedicated field lets Traefik handle the header correctly rather than manually injecting it alongside other response headers.

Updated the file, saved it, moved on.

The iframe was still blank

The next day, I noticed the embed still wasn’t working. Checked the headers again:

curl -I https://homepage.example.net

Still showing x-frame-options: SAMEORIGIN. No CSP header.

The config file was right. Traefik wasn’t serving it.

The dynamic configuration wasn’t applied because I’d edited the file but never redeployed it. Traefik does pick up some config changes dynamically, but deploying through Ansible ensures the configuration is consistent across restarts and the service is actually running the version in the repo:

ansible-playbook iac/ansible/playbooks/services/traefik.yml
systemctl restart traefik.service

After the restart, the headers looked right:

content-security-policy: frame-ancestors 'self' https://glance.example.net;

The Glance iframe widget loaded Homepage correctly.

Lessons learned

  • X-Frame-Options: SAMEORIGIN will block cross-origin iframe embedding every time. If a service is behind a reverse proxy, you control this at the proxy layer. The fix goes in the Traefik middleware, not in the service itself.
  • Use Traefik’s dedicated contentSecurityPolicy field. Don’t put the CSP header in customResponseHeaders. Traefik has a specific field for it and handles the header correctly through that path.
  • CSP frame-ancestors is the right long-term choice. It overrides X-Frame-Options in modern browsers and lets you whitelist specific origins rather than using an all-or-nothing setting.
  • A correct config file that isn’t deployed does nothing. When curl -I still shows the wrong headers after an edit, suspect the running service is still on the old config. Re-run the Ansible playbook and restart the service.
  • Always verify with curl -I. Browser developer tools surface the browser’s interpretation of the error, but curl shows you exactly what headers the server is sending. It’s the faster diagnostic when debugging header issues.

Related reading

Ready to Transform Your Career?

Let's work together to unlock your potential and achieve your professional goals.