Official Action

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.

uses: html2img/action@v1

Requires: Any GitHub-hosted or self-hosted runner with node20. Every account starts with 50 free credits, no card needed.

See also JavaScript PHP Python

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 and add it to the repository as a secret named HTML2IMG_API_KEY (Settings → Secrets and variables → Actions). Then:

- 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.

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:

# 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

InputRequiredDefaultDescription
api-keyyesYour API key. Store it as a repository secret; it is masked in the logs.
htmlone sourceInline HTML document to render.
html-fileone sourcePath to an HTML file in the workspace to render.
urlone sourcePublicly reachable URL to screenshot.
templateone sourceSlug of a named template, for example open-graph-image.
variablesnoJSON object of the template inputs. Only valid alongside template.
widthno1440Viewport width in pixels, 1 to 5000.
heightno900Viewport height in pixels, 1 to 5000.
full-pagenofalseCapture the whole height of the content instead of the viewport.
selectornoCSS selector to crop the capture to. Only valid with url, and must match exactly one element.
wait-for-selectornoWait until this CSS selector appears in the DOM before capturing.
ms-delaynoWait this many milliseconds before capturing, 1 to 5000.
dpino1 or 2Device pixel ratio, 1 to 4. Multiplies the rendered dimensions.
cssnoExtra CSS injected into the page.
formatnopngEither png or pdf.
scale-to-fitnofalsePDF only: scale a layout wider than the page down to fit instead of cropping it.
output-pathnoPath to download the render to. Parent directories are created.
skip-unchangednotrueSkip 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:

CombinationIgnored
a template rendereverything except format, since a template renders at its own size from its own inputs
format: pdfwidth, height, full-page, dpi and selector, since PDF output is A4 portrait and paginates long content
anything but format: pdfscale-to-fit, which only decides how a document is fitted to the page
full-page: trueheight, 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

OutputDescription
urlThe hosted URL of the render. On a skip, the URL recorded in the sidecar.
pathThe path the render was downloaded to, or empty when output-path was not set.
skippedtrue when the render was skipped because the inputs had not changed.

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

- 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. 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.

# 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.

# 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 and the inputs each one takes.

# 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.

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.

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. 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.

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 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 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.

Start rendering from GitHub Actions

50 free credits, no card required. One credit renders one image or one PDF.