---
title: "Generate Open Graph Images in GitHub Actions"
description: "Generate Open Graph images in GitHub Actions without installing a browser: build-time OG cards, PR preview screenshots and release images in one step."
url: "https://html2img.com/articles/generate-og-images-github-actions/"
section: "Tutorials"
published: "2026-08-06T13:19:29.898Z"
updated: "2026-09-07T13:21:50.226Z"
---

# Generate Open Graph Images in GitHub Actions

By Mike Griffiths. https://html2img.com/articles/generate-og-images-github-actions/

![Generate Open Graph Images in GitHub Actions](https://a.storyblok.com/f/320619/1200x630/4027dce051/og.png)

Every common route to generating images inside a GitHub workflow starts the same way: install a headless browser on the runner. Puppeteer wants Chrome plus a stack of shared libraries that apt fetches on every run. Playwright bundles its own browsers, which trades the dependency hunt for a large download and a cache step you now maintain. Either way you are running browser infrastructure to produce a PNG, your build gets slower to pay for it, and the first Chrome version bump breaks it on a Tuesday.

There is now a simpler route. `html2img/action` is the official GitHub Action for the [HTML to Image API](https://html2img.com). It renders inline HTML, an HTML file from your workspace, a live URL or a named template, and the rendering happens on the API's side. The workflow step sends the source and downloads the result. Nothing is installed on the runner, and because it runs directly on the node20 runner with no container, it adds no image pull to your job.

The action is on the Marketplace as [HTML to Image](https://github.com/marketplace/actions/html-to-image) and the source is at [github.com/html2img/action](https://github.com/html2img/action). A free account comes with 50 credits, needs no card, and one credit is one render.

This guide walks through the three workflows the action was built for: Open Graph images generated at build time and committed, pull request preview screenshots posted as comments, and a social card attached to every release. It also covers the two details that decide whether CI rendering costs you real money over a year: caching and DPI.

## Why a browser in CI is the wrong tool

A GitHub-hosted runner is a fresh virtual machine every run. Anything Puppeteer needs, from Chrome itself to `libnss3` and its friends, either gets reinstalled each time or cached with actions/cache, and both options add minutes and failure modes. The dependency problem is the same one that makes [Puppeteer on Lambda such hard work](https://html2img.com/articles/puppeteer-lambda-alternative-screenshots): Chrome was never designed to be a build dependency, and CI environments punish you for treating it as one.

Moving the render to an API inverts the cost. The runner does an HTTP request and a file download, which takes a second or two regardless of what the page contains. Fonts, emoji and full CSS support are the renderer's problem rather than yours. The [Puppeteer comparison](https://html2img.com/compare/puppeteer) covers the trade-off in general terms; in CI specifically, the case is stronger, because you pay the browser setup cost on every single run rather than once.

## One step, four sources

The whole integration is a repository secret and a step. Create a key on your dashboard, store it as `HTML2IMG_API_KEY`, 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
```

You set exactly one source per step: `html` for inline markup, `html-file` for a file in the workspace, `url` to screenshot a live page, or `template` plus a `variables` JSON object to render one of the named templates. Rendering options mirror the API: `width`, `height`, `full-page`, `wait-for-selector`, `ms-delay`, `dpi`, `css`, `selector` and `format`, which takes `png` or `pdf`.

Every step exposes three outputs. `url` is the hosted render, `path` is where the file landed if you set `output-path`, and `skipped` is `true` when the built-in cache decided nothing needed rendering. Those three outputs are the hooks the recipes below hang off.

## Open Graph images for every post, generated at build time

Static sites have to solve OG images at build time, because there is no server to render one when a crawler asks. We covered the in-build approach for [Astro, Hugo and Eleventy](https://html2img.com/articles/dynamic-og-images-in-astro-hugo-and-eleventy) using framework endpoints and scripts. The workflow version below solves the same problem one level up, in CI, which suits teams who want image generation out of the build entirely: the site builds with plain files in place, and a separate workflow keeps those files topped up.

The shape is three jobs. The first lists posts that have no card yet, the second renders the missing cards in a matrix, the third commits the results.

```
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 example reads Astro's content directory, but only the paths are Astro's. Point `find-posts` at `content/posts` and it is a Hugo workflow; point it at wherever Eleventy or Jekyll keeps posts and it is theirs.

Committing the PNGs is a deliberate choice, not a shortcut. On the free plan a hosted render is kept for 7 days, and paid plans keep renders indefinitely (see [pricing](https://html2img.com/pricing)). An OG image referenced from a meta tag needs to outlive both windows on your own terms, so a build artefact belongs in your repository or your deploy output rather than hotlinked from anyone's CDN, ours included. The card markup itself is minimal here to keep the workflow readable; in a real project you would give it a proper design, or skip the HTML entirely and use the [Open Graph template](https://html2img.com/templates/open-graph-image).

## Screenshot the pull request preview

The second workflow makes changes visible during review. Most hosts already deploy a preview per pull request. Screenshotting that preview and posting it as a comment means everyone looking at the PR sees the change without clicking through, and the comment updates itself on every push.

```
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
          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 }})
```

Two of those inputs do the real work. A preview URL answering a request is not the same as the page being ready to photograph, so `wait-for-selector` holds the capture until your content actually exists in the DOM, and it returns the moment it does, where a fixed `ms-delay` always waits the full duration. The `css` input injects styles on top of the page's own, which is the tidiest way to hide a cookie banner or chat widget before the shot; the page's styles usually win on specificity, so `!important` earns its keep here.

No `output-path` is set, so nothing is written to the workspace and the comment embeds the hosted render directly. The 7-day retention on the free plan works in your favour for once, since few pull requests outlive it, and the image has done its job by merge anyway.

One honest limitation: GitHub does not expose secrets to workflows triggered by pull requests from forks, which is the right security call and also why the job guards on `head.repo.full_name == github.repository`. This recipe is for same-repository branches. And if you want one component rather than the viewport, set `selector` to a CSS selector matching exactly one element; [cropping a screenshot to a single element](https://html2img.com/articles/screenshot-single-element-from-url) covers how that behaves.

## A social card on every release

The third workflow fires when you publish a release and attaches a share card to it. This one uses a named template rather than HTML, so there is no markup to maintain at all. If you only want a single card for the repository itself rather than one per release, the free [GitHub Social Preview Generator](https://html2img.com/tools/github-social-preview/) produces the 1280x640 image GitHub expects from a form, with no workflow to wire up.

```
name: Release card

on:
  release:
    types: [published]

permissions:
  contents: write

jobs:
  card:
    runs-on: ubuntu-latest
    steps:
      - 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 }}'
```

The variables JSON is built with `jq` rather than interpolated straight into the string, so a release name containing a quote cannot break the payload. A small thing, until the release called `Fix "undefined" in the header` ships and the job goes red.

Templates render at their own size from their own inputs, which is why `width` and `height` are absent. The [templates gallery](https://html2img.com/templates) lists what each one accepts, and the [GitHub social preview template](https://html2img.com/templates/github-social-preview) is a natural sibling to this workflow if you want repository cards as well as release cards.

## The caching model, and how credits are spent

CI is where accidental spend happens, because workflows re-run identical work constantly. The action's answer is `skip-unchanged`, which is on by default whenever `output-path` is set.

Before calling the API, the action hashes the resolved inputs: the markup or URL, the template and its variables, and every render option that will actually be sent. It writes that digest next to the output file as `<output-path>.html2img-hash`. On later runs, if the file and a matching digest are both present, the API is never called, the `skipped` output is `true`, and the `url` output is read back from the sidecar. Commit both files, as the OG workflow above does, and the cache survives across runs and across machines.

Two details make the behaviour predictable. The API key is not part of the digest, so rotating a key invalidates nothing. And the action never retries, so a failed render cannot quietly spend a credit twice.

## The DPI default is different for HTML and URLs

One default is worth knowing before your first render surprises you. For a `url` screenshot the `dpi`[ parameter](https://html2img.com/docs/parameters/dpi) defaults to 1, but for HTML sources it defaults to 2. Ask for a 1200 by 630 card from HTML and the file that comes back is 2400 by 1260, which is exactly what you want for a card that will be viewed on retina displays, and not what you want if a downstream check asserts the file's pixel dimensions. Set `dpi: 1` when the file must be exactly the size you asked for.

The action also refuses to send parameter combinations the API documents as ignored, and warns instead. A template render ignores everything except `format`. PDF output is A4 portrait, so it ignores `width`, `height`, `full-page`, `dpi` and `selector`, and gains `scale-to-fit`, which shrinks a wide layout to the page instead of cropping it. `full-page` captures ignore `height`. Dropping these before the request also keeps the cache honest, since an input that cannot change the output should not force a re-render. Speaking of PDF: `format: pdf` from the same step turns any of these workflows into a document pipeline, and [HTML to PDF](https://html2img.com/html-to-pdf) plus the guide to [controlling page breaks](https://html2img.com/articles/html-to-pdf-page-breaks) cover that side properly.

## When you do not need the action

If your static site already generates OG images inside the build, the way the [Astro, Hugo and Eleventy guide](https://html2img.com/articles/dynamic-og-images-in-astro-hugo-and-eleventy) does, the action is not adding capability, it is adding packaging: the download, the masking, the error messages and the hash cache, without any code in your repository. That packaging is the point for most teams, but a working build script is not something to rip out for the sake of a shinier YAML block.

Where the action earns its place outright is everywhere a build script cannot reach: screenshotting deployed previews, reacting to release events, and any repository where you would rather review a five-line workflow step than maintain a render script.

---

Rendering happens in your workflow now, not in a browser you babysit. [Browse the templates gallery](https://html2img.com/templates) to see what you can generate, or [read the docs](https://html2img.com/docs) for the full parameter reference.
