Broken links are boring until they cost you trust.

For a static site, documentation portal, personal blog, or self-hosted dashboard collection, they are also easy to miss. You move a page, rename a post, delete an image, or paste a link that worked once and quietly stops working later.

LinkChecker is one of the old, practical tools for catching that class of problem.

What is LinkChecker?

LinkChecker is a GPL Python website validator for checking links in documents or full websites.

I inspected:

repo: https://github.com/linkchecker/linkchecker
commit: 9b178bdafba75334626c0c8c32a5f544cdbe7ffd
version: v10.6.0-53-g9b178bda
latest release in changelog: 10.6.0, released 2025-07-28
license: GPL-2.0-or-later

The project supports Python >=3.10 and ships a linkchecker CLI entrypoint.

What It Checks

LinkChecker can check:

  • local HTML files;
  • static-site output folders;
  • public websites;
  • HTTP and HTTPS URLs;
  • FTP links;
  • mailto: links;
  • local file links;
  • sitemap URLs;
  • anchors inside HTML pages;
  • selected plugin-based checks.

It can crawl recursively and run with multiple threads. It also respects robots.txt by default.

Output Formats

The reporting side is a big reason to use LinkChecker instead of a quick custom script.

It can output:

  • normal text;
  • colored text;
  • HTML;
  • SQL;
  • CSV;
  • XML;
  • sitemap XML;
  • DOT;
  • GML;
  • failures-only output.

For CI, the most useful pattern is usually:

linkchecker --output=none --file-output=html/utf-8/reports/report.html ./public/

That gives you a persistent report without flooding the job log.

Architecture

The codebase is a Python CLI application.

The rough flow is:

CLI arguments / config file
  -> configuration object
  -> URL aggregation queue
  -> checker workers
  -> protocol-specific checkers
  -> parsers and plugins
  -> result cache
  -> output loggers

Important pieces:

  • linkcheck/command/linkchecker.py: CLI entrypoint, setup, and exit-code handling.
  • linkcheck/checker/: protocol-specific URL checkers.
  • linkcheck/htmlutil/: HTML parsing and link extraction.
  • linkcheck/parser/: sitemap parsing.
  • linkcheck/plugins/: extra checks such as anchors, Markdown, PDF/Word parsers, HTTP headers, and SSL certificate checks.
  • linkcheck/logger/: report formats.

Dependencies are intentionally modest:

  • requests;
  • dnspython;
  • beautifulsoup4.

Using LinkChecker with Docker

There was already a Home-Lab LinkChecker folder, but it needed cleanup.

I updated it to:

  • use the current GitHub Container Registry image;
  • pin the image to ghcr.io/linkchecker/linkchecker:10.6.0;
  • remove stale wummel.github.io documentation links;
  • remove hardcoded personal paths;
  • treat the container as a one-shot CLI runner, not a daemon;
  • ignore local smoke-test reports/ and fixtures/.

The reusable Home-Lab compose file is here:

The site includes the same Compose file from the local snippets folder:

assets/snippets/linkchecker/docker-compose.yml
services:
  linkchecker:
    image: ghcr.io/linkchecker/linkchecker:10.6.0
    container_name: linkchecker
    user: "${UID:-1000}:${GID:-1000}"
    working_dir: /work
    volumes:
      - ./:/work
    stdin_open: true
    tty: true
    restart: "no"

Check the installed version:

docker compose run --rm linkchecker --version

Check a website:

docker compose run --rm linkchecker --verbose https://example.com

Write an HTML report:

mkdir -p reports

docker compose run --rm linkchecker \
  --no-status \
  --output=none \
  --file-output=html/utf-8/reports/example.html \
  https://example.com

Check a local static build:

docker compose run --rm linkchecker --verbose ./public/index.html

Useful Options

Start with these:

Option Purpose
--recursion-level=2 Limit crawl depth before pointing it at large sites.
--check-extern Check external links too.
--ignore-url=REGEX Ignore matching URLs after syntax checks.
--no-follow-url=REGEX Check matching URLs but do not recurse into them.
--threads=5 Reduce concurrency.
--timeout=30 Avoid waiting a full minute on slow links.
--output=none Quiet console result output.
--file-output=html/utf-8/reports/report.html Write a report file.

For a big site, do not start with infinite recursion and external checks.

Use a bounded first pass:

docker compose run --rm linkchecker \
  --recursion-level=2 \
  --timeout=30 \
  https://example.com

Then widen the scan once the configuration is sane.

CI/CD Usage

For a static site generator, the natural place to run LinkChecker is after the build step.

Example shape:

hugo --minify
linkchecker --output=none --file-output=html/utf-8/linkchecker.html ./public/

In CI, decide whether warnings should fail the build.

LinkChecker exits:

  • 0 when there are no errors or printed warnings;
  • 1 when errors or warnings are found;
  • 2 on internal errors.

For early adoption, I usually prefer collecting the report first without failing deployment. Once the ignore rules are tuned, make it stricter.

Field Test

I smoke-tested the Home-Lab runner with the pinned image:

docker compose run --rm linkchecker --version

It returned:

