Screenshot API for Internal & Intranet Websites

Capture screenshots of internal dashboards, staging environments, and admin panels behind firewalls using tunnels, proxies, or the HTML parameter.

Updated

Screenshot API for internal websites solves a problem that sounds simple until you try it: your Grafana dashboard, admin panel, or staging environment lives behind a firewall, and a cloud API can't reach it. The API sends a request, the firewall drops it, and you get nothing back. That's not a bug in the API. It's how firewalls work.

But teams still need automated screenshots of internal tools, even those behind a firewall. Weekly reports with dashboard snapshots, compliance evidence from admin panels, visual regression checks on staging after each deploy. Doing this manually works for one or two pages. It falls apart when you have 15 dashboards and a Monday morning deadline. This page covers the approaches that actually work, with code you can run today.

Why a cloud API can't just capture internal URLs

Worth explaining briefly, because this trips people up. ScreenshotRun runs headless Chrome on cloud servers. When you pass a URL like https://grafana.internal.company.com:3000, the cloud server tries to resolve that hostname. It can't, because that DNS record only exists inside your corporate network. Same thing with private IPs like 192.168.x.x or 10.0.x.x, and most screenshot APIs block those by design to prevent SSRF attacks.

So the question isn't "does the API support internal URLs?" (it doesn't, and shouldn't). The question is: how do you temporarily make an internal page reachable for the few seconds it takes to capture a screenshot?

The tunnel approach (easiest for most teams)

A tunnel creates a temporary public URL that points to your internal service. The internal server makes an outbound connection to the tunnel provider. No inbound firewall rules needed. The tunnel stays up for as long as you want, and the URL is what you pass to the screenshot API.

Three tools that do this well:

Tool Command Free tier
Cloudflare Tunnel cloudflared tunnel --url http://localhost:3000 Unlimited, no account needed for Quick Tunnels
ngrok ngrok http 3000 3 endpoints, 20K requests/month
Tailscale Funnel tailscale funnel 3000 Free for personal use

Cloudflare Quick Tunnels are the fastest to try. You don't even need an account. Run the command, get a *.trycloudflare.com URL, pass it to the API. The whole setup takes about 30 seconds.

# Start a quick tunnel to your internal Grafana
cloudflared tunnel --url http://localhost:3000

# Output: https://random-words.trycloudflare.com

# Capture the dashboard
curl "https://api.screenshotrun.com/v1/screenshots/capture" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -G \
  --data-urlencode "url=https://random-words.trycloudflare.com/d/my-dashboard" \
  --data-urlencode "full_page=true" \
  --data-urlencode "delay=3000" \
  --data-urlencode "format=png" \
  -o grafana-dashboard.png

The 3-second delay matters for dashboards. Grafana, Metabase, and Kibana all render charts client-side with JavaScript. The HTML loads fast, but the charts need a moment to pull data and draw. Without the delay, you get empty chart panels. For especially heavy dashboards (20+ panels, large datasets), bump it to 5000ms or use wait_for_selector targeting a specific chart element.

For production use, set up a named Cloudflare Tunnel with a stable subdomain instead of quick tunnels. The random URL changes every time you restart the tunnel, which breaks any automation scripts that reference it.

Route through your own proxy

What if you already have a proxy server or reverse proxy that sits on your corporate network and is also reachable from the internet (common in enterprise setups), the proxy parameter does exactly what you'd expect. The API routes the browser request through your proxy, which can resolve internal hostnames and reach private IPs.

curl "https://api.screenshotrun.com/v1/screenshots/capture" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -G \
  --data-urlencode "url=http://grafana.internal:3000/d/my-dashboard" \
  --data-urlencode "proxy=http://your-corporate-proxy.example.com:8080" \
  --data-urlencode "full_page=true" \
  --data-urlencode "delay=3000" \
  --data-urlencode "format=png" \
  -o dashboard.png

The proxy needs to be accessible from ScreenshotRun's servers and able to forward requests to your internal network. A simple Squid or nginx reverse proxy on a bastion host works. If your proxy requires authentication, include credentials in the URL: http://user:[email protected]:8080.

One thing to watch: make sure your proxy only allows requests from ScreenshotRun's IP ranges and only forwards to your approved internal hosts. An open proxy that can reach your entire internal network is a security risk you don't want.

Send HTML instead of a URL

What if you could skip the network problem entirely? If your internal tool renders HTML that you can grab locally, send that HTML to the API's html parameter and let it render the page without ever needing to reach your network.

