# YouTube Transcripts in GitHub Actions and Scheduled Jobs

Source: https://transcript-yt.com/blog/youtube-transcript-api-github-actions · Updated 2026-11-10 · TranscriptYT

> youtube-transcript-api and yt-dlp fail in GitHub Actions runners, cron servers, and Docker hosts on DigitalOcean, Hetzner, Render, or Railway, because YouTube blocks datacenter IPs. Call a transcript API from the job instead.

Scheduled jobs that pull new YouTube transcripts, for a newsletter, a search index, or an AI digest, usually run on a GitHub Actions runner, a VPS, or a container platform. All of them use datacenter IPs: GitHub's hosted runners run on Azure, and DigitalOcean, Hetzner, Linode, OVH, Render, Railway, Fly.io, and Heroku are all well-known ranges. YouTube blocks caption requests from them, so the job fails with RequestBlocked, IpBlocked, or a yt-dlp bot check.

## A daily workflow that fetches new transcripts

Add TRANSCRIPTYT_KEY as a repository secret. The job lists a channel's latest uploads and saves each new transcript as a Markdown file in the repo.

```
# .github/workflows/transcripts.yml
on:
  schedule: [{ cron: "0 6 * * *" }]
  workflow_dispatch:
jobs:
  fetch:
    runs-on: ubuntu-latest
    permissions: { contents: write }
    steps:
      - uses: actions/checkout@v4
      - run: python fetch.py
        env:
          TRANSCRIPTYT_KEY: ${{ secrets.TRANSCRIPTYT_KEY }}
      - run: |
          git config user.name github-actions
          git config user.email github-actions@users.noreply.github.com
          git add transcripts && git commit -m "New transcripts" || true
          git push
```

## The script

```
# fetch.py
import os, pathlib, requests

API = "https://transcript-yt.com/v1"
H = {"Authorization": f"Bearer {os.environ['TRANSCRIPTYT_KEY']}"}
out = pathlib.Path("transcripts"); out.mkdir(exist_ok=True)

videos = requests.get(f"{API}/playlist", params={"url": "https://www.youtube.com/@mkbhd", "limit": 10},
                      headers=H, timeout=60).json()["videos"]
for v in videos:
    path = out / f"{v['videoId']}.md"
    if path.exists():
        continue
    r = requests.get(f"{API}/transcript", params={"url": v["videoId"], "format": "md"},
                     headers=H, timeout=300)
    if r.ok:
        path.write_text(r.text)
```

## Same job on a VPS or container

The script runs unchanged from cron, a Docker container, or a Render or Railway cron job, since only transcript-yt.com is contacted. Without code, the [n8n](https://transcript-yt.com/blog/youtube-transcripts-n8n), [Make](https://transcript-yt.com/blog/youtube-transcripts-make-com), and [Zapier](https://transcript-yt.com/blog/youtube-transcripts-zapier) guides build the same schedule.

## FAQ

### Why does youtube-transcript-api fail in GitHub Actions?

GitHub-hosted runners use Azure datacenter IPs, which YouTube blocks for caption requests. A self-hosted runner on a home connection works, but ties the job to that machine.

### Does a VPS on Hetzner or DigitalOcean work?

Usually not for long. VPS providers' IP ranges are known datacenter ranges, and YouTube blocks or rate-limits them, sometimes from the first request.

### How do I get new videos from a channel on a schedule?

Call /v1/playlist with the channel URL to list its latest uploads (free on paid plans), keep the IDs you've already processed, and fetch transcripts only for new ones.

### How do I store the API key in GitHub Actions?

Add it as a repository secret and pass it to the step as an environment variable: env: TRANSCRIPTYT_KEY: ${{ secrets.TRANSCRIPTYT_KEY }}.
