---
title: Host Puppeteer Screenshots on a Public URL
description: Capture a full-page Puppeteer screenshot, close the browser safely, upload the PNG, and verify its public URL.
slug: puppeteer-screenshot-upload
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: Use Puppeteer Page screenshot with a file path, close the browser in finally, then upload the PNG with Node fetch.
cluster: Automation workflows
intent: workflow
sources:
  - title: Puppeteer screenshot guide
    url: https://pptr.dev/guides/screenshots
  - title: Puppeteer Page screenshot API
    url: https://pptr.dev/api/puppeteer.page.screenshot
  - title: Puppeteer Browser close API
    url: https://pptr.dev/api/puppeteer.browser.close
  - title: imgd.dev OpenAPI document
    url: https://imgd.dev/openapi.json
---

# Host Puppeteer Screenshots on a Public URL

Call `page.screenshot({ path, type: "png" })`, close the browser in `finally`, then upload the saved PNG to `https://imgd.dev/v1/upload`. The result contains a public image URL that works immediately.

imgd.dev costs a one-time **$1 per GB** of storage. The service has **no free tier**, so fund the account before the first upload. Every imgd.dev image is public. Never capture authenticated pages, private dashboards, secrets, or personal data for this workflow.

## What the example does

The Puppeteer guide documents `Page.screenshot()` for page capture. The `path` option writes the output to a file.

This example uses these steps:

1. Open a new headless browser.
2. Set a fixed 1440 by 900 viewport.
3. Open the requested HTTP or HTTPS page.
4. Save a full-page PNG.
5. Close the browser in `finally`.
6. Read the closed file with `readFile`.
7. Upload it with Node `fetch`, `Blob`, and `FormData`.
8. Consume every HTTP response body.
9. Verify that the public URL returns an image.

The imgd.dev upload limit is 20 MB. The service accepts JPEG, PNG, GIF, WebP, and AVIF.

## Install the example

Save this package file as `examples/workflows/puppeteer/package.json`:

```json
{
  "name": "imgd-puppeteer-screenshot",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "puppeteer": "25.8.0"
  }
}
```

Install Puppeteer in that directory:

```bash
cd examples/workflows/puppeteer
npm install
```

Puppeteer installs a compatible browser unless your environment changes its download settings.

## Complete capture and upload script

Save this file as `examples/workflows/puppeteer/capture-and-upload.mjs`:

```js
import { readFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
import puppeteer from "puppeteer";

const UPLOAD_URL = "https://imgd.dev/v1/upload";

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Set the ${name} environment variable.`);
  }
  return value;
}

function errorMessage(error) {
  return error instanceof Error ? error.message : String(error);
}

function parseJson(text, service) {
  try {
    const value = JSON.parse(text);
    if (!value || typeof value !== "object" || Array.isArray(value)) {
      throw new Error("The response is not a JSON object.");
    }
    return value;
  } catch (error) {
    throw new Error(`${service} returned invalid JSON: ${errorMessage(error)}`);
  }
}

async function captureScreenshot(targetUrl, outputPath) {
  let browser;
  let captureError;

  try {
    browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
    const navigation = await page.goto(targetUrl, {
      waitUntil: "networkidle2",
      timeout: 45_000,
    });
    if (navigation && !navigation.ok()) {
      throw new Error(`Page navigation failed with HTTP ${navigation.status()}.`);
    }
    await page.screenshot({ path: outputPath, type: "png", fullPage: true });
  } catch (error) {
    captureError = error;
    throw error;
  } finally {
    if (browser) {
      try {
        await browser.close();
      } catch (closeError) {
        if (!captureError) {
          throw closeError;
        }
        console.error(`Browser close also failed: ${errorMessage(closeError)}`);
      }
    }
  }
}

