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 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
| 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:
- 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 }}:

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.
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.
Related integrations
The same API and the same key across every stack. These are the neighbours of the GitHub Actions integration.
Start rendering from GitHub Actions
50 free credits, no card required. One credit renders one image or one PDF.