all posts

Build a Headless Browser Screenshot Service on MicroVMs

Ajay Kumar··9 min read

A screenshot service is one of those features that sounds like a weekend project and is actually a loaded gun. Someone submits a URL, you point a headless Chromium at it, capture a PNG or render a PDF or scrape the OpenGraph image, and hand it back. Easy. Except the URL is untrusted, the page it loads is untrusted, and the JavaScript that page runs is untrusted — and you just asked a full browser engine to fetch and execute all of it on your infrastructure. A headless browser is a loaded gun that renders JavaScript: it will happily follow redirects into your private network, hammer a renderer bug you've never heard of, or allocate memory until the host tips over. This post is about putting that browser inside a disposable Firecracker microVM, one per render job (or per tenant), with egress locked down, torn down the moment the shot is captured.

I'm Ajay — I built PandaStack, a Firecracker microVM platform for exactly this create-render-destroy pattern. The service around the browser (your queue, your API, your storage) can keep running wherever it runs. What has to move into a disposable boundary is the browser itself: the process that fetches an attacker-chosen URL and executes whatever comes back.

The threat model: three ways a URL ruins your day

Chromium rendering arbitrary web content is one of the largest attack surfaces you can voluntarily sign up for. It's a JIT compiler, an HTML/CSS/JS engine, a font shaper, an image decoder, a network stack, and a PDF renderer, all processing bytes chosen by someone who wants to hurt you. Three distinct failure modes matter, and they compound:

  • SSRF into your own infrastructure — The submitted URL doesn't have to point at the public internet. `http://169.254.169.254/latest/meta-data/` reads your cloud instance metadata (and, on a misconfigured role, temporary credentials). `http://10.0.0.5:6379/` pokes your internal Redis. `http://localhost:8080/admin` hits a sidecar that trusts loopback. The browser is a fetch primitive running inside your network, and a redirect or a `<img src>` or a `fetch()` in page script is enough to reach it.
  • Renderer exploits (RCE) — Browser renderer bugs are a live, well-funded market; a malicious page can carry an exploit that gains code execution inside the Chromium process. Now the attacker isn't rendering your screenshot — they're running their code on your host, next to every other tenant's job.
  • Resource exhaustion — A page doesn't need an exploit to hurt you. An infinite `while(true){}` in script, a 50,000×50,000-pixel canvas, a decompression bomb, or a billion-laughs XML payload can pin a CPU and exhaust memory. In a shared browser pool, one hostile page degrades or kills rendering for everyone.
"We only screenshot URLs our users paste" is not a mitigation — it's the threat. The user is the attacker in this model. Every URL is adversarial input, and blocklisting `169.254.169.254` by string won't save you: DNS rebinding, IPv6 (`[::ffff:169.254.169.254]`), decimal IPs (`http://2852039166/`), and 30x redirects all route around a naive check. The boundary has to be the network the browser runs on, not a regex on the URL.

Why a shared browser pool is the wrong architecture

The natural first design is a pool of warm browser instances — keep N Chromium processes alive, hand each incoming job to a free one, reuse them for throughput. It's fast and it's cheap and it's exactly the architecture that turns one bad URL into a multi-tenant incident. A reused browser process carries state between jobs: cookies, `localStorage`, service workers, cached DNS, an HTTP connection pool, and whatever a previous page planted in the JS heap. Tenant A's malicious page can drop a service worker or poison the cache, and tenant B's screenshot of a totally innocent URL gets served the attacker's payload. One malicious URL poisons every tenant that shares the pool.

And that's the benign case, where nobody achieved code execution. If a renderer exploit lands in a pooled process, the attacker now sits inside a long-lived host process that will handle every other customer's render job for the rest of its life. `--incognito` contexts and `browser.close()` help with the cookie-hygiene problem, but they do nothing about a compromised process or a kernel the browser shares with every neighbor. The isolation boundary has to sit between jobs that don't share a trust domain — and for a service that renders arbitrary user URLs, every job is its own trust domain.

A microVM per render job, destroyed after the shot

The pattern is deliberately boring: when a render request arrives, create a fresh sandbox, launch a headless browser inside it, navigate to the untrusted URL, capture the artifact, copy the bytes out, and destroy the sandbox. Nothing survives to the next job. On PandaStack each sandbox is its own Firecracker microVM — separate guest kernel, filesystem, memory, and per-sandbox network namespace — so a renderer exploit has to escape the hypervisor, not just the shared Linux syscall surface a container leaves exposed. And "destroy" returns the machine to nothing: no planted service worker, no poisoned cache, no leftover process for the next tenant to inherit.