LinkChecker 10.6.0 released 2025-07-28

Then I checked a small local fixture with one intentionally broken link:

docker compose run --rm linkchecker \
  --no-status \
  --output=text/utf-8 \
  --file-output=html/utf-8/reports/fixture.html \
  fixtures/index.html

That produced the expected result:

1 error found
exit_status=1

It also wrote:

reports/fixture.html

Finally, I checked https://example.com with recursion disabled:

docker compose run --rm linkchecker \
  --no-status \
  --recursion-level=0 \
  --timeout=20 \
  https://example.com

That returned:

0 errors found
exit_status=0

Is There a Web UI?

Sort of, but that is not the main deployment path.

The repo includes WSGI/web-interface examples. The upstream install notes warn that allowing a web script to trigger checks can consume CPU and memory, especially if many users can start large crawls.

For self-hosting, I would treat LinkChecker as a CLI and CI tool first. If you expose any web wrapper, restrict it to trusted users.

How It Compares

LinkChecker is good when you want a deterministic CLI, Docker runner, CI step, and report files.

Screaming Frog and similar desktop crawlers are more interactive and SEO-heavy, but not as simple to automate in a small self-hosted pipeline.

Playwright/Cypress tests are better for checking JavaScript-heavy flows, but they are not a general recursive link crawler by default.

Custom scripts are fine for a short URL list, but they usually miss anchors, sitemap parsing, robots behavior, output formats, recursion controls, and edge cases.

FAQ

Is LinkChecker self-hosted?

It is self-run rather than self-hosted as a web app.

You run the CLI locally, in Docker, or in CI. There is no database-backed dashboard to keep running.

Does LinkChecker need Docker?

No.

You can install it with pipx install linkchecker. Docker is useful when you want a repeatable runner without installing Python packages on the host.

How do I apply LinkChecker to my own website?

Start with one bounded scan and save the report:

mkdir -p reports

docker compose run --rm linkchecker \
  --no-status \
  --recursion-level=2 \
  --timeout=30 \
  --threads=5 \
  --output=text/utf-8 \
  --file-output=html/utf-8/reports/site.html \
  https://example.com

Replace https://example.com with your site URL. Once the first report is readable, increase recursion, point it at your sitemap, or add --check-extern for external links.

For a static site, the stricter option is to check the generated output before deployment:

hugo --minify
linkchecker --output=text/utf-8 ./public/

That catches broken internal links and missing assets before they become public.

Should I check the live URL, sitemap, or local build?

Use all three for different jobs.

Target Best For
./public/ Pre-deploy checks for generated static output.
https://example.com/sitemap.xml Broad crawl of published pages without guessing entry points.
https://example.com User-path crawl from the homepage and visible navigation.

For Foss Engineer, the full-site pass used the sitemap because it naturally covers posts, docs, and app pages that may not all be linked from the homepage.

How do I understand the output?

A LinkChecker result is usually easiest to read from the HTML or CSV report.

The important fields are:

Field Meaning
URL The link or asset LinkChecker tried to fetch.
Parent URL The page where that link was found. This is usually where you fix the source.
Name The link text or image alt text, when available.
Result The outcome: valid, warning, redirect, missing, timeout, refused, and similar states.
Line / Column Source position when LinkChecker can map the link back to HTML.
Real URL The final URL after redirects. Useful when old paths still work but should be updated.

Treat Parent URL as your starting point. The broken URL tells you what failed; the parent tells you which page produced it.

What do warnings and errors mean?

Errors are broken behavior: 404, DNS failures, refused connections, malformed URLs, missing local files, or anchor targets that do not exist.

Warnings are not always urgent. A 301 or 308 redirect can be acceptable, but many redirect warnings usually mean old URLs, old category paths, or links that should be updated to their final destination.

The exit code matters in automation:

Exit Code Meaning
0 No errors or printed warnings.
1 LinkChecker found errors or warnings.
2 LinkChecker itself failed internally.

When adopting it on an existing site, collect HTML/CSV reports first. After the noisy legacy redirects are cleaned up or ignored intentionally, make the CI job fail on new issues.

How do I keep the output useful in CI?

Write reports to files and keep the console quiet:

linkchecker \
  --no-status \
  --output=none \
  --file-output=html/utf-8/reports/linkchecker.html \
  --file-output=csv/utf-8/reports/linkchecker.csv \
  ./public/

Upload reports/linkchecker.html as a CI artifact. Use the CSV report when you want to sort failures by parent page, status, or URL in a spreadsheet.

It can, but you need to choose that deliberately with --check-extern.

For first runs, keep scans limited. External checks can be slow, noisy, and sensitive to rate limits.

Can LinkChecker check local static-site output?

Yes.

That is one of the best use cases:

linkchecker ./public/

or through Docker with your site folder mounted into the container.

Can I use LinkChecker in GitHub Actions?

Yes.

Install it with pipx, your package manager, or run the container image after building the site. Then upload the HTML report as an artifact.

Does it execute JavaScript?

No, not like a browser automation tool.

Use it for document and website link validation. For JavaScript-rendered routes, pair it with a browser-based test runner or check the generated/static output that users actually receive.