import requests

# Fetch the internal page from within your network
internal_resp = requests.get(
    "http://grafana.internal:3000/d/my-dashboard",
    cookies={"grafana_session": "your-session-token"},
    timeout=10,
)

# Send the HTML to ScreenshotRun for rendering
screenshot = requests.get(
    "https://api.screenshotrun.com/v1/screenshots/capture",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "html": internal_resp.text,
        "width": 1920,
        "height": 1080,
        "format": "png",
    },
    timeout=30,
)

with open("dashboard.png", "wb") as f:
    f.write(screenshot.content)

This works perfectly for static HTML pages, server-rendered dashboards, and report pages. It won't work as well for pages that load data via JavaScript API calls after the initial HTML loads, because those API calls would point back to your internal endpoints that the cloud renderer can't reach. Grafana and Kibana fall into this category since they fetch data client-side. For those, the tunnel or proxy approach is better.

The html parameter also works well for internal tools that generate report HTML on the server side (like custom admin panels that render a complete HTML table). You're essentially using ScreenshotRun as an HTML-to-image converter, and the network access question disappears. More on this at the HTML to image feature page.

Automating screenshot API for internal websites on a schedule

The real value isn't one-off captures. It's a script that runs every Monday at 8 AM, opens a tunnel, captures 10 dashboards, emails the screenshots to stakeholders, and closes the tunnel. A Node.js version that does exactly that:

const { exec } = require('child_process');
const axios = require('axios');
const fs = require('fs');

const DASHBOARDS = [
  { name: 'revenue', path: '/d/revenue-overview' },
  { name: 'infra', path: '/d/infrastructure-health' },
  { name: 'support', path: '/d/support-metrics' },
];

async function captureInternalDashboards() {
  // Start Cloudflare tunnel (returns public URL)
  const tunnel = exec('cloudflared tunnel --url http://localhost:3000');
  let publicUrl;

  // Parse tunnel URL from cloudflared output
  await new Promise((resolve) => {
    tunnel.stderr.on('data', (data) => {
      const match = data.toString().match(/https:\/\/[^\s]+\.trycloudflare\.com/);
      if (match) { publicUrl = match[0]; resolve(); }
    });
  });

  for (const dash of DASHBOARDS) {
    const resp = await axios.get(
      'https://api.screenshotrun.com/v1/screenshots/capture',
      {
        headers: { Authorization: `Bearer ${process.env.SR_KEY}` },
        params: {
          url: `${publicUrl}${dash.path}`,
          full_page: true,
          width: 1920,
          delay: 4000,
          format: 'png',
          block_cookies: true,
        },
        responseType: 'arraybuffer',
        timeout: 30000,
      }
    );
    fs.writeFileSync(`reports/${dash.name}.png`, resp.data);
  }

  tunnel.kill();
}

captureInternalDashboards();

The tunnel lives for maybe 30 seconds total. It opens, the screenshots happen, it closes. Your internal Grafana is never persistently exposed to the internet.

For teams that want this without writing code, a Zapier or Make.com workflow can trigger the screenshot API on a schedule, though you'd still need the tunnel running on your end. The tunnel part is the one piece that has to live on your internal network.

CI/CD pipeline integration

QA teams use this pattern to capture staging environments after each deploy. Staging sits behind a VPN, so external services can't reach it directly. But your CI/CD runner (GitHub Actions, GitLab CI, Jenkins) is already inside the network or has VPN access.

The flow: deploy finishes, CI job starts a tunnel, captures screenshots of key pages, stores them as build artifacts, closes the tunnel. If you're doing visual regression testing, these screenshots become your post-deploy baseline.