Here's how the two architectures actually compare on the axes that matter for a service rendering untrusted URLs:

  • Isolation boundary — Shared pool: OS process + browser context on one shared host kernel. MicroVM-per-job: a hardware-virtualized guest with its own kernel; an escape must break the hypervisor.
  • SSRF blast radius — Shared pool: the pool host's full network reachability (metadata endpoint, internal services, loopback sidecars). MicroVM-per-job: only what the sandbox's network namespace allows — default-deny egress with an allowlist, and no route to your metadata endpoint.
  • Cross-tenant leakage — Shared pool: cookies, cache, service workers, and heap state carry between jobs; one poisoned page hits the next tenant. MicroVM-per-job: every job starts from the same clean snapshot and ends in the void; nothing crosses over.
  • Resource exhaustion — Shared pool: a CPU/memory bomb degrades every job on that host. MicroVM-per-job: capped by the VM's vCPU/RAM and a hard TTL; the bomb kills one disposable VM's clock.
  • Cleanup — Shared pool: 'best-effort' context close that leaves the process (and any planted persistence) alive. MicroVM-per-job: the whole machine is deleted — cleanup is total, not hopeful.
  • Density / cost — Shared pool: high throughput per host, paid for in shared blast radius. MicroVM-per-job: snapshot-restore makes a fresh VM per job affordable (create p50 ~179ms), so you get isolation without a warm pool burning RAM on idle browsers.
Fresh-VM-per-job means no warm-pool cross-tenant leakage. There is no reused browser where one URL's service worker, cache entry, or heap corruption can reach the next job — every render starts from the same clean template snapshot and ends deleted. That's the entire reason to pay for a VM per render instead of a shared pool.

The render job, boxed in a microVM

PandaStack ships a `browser` template with a headless Chromium and a Playwright/Puppeteer runtime baked into the snapshot, so there's zero per-job install. The plan below is a config-ish sketch of the pipeline, then the actual executor in the Python SDK. First, the shape of what runs per request:

# Per render job, on the browser template:
#
#   1. create   fresh microVM  (browser template, snapshot-restore ~179ms p50)
#   2. egress    default-deny netns; allowlist only 80/443 to the public web
#                -> the metadata endpoint 169.254.169.254 is UNREACHABLE
#                -> RFC1918 ranges (10/8, 172.16/12, 192.168/16) blackholed
#   3. render    launch headless Chromium, navigate(url), screenshot -> /out
#   4. pull      copy the PNG/PDF bytes out via the filesystem API
#   5. destroy   delete the VM (ttl_seconds is the backstop if we crash)
#
# Chromium hardening flags to pass to the launcher inside the guest:
CHROME_FLAGS=(
  --headless=new
  --no-sandbox                 # the VM *is* the sandbox; chrome's is redundant here
  --disable-dev-shm-usage      # /dev/shm is small in a microVM; use /tmp
  --disable-gpu
  --js-flags=--max-old-space-size=512   # cap the JS heap: starve the memory bomb
  --window-size=1280,720
)
# Hard per-job budget: navigation timeout 20s, VM ttl 120s. A page that
# won't finish rendering is a page you don't screenshot.

Now the executor. The service has already decided which URL to render; this is the part that boxes the browser in. It writes a small Playwright script into the guest, runs it with a hard timeout, and reads the resulting PNG back out as bytes.

from pandastack import Sandbox

RENDER_SCRIPT = r"""
import sys
from playwright.sync_api import sync_playwright

url = sys.argv[1]
with sync_playwright() as p:
    browser = p.chromium.launch(args=[
        "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
        "--js-flags=--max-old-space-size=512",
    ])
    # Fresh context = no shared cookies/cache/service-workers. In a
    # throwaway VM this is belt-and-suspenders, and that's the point.
    ctx = browser.new_context(viewport={"width": 1280, "height": 720})
    page = ctx.new_page()
    page.goto(url, wait_until="networkidle", timeout=20_000)
    page.screenshot(path="/out/shot.png", full_page=True)
    browser.close()
print("ok")
"""

def render_screenshot(job_id: str, untrusted_url: str) -> bytes:
    """Render one attacker-controlled URL in a throwaway microVM, return PNG bytes.

    `untrusted_url` is adversarial: it may point at your metadata endpoint,
    an internal service, or a page carrying a renderer exploit. The VM (and its
    locked-down network namespace) is the boundary — not a regex on the URL.
    """
    # One hardware-isolated microVM per render (p50 ~179ms to create),
    # tagged with the job so a log traces a render back to a request.
    with Sandbox.create(
        template="browser",
        ttl_seconds=120,                     # reaped even if we crash mid-handler
        metadata={"surface": "screenshot-svc", "job": job_id},
    ) as sbx:
        sbx.exec("mkdir -p /out")
        sbx.filesystem.write("/workspace/render.py", RENDER_SCRIPT)
        # timeout_seconds is the circuit breaker for a page that never settles.
        result = sbx.exec(
            f"python3 /workspace/render.py {untrusted_url!r}",
            timeout_seconds=40,
        )
        if result.exit_code != 0:
            raise RuntimeError(f"render failed: {result.stderr[:500]}")
        # Pull the artifact bytes out through the filesystem API.
        return sbx.filesystem.read("/out/shot.png")
    # VM destroyed here — no browser process, no service worker, no cache leftover.

