Scheduled broken-link and redirect checks that tell you the moment a link breaks — linkbruno.com

lychee is a link checker written in Rust. It's fast, it understands Markdown, HTML and plain text, and it has an official GitHub Action. That makes it one of the easiest ways to stop broken links landing in a docs repo or a static site.

The default setup works, but it tends to produce a noisy first run: rate-limited sites, links that need a login, and example URLs that were never meant to resolve. This post gets you from zero to a quiet, useful check: one workflow for pull requests, one for a weekly sweep, a config file, caching, and the exclusions worth adding on day one.

The minimal workflow

Create .github/workflows/links.yml:

name: Links on: pull_request: schedule: - cron: "0 6 * * 1" # Mondays 06:00 UTC  workflow_dispatch: jobs: lychee: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Check links uses: lycheeverse/lychee-action@v2 with: args: --config lychee.toml './**/*.md' './**/*.html' fail: true 

fail: true makes the job fail when broken links are found. Without it the action reports problems but the check stays green, which is fine for a first trial run and useless after that.

Quote the glob patterns. If you don't, the shell expands them before lychee sees them, and you'll either check too little or hit an argument-length limit.

A config file instead of a long args line

Put settings in lychee.toml at the repo root, so they're the same in CI and on your laptop:

# lychee.toml max_concurrency = 16 max_retries = 2 retry_wait_time = 3 timeout = 20 # Treat these as success, not failure accept = [200, 204, 206, 429] # Don't check these at all exclude = [ '^https?://localhost', '^https?://127\.0\.0\.1', '^https?://(www\.)?example\.(com|org|net)', '^https?://(www\.)?linkedin\.com', ] exclude_path = ["node_modules", "vendor", "CHANGELOG.md"] # Check mailto: addresses for valid syntax only include_mail = false 

Two settings deserve a comment.

Accepting 429. "Too Many Requests" means the server is up and has decided you're asking too often. In CI, that's not a broken link. Accepting it keeps the check from failing because a popular site rate-limited GitHub's shared runner IPs. If you'd rather be strict, keep 429 as a failure in the weekly run and accept it on pull requests.

Excluding LinkedIn and friends. Some sites block automated requests entirely (LinkedIn's famous status 999, some sites behind bot protection that return 403 to anything without a real browser). Checking them in CI produces failures you can't fix. Exclude them, and check those links by hand occasionally, or with a separate tool that uses a real browser.

Checking only what changed on pull requests

On a big docs repo, checking every file on every pull request is slow and fails on problems the PR didn't introduce. For PRs, check only the changed files:

 - name: Changed files id: changed run: | git fetch origin "${{ github.base_ref }}" --depth=1 files=$(git diff --name-only --diff-filter=AM "origin/${{ github.base_ref }}" -- '*.md' '*.html' | tr '\n' ' ') echo "files=$files" >> "$GITHUB_OUTPUT" - name: Check links in changed files if: github.event_name == 'pull_request' && steps.changed.outputs.files != '' uses: lycheeverse/lychee-action@v2 with: args: --config lychee.toml ${{ steps.changed.outputs.files }} fail: true 

Use fetch-depth: 0 on the checkout step (or fetch the base branch as above) so the diff has something to compare against. The scheduled run still checks everything, which is where links that rotted on their own get caught.

Want link checks that still run when CI is quiet? linkbruno.com schedules broken-link and redirect checks and tells you the moment a link breaks.

Caching results between runs

External links rarely change from hour to hour. lychee can cache results so repeated runs skip URLs it checked recently:

 - name: Restore lychee cache uses: actions/cache@v4 with: path: .lycheecache key: cache-lychee-${{ github.sha }} restore-keys: cache-lychee- - name: Check links uses: lycheeverse/lychee-action@v2 with: args: --config lychee.toml --cache --max-cache-age 1d './**/*.md' fail: true 

The key includes the commit SHA so a new cache is saved after every run, and restore-keys loads the most recent one. A one-day maximum age is a good balance: PRs on the same day are fast, and nothing stays "known good" for long. If you don't want failures to be cached at all, so a link that was down an hour ago is always checked again, recent lychee versions can leave chosen status codes out of the cache (look for cache_exclude_status in the docs for your version).

Opening an issue from the weekly run

A failing scheduled job is easy to miss. Turn the report into an issue instead:

 - name: Check links id: lychee uses: lycheeverse/lychee-action@v2 with: args: --config lychee.toml './**/*.md' output: ./lychee/out.md fail: false - name: Create issue if: steps.lychee.outputs.exit_code != 0 && github.event_name == 'schedule' uses: peter-evans/create-issue-from-file@v5 with: title: Broken links report content-filepath: ./lychee/out.md labels: broken-links 

The job needs issues: write permission for this. The issue becomes the to-do list; close it when the links are fixed.

Things lychee won't catch by default

  • Fragments. page.html#install passes as long as page.html exists, even if the #install heading was renamed. Recent lychee versions can check fragments with --include-fragments; try it, and expect a few false positives on pages that build headings with JavaScript.
  • Root-relative links in local files. /docs/setup in a Markdown file has no domain. Tell lychee what the site root is (the option name has changed between versions, so check lychee --help), or those links get skipped or reported as errors.
  • Soft 404s. A page that says "not found" with a 200 status passes every link checker that only looks at status codes.

For those gaps, and for the sites that block CI runners, a crawler that runs from outside your CI, such as linkbruno.com, is a useful second layer.

Related reading

Are you running lychee, or another checker, in CI? Which exclusions did you end up needing that I haven't listed?