async function uploadScreenshot(path, key) {
  const bytes = await readFile(path);
  const form = new FormData();
  form.set("file", new Blob([bytes], { type: "image/png" }), "screenshot.png");

  const response = await fetch(UPLOAD_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}` },
    body: form,
    signal: AbortSignal.timeout(60_000),
  });
  const text = await response.text();
  const payload = parseJson(text, "imgd.dev");

  if (!response.ok) {
    const code = typeof payload.error === "string" ? payload.error : "unknown_error";
    const fix = typeof payload.fix === "string" ? payload.fix : "Read the response body.";
    throw new Error(`Upload failed with HTTP ${response.status} and ${code}: ${fix}`);
  }

  if (typeof payload.hash !== "string" || !/^[0-9a-f]{64}$/.test(payload.hash)) {
    throw new Error("The success response has no valid image hash.");
  }
  if (typeof payload.url !== "string") {
    throw new Error("The success response has no public URL.");
  }

  const publicUrl = new URL(payload.url);
  if (
    publicUrl.protocol !== "https:"
    || publicUrl.hostname !== "i.imgd.dev"
    || publicUrl.pathname !== `/i/${payload.hash}`
  ) {
    throw new Error("The success response has an unexpected public URL.");
  }

  return payload;
}

async function verifyPublicUrl(url) {
  const response = await fetch(url, {
    headers: { Accept: "image/*" },
    signal: AbortSignal.timeout(60_000),
  });
  const contentType = response.headers.get("content-type") ?? "";
  const _body = await response.arrayBuffer();

  if (!response.ok || !contentType.startsWith("image/")) {
    throw new Error(
      `Public URL verification failed with HTTP ${response.status} and Content-Type ${contentType}.`,
    );
  }

  console.error(`Verified ${url} with HTTP ${response.status} and Content-Type ${contentType}.`);
}

async function main() {
  const key = requiredEnv("IMGD_KEY");
  const targetUrl = new URL(process.argv[2] ?? "https://example.com");
  if (!new Set(["http:", "https:"]).has(targetUrl.protocol)) {
    throw new Error("Use an HTTP or HTTPS page URL.");
  }

  const outputPath = resolve(process.argv[3] ?? "screenshot.png");
  if (extname(outputPath).toLowerCase() !== ".png") {
    throw new Error("Use a .png output path.");
  }

  await captureScreenshot(targetUrl.href, outputPath);
  const upload = await uploadScreenshot(outputPath, key);
  await verifyPublicUrl(upload.url);
  console.log(JSON.stringify(upload, null, 2));
}

main().catch((error) => {
  console.error(errorMessage(error));
  process.exitCode = 1;
});
```

The browser closes after capture success and capture failure. The file writer finishes before `readFile` opens the PNG for upload.

The script calls `text()` or `arrayBuffer()` on each HTTP response. This action consumes and releases the response body on error paths.

## Run the capture and upload

Store `IMGD_KEY` outside the repository. Do not pass it to page JavaScript or put it in a URL.

```bash
cd examples/workflows/puppeteer
test -n "$IMGD_KEY"
node capture-and-upload.mjs https://example.com screenshot.png | tee upload.json
```

A new screenshot returns HTTP `202` with this complete JSON shape:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1440,
  "height": 2400,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}
```

An identical screenshot can return HTTP `200` with `"deduplicated": true`.

The public URL works immediately. It can show a blurred image while moderation runs. The same URL shows the full screenshot after a `live` result.

## Verify the result

The script verifies the response status and `Content-Type`. Also inspect the image metadata with the returned hash:

```bash
HASH="$(jq -r '.hash' upload.json)"
curl --fail --silent --show-error \
  -H "Authorization: Bearer $IMGD_KEY" \
  "https://imgd.dev/v1/images/$HASH" | jq .
```

A live result includes the final dimensions and generated alt text:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "live",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1440,
  "height": 2400,
  "alt_text": "A full-page screenshot of the Example Domain page.",
  "category": "safe",
  "nsfw": false,
  "violence": false,
  "filename": "screenshot.png",
  "unpublish_at": null,
  "published": true,
  "created_at": 1787184000
}
```

A `review` result is terminal and stays blurred. A `blocked` result removes the bytes. Do not retry identical blocked bytes.

## Recover from errors

| Status | Meaning | Recovery |
| --- | --- | --- |
| `401` | The key is missing or invalid. | Set the correct `IMGD_KEY`. Do not retry the same invalid key. |
| `402` | The account has no storage. | Buy storage and verify funding, then retry. There is no free tier. |
| `402` | The account quota is too small. | Add the suggested storage or delete images, then retry. |
| `413` | The full-page PNG exceeds 20 MB. | Reduce the viewport, disable `fullPage`, or compress the image. |
| `415` | The body is not a valid accepted image. | Keep `type: "png"` and the `image/png` Blob type together. |
| `429` | The key exceeded the upload rate. | Wait for the next minute, then retry. |

A representative `413` response is:

```json
{
  "error": "file_too_large",
  "fix": "file exceeds 20MB; resize the image below that and retry",
  "bytes": 24117248,
  "max_bytes": 20971520
}
```

Navigation errors happen before upload. Check the target status, DNS, certificate, and page timeout before another attempt.

## Security notes

- Use a clean browser session without saved cookies or credentials.
- Do not capture a page that contains secrets or customer data.
- Keep the browser sandbox enabled.
- Keep `IMGD_KEY` outside the page context and source code.
- Do not write the key to logs, URLs, or screenshot text.
- Treat the local PNG and returned URL as public data.
- Remove the local PNG after use if local retention is not required.

## When imgd.dev fits

imgd.dev fits public browser evidence, visual reports, and documentation screenshots. The content-addressed URL stays stable for identical PNG bytes.

It does not fit private browser evidence, authenticated screenshots, files above 20 MB, video, or a free trial. Use private artifact storage for sensitive captures.

## Next guides

- [Post the hosted screenshot in a pull request](/blog/github-pr-screenshot-comment/).
- [Add the hosted screenshot to a README](/blog/host-images-markdown-readme/).
- [Resize or convert the hosted screenshot](/blog/resize-convert-image-webp-avif/).

For an agent, follow the [integration procedure](/integrate.md). For human setup, open [the start page](/#start). Read the [service evaluation](/evaluate.md) before you select a host.

## Sources

- [Puppeteer screenshot guide](https://pptr.dev/guides/screenshots)
- [Puppeteer Page screenshot API](https://pptr.dev/api/puppeteer.page.screenshot)
- [Puppeteer Browser close API](https://pptr.dev/api/puppeteer.browser.close)
- [imgd.dev OpenAPI document](https://imgd.dev/openapi.json)