# png = render_screenshot("job_9f21", "https://example.com")
# object_store.put(f"shots/{job_id}.png", png)

Two guardrails belong on every job. `timeout_seconds` on `exec` bounds a page that never reaches `networkidle` — a hung navigation or a spin-loop kills one disposable VM's clock instead of wedging a pooled worker forever. `ttl_seconds` on create is the backstop that reaps the VM even if your handler throws between create and destroy. Belt and suspenders: the render has a deadline, and the VM has an expiry. The `with` form kills the sandbox on block exit, so there's no leak even when a render raises. The same create-render-destroy flow exists in the TypeScript SDK, so a Node service (with Puppeteer instead of Playwright) wraps it identically.

The controls that actually stop SSRF

Isolation caps the blast radius of a renderer exploit, but the far likelier incident is a perfectly isolated VM whose browser cheerfully fetched your instance metadata and posted the credentials back to the attacker. A microVM with an open outbound path to your private network isn't a sandbox — it's an SSRF proxy with a screenshot API. The boundary is necessary; these controls make it sufficient:

  • Default-deny egress — Block all outbound network from the sandbox and allowlist only what a public-web render needs (typically 80/443 to non-private destinations). Per-sandbox network namespaces make this a property of the environment, not a hope about the URL. This is the same mechanism behind /blog/controlling-network-egress-untrusted-code.
  • Block the metadata endpoint — The cloud instance metadata service (169.254.169.254, and the IPv6 fd00:ec2::254) is the number-one SSRF target and must be unreachable from inside the sandbox, full stop.
  • Blackhole RFC1918 and loopback — Drop egress to 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 127.0.0.0/8 at the network layer, so a redirect or DNS-rebind to an internal service or a loopback sidecar goes nowhere. Enforce this after DNS resolution, not on the URL string, so rebinding can't sneak past.
  • No credentials in the guest — Never inject cloud keys, DB URLs, or your storage token into the browser VM. It fetches public pages; it has no business holding a secret. A leaked page can't exfiltrate what the environment never contained.
  • A hard resource cap — The VM's baked vCPU/RAM already bounds a memory bomb; pair it with --max-old-space-size on the JS heap and a navigation timeout so a spin-loop page can't consume the whole job budget.
  • A TTL on every sandbox — Set a time-to-live so an abandoned or runaway render VM is reaped even if your handler forgets to clean up. Rendering hangs; the host shouldn't be where that ends.

For deeper background on why a browser specifically needs VM-grade isolation rather than a process boundary, see /blog/browser-isolation-microvm. The controls above are policy you set on the sandbox and the loop around it — the code is the executor skeleton; egress, credentials, and budget are what you layer on.

Sub-second create is what makes per-job affordable

The historical objection to a VM-per-render service was latency: VMs took seconds to boot, and a screenshot API that adds five seconds of cold-boot tax to every request is a non-starter. That boot cost is exactly what pushed everyone toward warm browser pools — the shared-state pattern we're trying to escape. Snapshot-restore removes the trade-off. PandaStack boots the browser template once, snapshots the running machine, and restores that snapshot on demand: a create is roughly ~49ms of restore work, landing at a p50 of 179ms and p99 of ~203ms end to end. The first-ever cold boot of a brand-new template is ~3s, but that happens once at bake time — every real render takes the restore fast path.

There's a capacity angle too. Because create is restore-on-demand rather than a warm pool burning RAM on idle Chromium processes, per-job VMs scale with how many renders you're actually serving, not with a guessed pool size. A single agent pre-allocates 16,384 /30 subnets, so a burst of screenshot requests spins up VMs on arrival and lets them evaporate when the shot is captured. If every render needs a heavier known-good state — a logged-in session, a custom font set, a warmed browser profile — configure one sandbox, snapshot it, and fork per job instead: a same-host fork lands in ~400–750ms with copy-on-write memory (cross-host is ~1.2–3.5s when the scheduler places the child elsewhere), so setup is paid once, not per render, without ever sharing a live browser between jobs. Compare against other platforms' numbers by verifying against their docs; the point here is that snapshot-restore, not a warm pool, is what makes fresh-per-job practical.

Per-job or per-tenant? Pick your trust boundary

