DEV Community

Cover image for Generate Open Graph images in GitHub Actions (no headless browser required)
Accreditly
Accreditly

Posted on • Originally published at html2img.com

Generate Open Graph images in GitHub Actions (no headless browser required)

You can render Open Graph images, pull request screenshots and release cards from a GitHub workflow without installing a single browser on the runner. No Puppeteer, no apt-get install block for Chrome's dependencies, no cache step for a 300 MB browser download. One workflow step does the rendering remotely and hands you back a file.

The step is html2img/action, the official GitHub Action for the HTML to Image API. We have published a full write-up of the workflow patterns in Generate Open Graph Images in GitHub Actions, and this tutorial walks you through getting from nothing to committed OG images and PR screenshots, step by step.

Why bother moving the render out of the runner at all? Because a GitHub-hosted runner is a fresh machine every run. Anything a headless browser needs gets installed every time or cached with actions/cache, and both routes add minutes and new ways to fail. An API call takes a couple of seconds whatever the page contains, and fonts, emoji and CSS support stop being your problem.

Prerequisites

  • A GitHub repository with Actions enabled.
  • A free HTML to Image account. The free tier comes with 50 credits and does not ask for a card. One credit is one render.
  • Your API key from the dashboard.

Step 1: Add your API key as a secret

In your repository, go to Settings, then Secrets and variables, then Actions, and create a repository secret named HTML2IMG_API_KEY with your key as the value. The action masks the key in logs, but it still belongs in a secret rather than in the workflow file.

Step 2: Render your first image

Create .github/workflows/render.yml:

name: Render

on: workflow_dispatch

jobs:
  render:
    runs-on: ubuntu-latest
    steps:
      - uses: html2img/action@v1
        id: render
        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: hello.png

      - run: echo "Rendered ${{ steps.render.outputs.url }}"
Enter fullscreen mode Exit fullscreen mode

Run it from the Actions tab. The step writes hello.png into the workspace and sets three outputs you can use in later steps: url (the hosted render), path (where the file landed) and skipped (more on that in a moment).

You give the action exactly one source per step: html for inline markup, html-file for a file in your repository, url to screenshot a live page, or template plus variables to render a named template.

Step 3: Generate OG images for your blog posts

Here is the real workflow: every time posts change on main, find the ones with no social card yet, render them, and commit the PNGs. The example paths are Astro's, but only the paths are. Point it at content/posts and it is a Hugo workflow.

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

      - 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
Enter fullscreen mode Exit fullscreen mode

Committing the images matters, and it is not just tidiness. On the free plan a hosted render is kept for 7 days. An OG image referenced from a meta tag has to exist for as long as the post does, so the PNG belongs in your repository or your deploy output, not hotlinked from a CDN you do not control.

The card markup above is deliberately minimal so the workflow stays readable. In a real project you would design something better, or skip HTML altogether and use a named template.

Step 4: Screenshot the pull request preview

If your host deploys a preview per pull request, you can screenshot it and post the image as a comment that updates on every push. Reviewers see the change without leaving the PR.

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
          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 }})
Enter fullscreen mode Exit fullscreen mode

Two inputs are doing the heavy lifting. wait-for-selector holds the capture until your content actually exists in the DOM, and returns the moment it does, which beats guessing a fixed delay. css injects styles over the page's own, and since the page's styles usually win on specificity, that !important is not optional. It is the cleanest way to hide a cookie banner before the shot.

Note the if: guard. GitHub does not expose secrets to workflows triggered by pull requests from forks, so this recipe is for branches on your own repository. That is a platform security decision, and a sensible one, but it is better to know before you wonder why fork PRs get no screenshot.

Two things that save your credits

First, caching. When output-path is set, the action hashes the resolved inputs and writes the digest next to the file as <output-path>.html2img-hash. If the file and a matching digest already exist, the API is never called and the skipped output is true. Commit both files, as the OG workflow does, and re-runs cost nothing. CI re-runs identical work all day long, so this default is what keeps 50 free credits meaning 50 images rather than 50 workflow runs.

Second, DPI. For HTML sources the API's dpi default is 2, so a 1200 by 630 card comes back as a 2400 by 1260 file. That is the right call for retina screens, but if anything downstream asserts exact pixel dimensions, set dpi: 1.

Going further

The same step renders named templates (template: open-graph-image with a variables JSON object), which is how you attach a social card to every release without maintaining any markup, and it takes format: pdf if the artefact you need is a document rather than an image. The full article covers the release workflow, the PDF options and the parameter combinations the action refuses to send, and the action's README has the complete inputs table.

In this tutorial you have set up an API key as a secret, rendered a first image from a workflow, wired up committed OG images for a blog and posted preview screenshots on pull requests, all without a browser anywhere near your CI.

Are you generating OG images at build time, in CI, or on demand at request time? Share your setup in the comments below.

Top comments (0)