Broken Link Checking in Docs CI
This guide is part of Docs CI/CD & Preview Deployments within Developer Portal Frameworks & UI Setup. It sets up an automated gate that refuses to merge documentation containing a dead link. This becomes urgent the first time an operation is renamed: every deep link to it across guides, blog posts, and support replies breaks at once, and nothing in the build noticed.
Problem & Context
Documentation links rot in three distinct ways, and a single check catches only one of them.
The first is an internal reference to a page that does not exist — a typo, or a link to a page that was renamed. The generator can catch this at build time because it knows every page it emitted.
The second is a link that resolves on disk but not over HTTP. /guides/auth is a directory on the filesystem and a request that must be rewritten to index.html by the host. Whether that rewrite happens depends on host configuration, so a link can be perfectly valid locally and 404 in production. No build-time check can see this, because the build has no host.
The third is an anchor that no longer exists. A link to /guides/auth/#refresh-tokens is a valid page reference regardless of whether that heading is still on the page, so it passes every structural check and lands the reader at the top of a long document wondering what they were meant to see.
A generator’s onBrokenLinks: 'throw' handles the first. Only a checker running against a deployed URL handles the other two, which is why this gate belongs on the preview rather than in the build.
Step-by-Step Solution
1. Fail the build on broken internal links
Free, instant, and catches the most common case before anything is deployed:
// docusaurus.config.js
module.exports = {
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'throw',
onBrokenAnchors: 'throw', // v3.1+; catches the third failure mode at build time
};
2. Check the deployed preview, not the filesystem
Install the checker and point it at a URL rather than a path:
# lychee is a single fast binary; pin it in CI
curl -fsSL https://github.com/lycheeverse/lychee/releases/download/v0.15.1/lychee-v0.15.1-x86_64-unknown-linux-gnu.tar.gz \
| tar -xz -C /usr/local/bin lychee
lychee --version
lychee --no-progress https://pr-482.docs-preview.example.com/
3. Drive the crawl from the sitemap
Starting at the root only reaches pages the navigation links to. Starting from the sitemap reaches everything you publish, including pages that are only reachable by deep link — which are precisely the ones nobody notices breaking:
lychee --no-progress --max-concurrency 8 \
https://pr-482.docs-preview.example.com/sitemap.xml
4. Separate internal from external failures
Two invocations with different consequences. The internal one is the gate; the external one is information:
# Gate: internal links only. Any failure blocks the merge.
lychee --no-progress --exclude-all-private \
--include "^https://pr-482\.docs-preview\.example\.com" \
--exclude-mail \
"https://pr-482.docs-preview.example.com/sitemap.xml"
# Report: everything else, never fails the build.
lychee --no-progress \
--exclude "^https://pr-482\.docs-preview\.example\.com" \
--format markdown --output external-links.md \
"https://pr-482.docs-preview.example.com/sitemap.xml" || true
5. Cache results and respect rate limits
Most 429 responses come from hammering the same few hosts. Persist the cache between runs so unchanged URLs are checked once, and throttle:
lychee --cache --max-cache-age 14d --max-concurrency 6 \
--retry-wait-time 4 --max-retries 3 \
"https://pr-482.docs-preview.example.com/sitemap.xml"
Complete Working Example
# .github/workflows/link-check.yml
name: Link check
on:
pull_request:
paths: ['docs/**', 'openapi/**']
schedule:
- cron: '0 6 * * 1' # weekly external sweep against production
permissions:
contents: read
pull-requests: write
jobs:
links:
runs-on: ubuntu-latest
env:
BASE: ${{ github.event_name == 'pull_request'
&& format('https://pr-{0}.docs-preview.example.com', github.event.number)
|| 'https://docs.example.com' }}
steps:
- uses: actions/checkout@v4
# Persist the checker cache so unchanged URLs are not re-fetched every run.
- uses: actions/cache@v4
with:
path: .lycheecache
key: lychee-${{ github.run_id }}
restore-keys: lychee-
- name: Wait for the preview to be live
run: npx wait-on "${BASE}/sitemap.xml" --timeout 120000
# GATE — internal links only. A failure here is a defect in this change.
- name: Check internal links
uses: lycheeverse/lychee-action@v2
with:
args: >-
--no-progress --cache --max-cache-age 14d
--max-concurrency 8 --exclude-mail
--include "^${{ env.BASE }}"
"${{ env.BASE }}/sitemap.xml"
fail: true
# REPORT — external links. Never fails the build.
- name: Check external links
id: external
continue-on-error: true
uses: lycheeverse/lychee-action@v2
with:
args: >-
--no-progress --cache --max-cache-age 14d
--max-concurrency 4 --retry-wait-time 4 --max-retries 3
--exclude "^${{ env.BASE }}"
--exclude "^https://(twitter|x)\.com"
"${{ env.BASE }}/sitemap.xml"
output: external-links.md
fail: false
- name: Comment external findings
if: steps.external.outcome == 'failure' && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = '<!-- link-report -->\n### External links needing attention\n\n'
+ fs.readFileSync('external-links.md', 'utf8').slice(0, 60000);
const {data: c} = await github.rest.issues.listComments(
{...context.repo, issue_number: context.issue.number});
const prev = c.find(x => x.body.includes('<!-- link-report -->'));
if (prev) await github.rest.issues.updateComment(
{...context.repo, comment_id: prev.id, body});
else await github.rest.issues.createComment(
{...context.repo, issue_number: context.issue.number, body});
The schedule trigger is what keeps external links honest without blocking anyone. A weekly sweep against production finds the third-party pages that disappeared since the last release, and files them as information rather than as an obstacle to a merge that has nothing to do with them.
Gotchas & Edge Cases
The checker races the deploy. Pointing it at a preview URL that is not live yet produces a run where every link fails, which reads as a catastrophic regression. The wait-on step is not optional; without it this job is flaky in a way that erodes trust in the gate faster than any false positive.
Anchors are checked only if you ask. Many checkers verify that a page responds and stop there, ignoring the fragment. Enable fragment checking explicitly, and be prepared for findings — anchor rot is invisible and therefore widespread. Renaming a heading changes its generated id, so a heading edit in one file quietly breaks links from every other file that pointed at it.
Login-walled and bot-hostile hosts return 403 forever. Some sites reject any request without a browser-like user agent, and no amount of retrying helps. Maintain a small, commented exclusion list rather than lowering the whole check’s strictness, and re-review it occasionally so a permanent exclusion does not hide a link that genuinely died.
Excluding a pattern is easy to over-scope. An exclusion written as a bare domain can match far more than intended once the site adds a subdomain. Anchor the regexes, and prefer excluding a specific host over excluding a substring that happens to appear in other URLs.
A checker that reports hundreds of findings will be ignored. The first run against a mature site almost always produces a long list, most of it accumulated over years and none of it caused by the pull request in front of you. Blocking that pull request on a backlog it did not create is how a gate loses its credibility in a single afternoon. Fix the backlog as its own piece of work — run the checker once against production, file the findings, clear them — and only then turn the gate on. From that point every finding genuinely belongs to the change that introduced it, which is the condition under which people fix them promptly instead of arguing about them.
Redirects deserve a decision, not a default. A link that returns 301 before reaching a 200 is not broken, and most checkers accept it. But a documentation site accumulating redirect hops is slowly getting slower and more fragile, and a chain of three is one removed hostname away from being genuinely broken. Configure the checker to report redirected internal links separately from failing ones, and treat the report as a cleanup queue: update the link to its final destination in the source, and keep the redirect for readers who bookmarked the old URL.
Reading the checker output is faster once you know that each status code points at a different owner. The mapping below covers almost everything a documentation crawl reports:
Treat the leftmost column as the only one that stops a merge. The other three belong in a queue that someone works through deliberately, which keeps the gate fast and its failures unambiguous.
FAQ
Why do links pass locally and fail on the deployed site?
A filesystem check resolves /guides/auth to guides/auth/index.html regardless of server behaviour, while the deployed host may not perform that rewrite. Checking the deployed URL is the only way to exercise the redirect and directory-index rules readers actually hit.
Should a broken external link fail the build?
No. A third-party site being down is not a defect in your pull request, and blocking merges on someone else’s uptime trains people to bypass the gate. Fail on internal links and report external ones, with a scheduled sweep against production to keep them from accumulating.
How do I stop the checker being rate limited?
Lower concurrency, persist the cache between runs so unchanged URLs are not re-fetched, and exclude hosts that aggressively throttle. Most 429 responses come from checking the same handful of domains hundreds of times in one run.
Related
- Docs CI/CD & Preview Deployments — the parent guide and the rest of the gate set.
- Deploying docs previews on pull requests — the preview URL this checks.
- Caching and CDN headers for docs sites — why a preview must not be cached during a check.
- Developer Portal Frameworks & UI Setup — the parent overview and its quality-gate ordering.