---
title: "HTML to Image API for GitHub Actions | Render Images in CI"
description: "Render HTML, screenshot a deploy preview or fill a named template from a GitHub workflow. Official Action with input hashing so unchanged runs cost nothing."
url: "https://html2img.com/integrations/github-actions/"
---

# HTML to Image API for GitHub Actions

The official GitHub Action renders HTML, captures a live URL or fills a named template from a workflow step. It writes the file into the workspace, sets the hosted URL as a step output, and skips the API call entirely when the inputs have not changed.

- **Install:** `uses: html2img/action@v1`
- **Registry:** [GitHub Marketplace](https://github.com/marketplace/actions/html-to-image)
- **Source:** https://github.com/html2img/action
- **Licence:** MIT
- **Requires:** Any GitHub-hosted or self-hosted runner with node20

Images that belong to a repository should be built by the repository. The Action
renders HTML, captures a live URL or fills a named template from a workflow step,
writes the file into the workspace, and sets the hosted URL as a step output. It
hashes its own inputs, so a run where nothing changed makes no API call and spends
no credit.

It runs on the node20 runner with no container, so it adds no image pull to your
job.

## What you can build

- **Open Graph images for new posts**, generated on push and committed alongside
  the content.
- **A screenshot of the deploy preview**, posted as a pull request comment that
  updates on every push.
- **A social card on release**, rendered from a named template and attached to the
  release.
- **Documentation screenshots**, regenerated from a script when the UI changes
  instead of retaken by hand.
- **Scheduled snapshots** of a dashboard or a status page, committed or uploaded
  as an artefact.

## Setup

Create a key on your [dashboard](https://app.html2img.com/dashboard) and add it to
the repository as a secret named `HTML2IMG_API_KEY` (**Settings → Secrets and
variables → Actions**). Then:

```yaml
- uses: html2img/action@v1
  with:
    api-key: ${{ secrets.HTML2IMG_API_KEY }}
    html: '<div style="font: 700 72px system-ui; padding: 80px">Hello</div>'
    width: 1200
    height: 630
    output-path: og/hello.png
```

The step writes `og/hello.png` into the workspace and sets the `url` output to the
hosted render.

> **Warning: Secrets and forked pull requests**
>
> `secrets` is empty for a workflow triggered by a pull request from a fork, which
> is a GitHub security boundary rather than something the Action can work around.
> Guard those jobs with a condition on the head repository, as the preview recipe
> below does, or move the render to a `pull_request_target` or post-merge workflow
> you control.

## Choosing a source

Set exactly one of `html`, `html-file`, `url` or `template`:

```yaml
# Inline markup
- uses: html2img/action@v1
  with:
    api-key: ${{ secrets.HTML2IMG_API_KEY }}
    html: '<h1>Inline</h1>'

# A file in the workspace, built by an earlier step
- uses: html2img/action@v1
  with:
    api-key: ${{ secrets.HTML2IMG_API_KEY }}
    html-file: build/card.html

# A live URL
- uses: html2img/action@v1
  with:
    api-key: ${{ secrets.HTML2IMG_API_KEY }}
    url: https://example.com

# A named template, with its data as JSON
- uses: html2img/action@v1
  with:
    api-key: ${{ secrets.HTML2IMG_API_KEY }}
    template: open-graph-image
    variables: '{"title": "Release 2.0", "subtitle": "html2img"}'
```

For anything longer than a line or two, prefer `html-file`: YAML quoting and
multi-line strings get awkward quickly, and a file keeps the markup readable and
diffable.

## Inputs

| Input | Required | Default | Description |
| --- | --- | --- | --- |
| `api-key` | yes | | Your API key. Store it as a repository secret; it is masked in the logs. |
| `html` | one source | | Inline HTML document to render. |
| `html-file` | one source | | Path to an HTML file in the workspace to render. |
| `url` | one source | | Publicly reachable URL to screenshot. |
| `template` | one source | | Slug of a named template, for example `open-graph-image`. |
| `variables` | no | | JSON object of the template inputs. Only valid alongside `template`. |
| `width` | no | `1440` | Viewport width in pixels, 1 to 5000. |
| `height` | no | `900` | Viewport height in pixels, 1 to 5000. |
| `full-page` | no | `false` | Capture the whole height of the content instead of the viewport. |
| `selector` | no | | CSS selector to crop the capture to. Only valid with `url`, and must match exactly one element. |
| `wait-for-selector` | no | | Wait until this CSS selector appears in the DOM before capturing. |
| `ms-delay` | no | | Wait this many milliseconds before capturing, 1 to 5000. |
| `dpi` | no | `1` or `2` | Device pixel ratio, 1 to 4. Multiplies the rendered dimensions. |
| `css` | no | | Extra CSS injected into the page. |
| `format` | no | `png` | Either `png` or `pdf`. |
| `scale-to-fit` | no | `false` | PDF only: scale a layout wider than the page down to fit instead of cropping it. |
| `output-path` | no | | Path to download the render to. Parent directories are created. |
| `skip-unchanged` | no | `true` | Skip the API call when the output and its hash sidecar are already current. |

Every default above is the API's own: leave an input out and the API applies it.

**The `dpi` default depends on the endpoint**: 1 for a `url` screenshot, but 2 for
HTML. So `html` with `width: 1200` and `height: 630` returns a 2400x1260 file,
which is the sharper choice for a card on a retina display. Set `dpi: 1` if you
need the file to be exactly the dimensions you asked for.

**`css` is injected on top of the page's existing styles**, which usually win on
specificity, so `!important` is generally needed. It is the tidiest way to hide a
cookie banner or a chat widget before a screenshot.

**For timing, prefer `wait-for-selector`** wherever you control the markup: it
returns as soon as the element exists, where `ms-delay` always waits the full
duration. `ms-delay` is the fallback for the case `wait-for-selector` cannot cover,
since it does not see inside iframes. If you find yourself reaching for several
seconds of delay, the render is probably close to the API's timeout and worth
simplifying instead.

**`scale-to-fit` applies to PDF output only.** It scales a layout wider than the A4
page down until it fits, rather than cropping it, and trims a trailing blank page.
It only ever scales down, so a 600px-wide card stays 600px wide on the page.

### Combinations the API ignores

Some inputs cannot do anything in combination with others. Rather than send them,
the Action drops them and says so in a warning, which also keeps the cache from
re-rendering over an input that could not have changed the output:

| Combination | Ignored |
| --- | --- |
| a template render | everything except `format`, since a template renders at its own size from its own inputs |
| `format: pdf` | `width`, `height`, `full-page`, `dpi` and `selector`, since PDF output is A4 portrait and paginates long content |
| anything but `format: pdf` | `scale-to-fit`, which only decides how a document is fitted to the page |
| `full-page: true` | `height`, because the image takes the height of the content, and `dpi`, which the API forces to 1 |

Using `selector` without `url` is an error rather than a warning, because it would
otherwise return a full-page capture where you asked for one element.

## Outputs

| Output | Description |
| --- | --- |
| `url` | The hosted URL of the render. On a skip, the URL recorded in the sidecar. |
| `path` | The path the render was downloaded to, or empty when `output-path` was not set. |
| `skipped` | `true` when the render was skipped because the inputs had not changed. |

Give the step an `id` to read them in later steps:

```yaml
- uses: html2img/action@v1
  id: card
  with:
    api-key: ${{ secrets.HTML2IMG_API_KEY }}
    html-file: build/card.html
    output-path: public/og/post.png

- name: Report
  run: |
    echo "URL:     ${{ steps.card.outputs.url }}"
    echo "Path:    ${{ steps.card.outputs.path }}"
    echo "Skipped: ${{ steps.card.outputs.skipped }}"

- name: Upload as an artefact
  if: steps.card.outputs.skipped != 'true'
  uses: actions/upload-artifact@v4
  with:
    name: og-card
    path: ${{ steps.card.outputs.path }}
```

Note that step outputs are strings, so compare against `'true'` rather than
treating `skipped` as a boolean.

## Credits and caching

One credit is one render, whether the output is a PNG or a PDF. Nothing in the
Action retries, so a failed render never spends a credit twice.

With `output-path` set and `skip-unchanged` left on, the Action hashes the
resolved inputs (the markup or URL, the template and its variables, and every
render option that will actually be sent) and writes the digest next to the file as
`<output-path>.html2img-hash`. When the file and a matching digest are both
present, the API is never called and `skipped` is `true`.

**Commit both files** to keep that cache across runs. Without the sidecar in the
repository, every run starts from scratch and spends a credit per image. The API
key is not part of the digest, so rotating a key does not invalidate anything.

Renders on the free plan are kept for seven days. Paid plans keep them
indefinitely; see [pricing](https://html2img.com/pricing/). That expiry is the reason the recipes below
commit the PNGs rather than hotlinking the CDN URL: a build artefact belongs in the
repository or in your deploy output.

## Open Graph images for new posts

This generates a card for every post that does not have one yet, then commits the
results. The example is Astro; Hugo, Eleventy and Jekyll work the same way, because
only the content path changes.

```yaml
# HTML to Image API - https://html2img.com
name: OG images

on:
  push:
    branches: [main]
    paths: ['src/content/blog/**']

permissions:
  contents: write

jobs:
  find-posts:
    runs-on: ubuntu-latest
    outputs:
      slugs: ${{ steps.find.outputs.slugs }}
    steps:
      - uses: actions/checkout@v4

      - id: find
        name: List posts with no card yet
        run: |
          slugs=$(for file in src/content/blog/*.md; do
            slug=$(basename "$file" .md)
            if [ ! -f "public/og/$slug.png" ]; then printf '%s\n' "$slug"; fi
          done | jq -R . | jq -sc .)
          echo "slugs=$slugs" >> "$GITHUB_OUTPUT"

  render:
    needs: find-posts
    if: needs.find-posts.outputs.slugs != '[]'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        slug: ${{ fromJSON(needs.find-posts.outputs.slugs) }}
    steps:
      - uses: actions/checkout@v4

      - name: Build the card markup
        run: |
          title=$(sed -n 's/^title: *//p' "src/content/blog/${{ matrix.slug }}.md" | head -1 | tr -d '"')
          mkdir -p build
          cat > build/card.html <<HTML
          <!doctype html>
          <meta charset="utf-8">
          <div style="display:flex;align-items:center;width:1200px;height:630px;
                      box-sizing:border-box;padding:80px;background:#0f172a;
                      color:#fff;font:700 68px/1.15 system-ui,sans-serif">
            $title
          </div>
          HTML

      - uses: html2img/action@v1
        with:
          api-key: ${{ secrets.HTML2IMG_API_KEY }}
          html-file: build/card.html
          width: 1200
          height: 630
          output-path: public/og/${{ matrix.slug }}.png

      - uses: actions/upload-artifact@v4
        with:
          name: og-${{ matrix.slug }}
          path: public/og/

  commit:
    needs: render
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/download-artifact@v4
        with:
          pattern: og-*
          merge-multiple: true
          path: public/og

      - name: Commit the cards
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git add public/og
          if git diff --cached --quiet; then
            echo 'No new cards.'
          else
            git commit -m 'Add Open Graph images'
            git push
          fi
```

The matrix keeps each post independent, so one malformed title does not stop the
rest. The `.html2img-hash` files committed alongside the images let later runs skip
renders whose inputs have not changed.

## Screenshot a pull request preview

This screenshots a deployed preview and posts it as a pull request comment,
updating the same comment on each push. Replace the preview URL with whatever your
host produces; the pattern below is Netlify's.

```yaml
# HTML to Image API - https://html2img.com
name: Preview screenshot

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  screenshot:
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - name: Wait for the preview to answer
        id: preview
        run: |
          url="https://deploy-preview-${{ github.event.pull_request.number }}--example.netlify.app"
          for _ in $(seq 1 30); do
            if curl -fsS -o /dev/null "$url"; then
              echo "url=$url" >> "$GITHUB_OUTPUT"
              exit 0
            fi
            sleep 10
          done
          echo "The preview at $url did not answer within five minutes." >&2
          exit 1

      - uses: html2img/action@v1
        id: shot
        with:
          api-key: ${{ secrets.HTML2IMG_API_KEY }}
          url: ${{ steps.preview.outputs.url }}
          width: 1280
          height: 800
          # Answering is not the same as being ready to photograph, so wait for
          # the content, and hide anything that would sit over it.
          wait-for-selector: 'main'
          css: '.cookie-banner, .chat-widget { display: none !important }'

      - uses: peter-evans/create-or-update-comment@v4
        with:
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            Preview of ${{ github.event.pull_request.head.sha }}:

            ![Preview screenshot](${{ steps.shot.outputs.url }})
```

No `output-path` is set here, so nothing is written to the workspace and the
comment points at the hosted render. On the free plan that image disappears after
seven days, which is usually longer than the pull request stays open.

To photograph one component rather than the viewport, set `selector` to a CSS
selector matching exactly one element. For the whole scrolling page, set
`full-page: true`.

The `if` condition limits the job to branches on this repository, because secrets
are not available to a workflow triggered from a fork.

## Social card on release

This renders a card from a named template when you publish a release, then
attaches it to the release. See the full list of [templates](https://html2img.com/templates/) and the
inputs each one takes.

```yaml
# HTML to Image API - https://html2img.com
name: Release card

on:
  release:
    types: [published]

permissions:
  contents: write

jobs:
  card:
    runs-on: ubuntu-latest
    steps:
      # Built with jq rather than interpolated into the JSON directly, so a
      # release name containing a quote cannot break the payload.
      - name: Build the template variables
        id: vars
        env:
          TITLE: ${{ github.event.release.name || github.event.release.tag_name }}
          SUBTITLE: ${{ github.repository }} ${{ github.event.release.tag_name }}
        run: |
          json=$(jq -nc --arg title "$TITLE" --arg subtitle "$SUBTITLE" \
            '{title: $title, subtitle: $subtitle,
              background_color: "#0f172a", accent_color: "#3b82f6"}')
          echo "json=$json" >> "$GITHUB_OUTPUT"

      - uses: html2img/action@v1
        id: card
        with:
          api-key: ${{ secrets.HTML2IMG_API_KEY }}
          template: open-graph-image
          variables: ${{ steps.vars.outputs.json }}
          output-path: release-card.png

      - name: Attach the card to the release
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release upload '${{ github.event.release.tag_name }}' release-card.png --repo '${{ github.repository }}'
```

A template renders at its own size, so `width` and `height` are not set here.

## Scheduled renders

A `schedule` trigger plus `skip-unchanged` gives you a cheap regeneration loop: the
job runs on a cron, and only the outputs whose inputs actually changed cost a
credit.

```yaml
on:
  schedule:
    - cron: '0 6 * * 1' # Mondays at 06:00 UTC
  workflow_dispatch:

permissions:
  contents: write

jobs:
  snapshot:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: html2img/action@v1
        id: shot
        with:
          api-key: ${{ secrets.HTML2IMG_API_KEY }}
          url: https://status.example.com
          width: 1200
          full-page: true
          output-path: docs/status.png

      - name: Commit when it changed
        if: steps.shot.outputs.skipped != 'true'
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git add docs/status.png docs/status.png.html2img-hash
          git diff --cached --quiet || (git commit -m 'Update status snapshot' && git push)
```

Note that a screenshot of a live URL changes whenever the page does, but the input
hash covers the URL and the render options, not the page's content. For a page that
changes often, the render will run every time; `skip-unchanged` earns its keep on
`html`, `html-file` and `template` sources, where the inputs are in the repository.

> **Note: Scheduled workflows on quiet repositories**
>
> GitHub disables scheduled workflows on a public repository after 60 days with no
> activity, and schedules can be delayed under load. Add `workflow_dispatch` so you
> can always run it by hand.

## Errors

Failures name the cause and the fix. An invalid key says so and points at the
dashboard; running out of credits says so and points at pricing; a rejected
parameter is reported with the API's own message for that field. Neither the key
nor the rendered HTML is written to the log.

Combine that with `fail-fast: false` on a matrix so one bad input does not cancel
the rest of the renders, and with `continue-on-error: true` on the step when a
missing image should not block a deployment.

## Troubleshooting

**`api-key` is empty.** Secrets are not available to workflows triggered by a pull
request from a fork. Check the trigger, and guard the job with a condition on
`github.event.pull_request.head.repo.full_name`.

**The screenshot is of an error page.** The URL has to be reachable from the public
internet: a preview behind basic auth, an IP allowlist or a VPN is not. Either open
the preview, or render the markup with `html-file` instead of screenshotting it.

**The capture is blank or half-drawn.** The page had not finished when the shot was
taken. Add `wait-for-selector` pointing at something the finished page contains.
`ms-delay` is the fallback for iframe content, which selectors cannot see.

**Every run spends a credit.** The `.html2img-hash` sidecar is not committed, so
each run starts from a clean workspace with no cache. Add both the image and its
sidecar to the repository.

**The output file is bigger than I asked for.** `dpi` defaults to 2 on the HTML
endpoint, so `width: 1200` produces a 2400px file. Set `dpi: 1` for exact
dimensions.

**A warning says an input was ignored.** That is the Action telling you a
combination cannot do anything; see
[combinations the API ignores](#combinations-the-api-ignores). It is a warning
rather than an error because the render still succeeded.

**The commit step pushes nothing.** `git diff --cached --quiet` succeeds when there
is nothing staged, which is the intended no-op. If images were rendered but not
staged, check that `output-path` is inside the paths you `git add`.

## FAQ

**Which version should I pin?**
`html2img/action@v1` tracks the v1 major and picks up fixes. Pin a full tag, or a
commit SHA, if your policy requires immutable third-party actions.

**Does it need Node or a container on the runner?**
No. It runs on the node20 runner that GitHub already provides, with no container
image to pull.

**Can I render several images in one job?**
Yes: repeat the step with different inputs and different `output-path` values, or
use a matrix as in the Open Graph recipe. Each render is one credit.

**Does it work on self-hosted runners?**
Yes, provided the runner has the node20 environment and outbound HTTPS to
`app.html2img.com`.

**Can it produce a PDF?**
Yes. Set `format: pdf`. The output is A4 portrait with selectable text, and
`scale-to-fit` handles layouts wider than the page. Sizing inputs are ignored, and
it is still one credit. The [HTML to PDF API](https://html2img.com/html-to-pdf/) page covers what PDF
mode does and does not do.

**How do I use the render without committing it?**
Leave `output-path` unset and use the `url` output directly, as the pull request
recipe does. Bear in mind the seven-day free-tier expiry if the link needs to
outlive that.

**Can I check credits from a workflow?**
Call [`GET /api/me`](https://html2img.com/docs/account/) with `curl` and the same secret. It costs
nothing and never renders.

**How does this relate to the JavaScript SDK?**
The Action is a packaged workflow step; the
[JavaScript SDK](https://html2img.com/integrations/javascript/) is what you would reach for inside a
`run:` step doing something more involved, such as rendering a card per row of a
data file.