Fresh-per-job is the safe default, but the rule is really about trust domains, not requests: you may reuse a VM across renders that share a trust domain, and you must never reuse one across renders that don't.

  • Fully untrusted public URLs — Fresh VM per job. Every URL is its own trust domain; there's nothing to reuse safely. This is the strictest and simplest posture.
  • Per-tenant known destinations — Reuse: a short-lived VM per tenant that renders several of that one tenant's own pages (e.g. generating PDFs of their dashboards) is fine — they already trust their own content. Never let a second tenant's job touch that VM.
  • Setup-heavy renders — Fork: if each render needs a warmed profile or a logged-in session, fork per job off a snapshotted base so setup is paid once, without a shared live browser between trust domains.

The mental model: one long-lived browser VM is a single-tenant session, genuinely useful for rendering one customer's own content across many requests. The moment two different tenants' URLs could touch the same VM — or the moment you're rendering arbitrary public URLs at all — you've turned an isolation boundary into a shared bus with a render API. When in doubt, destroy it: a VM costs ~179ms to create now, and the alternative costs an incident review.

The takeaway

A screenshot/render service is a firehose of adversarial URLs pointed at a browser engine that fetches and executes whatever it's told, and you cannot make Chromium immune to a malicious page. So you make the environment disposable: a microVM per render job, default-deny egress with the metadata endpoint and private ranges blackholed, no credentials in the guest, a JS-heap and navigation cap, a TTL, and never a shared browser across tenants. Sub-second snapshot-restore is what makes that affordable — a fresh, hardware-isolated VM per render instead of a reused warm pool that quietly mixes everyone's pages together. Do it, and the day someone submits a URL pointing at `169.254.169.254`, the only thing that happens is a connection error inside a throwaway VM you were about to delete anyway — and your credentials stay exactly where they belong.

Frequently asked questions

Why do I need to sandbox a headless browser screenshot service at all?

Because you're pointing a full browser engine at attacker-chosen URLs and asking it to execute whatever comes back. That exposes three risks: SSRF (the browser can fetch your cloud metadata endpoint, internal services, or loopback sidecars via a redirect or page script), renderer exploits (a malicious page can gain code execution inside Chromium, and now inside your host), and resource exhaustion (an infinite loop or a giant canvas can pin a CPU and exhaust memory). Running each render in a disposable, network-restricted microVM contains all three to a throwaway VM instead of your host or your other tenants.

Why is a shared browser pool dangerous for rendering untrusted URLs?

A reused browser process carries state between jobs — cookies, localStorage, service workers, DNS cache, connection pools, and JS heap. One tenant's malicious page can plant a service worker or poison the cache, and the next tenant's screenshot of an innocent URL gets served the attacker's payload. Worse, if a renderer exploit lands in a pooled process, the attacker sits inside a long-lived host process that handles every other customer's job. Giving each render its own Firecracker microVM (separate kernel, filesystem, memory, network namespace) that is destroyed after the shot means there is no shared process or kernel to leak or escape through.

How does a microVM stop SSRF into my cloud metadata endpoint?

Isolation alone doesn't — you also lock down the sandbox's network namespace. Set default-deny egress and allowlist only public 80/443, then blackhole the metadata endpoint (169.254.169.254 and fd00:ec2::254), RFC1918 ranges (10/8, 172.16/12, 192.168/16), and loopback. Enforce this at the network layer after DNS resolution, not with a regex on the URL, so DNS rebinding, decimal IPs, IPv6 forms, and redirects can't route around it. A microVM with default-deny egress simply has no route to your metadata endpoint, so a page that tries to fetch it gets a connection error.

Won't spinning a fresh VM for every screenshot make the service too slow?

Not with snapshot-restore. PandaStack restores a baked browser-template snapshot in ~49ms of restore work, for a create p50 of about 179ms and p99 of ~203ms — small next to the render itself. A first-ever cold boot of a new template is ~3s, but that happens once at bake time, not per job. If each render needs heavier setup (a warmed profile or logged-in session), fork from a snapshot instead (~400–750ms same-host, copy-on-write memory) so setup is paid once rather than per render. Compare other platforms' numbers against their own docs.

Can I ever reuse a browser VM across render jobs?

Only within a single trust domain. Reusing a short-lived VM to render several of one tenant's own pages (like generating PDFs of that customer's dashboards) is fine — they already trust their own content. But for arbitrary public URLs, every URL is its own trust domain, so use a fresh VM per job. Never let two different tenants' renders share a VM; that mixes their untrusted content and defeats the boundary. Because create is sub-second, you don't need the reuse optimization for untrusted traffic — use a fresh VM (or a fork off a warm base) per render and kill it after the shot.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.