# .github/workflows/post-deploy-screenshots.yml
- name: Capture staging screenshots
  run: |
    cloudflared tunnel --url http://staging.internal:8080 &
    sleep 5  # wait for tunnel
    TUNNEL_URL=$(curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url')

    for page in /dashboard /settings /billing; do
      curl "https://api.screenshotrun.com/v1/screenshots/capture" \
        -H "Authorization: Bearer ${{ secrets.SR_KEY }}" \
        -G \
        --data-urlencode "url=${TUNNEL_URL}${page}" \
        --data-urlencode "full_page=true" \
        --data-urlencode "delay=3000" \
        --data-urlencode "format=png" \
        -o "screenshots$(echo $page | tr '/' '-').png"
    done

    kill %1  # close tunnel

The screenshots end up as GitHub Actions artifacts that the QA team can browse without VPN access. Same visual result, no credential sharing needed. How much time does that save compared to everyone logging in separately to check staging? For a team of five, probably an hour per deploy cycle.

Security considerations

Exposing internal services, even temporarily, needs some care. A few things to get right:

  • Use named Cloudflare Tunnels with Cloudflare Access (SSO/OAuth layer in front of the tunnel). Quick tunnels are fine for testing but don't have access controls.
  • If using ngrok, set up IP restrictions to allow only ScreenshotRun's server IPs. ngrok's paid plans support IP allowlisting.
  • Time-box the tunnel. Start it, capture the screenshots, kill it immediately. A tunnel that stays open 24/7 is unnecessary exposure.
  • The proxy approach should whitelist both source IPs (ScreenshotRun's servers) and destination hosts (only your approved internal URLs). Don't create an open proxy.
  • For compliance-sensitive dashboards, add certified=true to your API call. The response includes a SHA-256 hash and timestamp, creating a verifiable record. See certified screenshots for details.

The tunnel approach is actually more secure than most alternatives. Compared to opening inbound firewall ports, or running a publicly accessible proxy 24/7, a tunnel that exists for 30 seconds during a cron job has a much smaller attack surface.

Grafana users: a note on the image renderer

If you're here because Grafana's built-in image renderer plugin stopped working, you're not alone. Grafana removed the plugin in version 13 (April 2026) and now offers the image renderer only as a standalone service that you deploy separately. For Grafana Enterprise users, scheduled reporting is built in. For OSS users, that feature doesn't exist.

The tunnel + API approach replaces what the old plugin did. You get more control over output format, viewport size, and timing, and it works with any internal tool, not just Grafana. The same script that captures your Grafana dashboards can also grab your Metabase reports, Kibana visualizations, or custom admin panels.

When this doesn't make sense

Being honest about the limits:

  • Air-gapped networks with zero internet access can't use tunnels or cloud APIs at all. The hybrid approach (local Puppeteer capture + API for processing) works, but at that point you might be better off with a fully self-hosted solution.
  • If your security policy prohibits any temporary exposure of internal services, even through authenticated tunnels, this approach won't pass review. Some regulated environments (government, defense) have strict rules about this.
  • Pages that require complex multi-step authentication (SAML SSO with MFA) are harder to capture. The cookies and headers parameters handle simple auth, but SAML flows need a pre-authenticated session. The screenshot behind login page covers authentication patterns in detail.

Why ScreenshotRun for internal screenshots

Any screenshot API can capture a URL. The difference is what happens when that URL needs extra handling. The proxy parameter routes through your network. The html parameter skips the network entirely. delay and wait_for_selector handle JavaScript-heavy dashboards that need time to render their charts. And certified=true adds the timestamp and hash that compliance teams ask for.

For teams already using tunnels for development (and most engineering teams are), adding a screenshot API for internal websites is a few lines of code on top of infrastructure that already exists. No new dependencies, no self-hosted browser fleet to maintain. Check the pricing page to see if the volume fits your reporting cadence.

FAQ#

Not directly. Cloud APIs run headless browsers on external servers that cannot resolve private DNS names or reach private IP ranges like 192.168.x.x or 10.0.x.x. You need a tunnel, proxy, or the HTML parameter to bridge the gap.

Cloudflare Quick Tunnels are the fastest to set up — no account needed, one command, and you get a public URL in seconds. For production automation, named Cloudflare Tunnels with Cloudflare Access give you a stable subdomain plus SSO protection. ngrok and Tailscale Funnel are solid alternatives.

Time-boxed tunnels that open for 30 seconds during a cron job have a very small attack surface. For extra safety, use named tunnels with access controls (Cloudflare Access or ngrok IP allowlisting) and kill the tunnel immediately after capturing screenshots.

Yes. The html parameter accepts raw HTML and renders it in the cloud without any network access to your infrastructure. This works well for server-rendered pages and report HTML. It does not work for pages that fetch data via client-side JavaScript after the initial load.

Start a Cloudflare tunnel pointing to your Grafana instance, pass the tunnel URL to the API with a 3-5 second delay so charts have time to render, and capture with full_page enabled. The same approach works for Metabase, Kibana, and any other internal dashboard tool.

Yes. Include credentials directly in the proxy URL like http://user:[email protected]:8080. Make sure the proxy only allows requests from ScreenshotRun server IPs and only forwards to your approved internal hosts.

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call