A pull request fixing a typo in the README failed CI. The reason: a blog post linked from the docs, on a site owned by someone else, started returning 503 that morning. The contributor couldn't fix it, the maintainer was asleep, and the PR sat red for two days.
The week before, the same repo merged a renamed docs page that broke 14 internal links, because link checking had been turned off after the last false alarm.
TL;DR
- Treat internal links (your own site, your own docs) as code: broken means failing build.
- Treat external links as data: check them on a schedule, report them, but don't block merges.
- Use caching, retries and an allowlist to keep the PR check fast and deterministic.
- My opinion: a CI check that can fail because of someone else's server is a flaky test, and flaky tests get disabled.
Why "fail on everything" doesn't last
External links fail for reasons that have nothing to do with the change in the PR: rate limiting, bot protection, outages, geo-blocking. If those failures block merges, three things happen in order: people re-run the job until it passes, then they add continue-on-error, then they delete the job. You end up with no link checking at all.
Why "never fail" doesn't work either
Internal links break because of changes in PRs: renamed files, moved headings, deleted pages. The PR is the cheapest moment to catch them, because the author knows exactly what moved. A week later, nobody remembers.
The split policy
| Link type | When checked | On failure |
|---|---|---|
| Relative links and anchors in the repo | every PR | fail the build |
| Links to your own domain | every PR | fail the build |
| External links | nightly or weekly | open or update an issue |
PR job: internal only, fast and offline-ish
Using lychee, a fast link checker that runs well in CI:
# .github/workflows/links-pr.yml name: Links (internal) on: pull_request jobs: internal: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: lycheeverse/lychee-action@v2 with: args: >- --offline --include-fragments --no-progress './**/*.md' fail: true --offline skips network requests and checks only local files and relative links, so the job can't be flaky. --include-fragments also checks #section anchors, which break silently whenever someone renames a heading. Broken anchors are common enough to deserve their own post: Anchor link not working? How to find and fix broken section links.
If your docs link to your own production domain with absolute URLs, add a second step that checks only those, with retries:
- uses: lycheeverse/lychee-action@v2 with: args: >- --include '^https://docs\.example\.com' --max-retries 3 --no-progress './**/*.md' fail: true Scheduled job: external links, reported not blocking
# .github/workflows/links-nightly.yml name: Links (external) on: schedule: [{ cron: '0 3 * * 1-5' }] workflow_dispatch: jobs: external: runs-on: ubuntu-latest permissions: { issues: write } steps: - uses: actions/checkout@v4 - uses: lycheeverse/lychee-action@v2 id: lychee with: args: >- --exclude-path node_modules --max-retries 3 --accept '200..=299,429' --no-progress './**/*.md' fail: false - if: steps.lychee.outputs.exit_code != 0 uses: peter-evans/create-issue-from-file@v5 with: title: External link report content-filepath: ./lychee/out.md labels: links Accepting 429 is a deliberate choice: a rate-limited link is almost always a working link. The full reasoning on which status codes deserve trust is in the false positives post.
Keep a short, reviewed ignore list
# .lycheeignore ^https://www\.linkedin\.com/ ^https://twitter\.com/ ^https://x\.com/ Every entry should have a reason, ideally in the commit message that added it. An ignore list that grows without review turns into "we don't check links".
If you don't use lychee
The same policy works with any tool. In a small repo, even a short script that resolves relative Markdown links against the file tree is enough for the PR job. Check links in Markdown docs with a 60-line Python script has one you can drop in. For a live website rather than a docs repo, a browser test like the one in How to find broken links with Playwright fits better, and a scheduled crawl with something like linkbruno.com covers the parts of the site that don't live in the repo.
What about redirects?
Don't fail builds on redirects. Report internal links that redirect, though, because each one is a hop you could remove (see the redirect chains post). A good middle ground: fail on 4xx/5xx for internal links, warn on 3xx.
Common objections
"But then external rot piles up." Only if nobody reads the issue. Assign it to a rotating owner and close it weekly, like any other chore.
"Offline mode misses links to our own site." That's what the second, domain-scoped step is for. Keep its scope tight so it only hits servers you run.
"Nightly is too slow." For most docs, a dead external link that lives for one day costs nothing. If a link is business-critical, it probably belongs on your own domain anyway.
My take
CI should fail only on things the PR author caused and can fix. Internal links meet that bar. External links don't. Splitting the two keeps the PR check strict and trusted, and still gets external rot onto somebody's to-do list.
Over to you
Do you fail builds on external links, and if so, how do you keep the job from going flaky? I'd also like to hear how people handle links behind login, which no CI job can check.

