# markdown-link-check turns a dead link into a failing build step

> markdown-link-check extracts every link from a markdown document and reports each one as alive, ignored or dead, with options for headers, timeouts, retry on 429 and per-URL rewriting. It ships as an npm module, a CLI, a Docker image and a pre-commit hook, and its value is that the check can be a gate rather than a report.

**tcort/markdown-link-check** — checks all of the hyperlinks in a markdown text to determine if they are alive or dead

- Repository: https://github.com/tcort/markdown-link-check
- Stars: 721 · Forks: 141
- Language: JavaScript
- License: ISC
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/tcort-markdown-link-check

## Extraction and checking are separate steps

markdown-link-check extracts links from markdown texts and checks whether each link is alive, meaning it returns 200 OK, or dead. Mailto links are validated as well.

The dependency list shows the split. The extraction side is markdown-link-extractor at version 4.0.4, which parses the markdown text and hands back the links it found. The checking side is the link-check package at 5.6.0, with needle as the HTTP client underneath it, which performs the requests and reports the responses.

Around that sit the supporting pieces: chalk for coloured terminal output, commander for the command line parsing, async for the concurrent checking, progress for the progress bar, proxy-agent for proxy support, and xmlbuilder2 for generating output.

That architecture has a practical consequence. The checker is not a markdown tool, it is an HTTP client with a markdown front end, and it works on the raw text of the document rather than on rendered output. A link that only appears after rendering, or a URL assembled by a template engine, is invisible to it. It is also why mailto links are treated separately, since there is no HTTP request to make for them.

The result objects are deliberately plain. Each entry carries the link as it was given, a status string that is one of alive, ignored or dead, the HTTP status code, which is set to 0 when no status came back at all, for example when the server is down, and any connection error.

## Installing as a dependency or as a global command

There are two installation shapes, and which one you pick decides how the tool reaches your files.

To add the module to your project, run the save-dev install.

```shell
npm install --save-dev markdown-link-check
```

To install the command line tool globally, run the global install.

```shell
npm install -g markdown-link-check
```

The package metadata explains what each one gives you. The bin entry maps the command name to the markdown-link-check script, and the main entry is index.js, which is the module API. The published files list contains only the markdown-link-check entry, so the CLI is the shipped artefact and the module is consumed from the package root.

The command line tool takes one optional argument, the file name or an http/https URL, and if it is not supplied the tool reads from standard input. That last point is what makes the tool composable in a pipeline, since it can sit in the middle of a shell pipeline without needing a file on disk.

Checking a document hosted on the web and checking a local file are the same command with a different argument.

```shell
markdown-link-check https://github.com/tcort/markdown-link-check/blob/master/README.md
```

```shell
markdown-link-check ./README.md
```

## The Docker image runs the check against a read-only mount

Docker images are built with each release, and the stable tag points at the current stable release. The documented invocation mounts the current directory as a read-only volume and passes the file path inside the container.

```shell
docker run -v .:/tmp:ro --rm -i ghcr.io/tcort/markdown-link-check:stable /tmp/README.md
```

The read-only mount is the detail worth copying. The checker only needs to read the document, and mounting it read-only means a container that misbehaves cannot modify the working tree, which matters in a CI runner where the checkout is usually trusted.

The Dockerfile explains how the image is put together. It starts from node:alpine, copies the package files, runs npm install, copies the source, and symlinks the CLI into the path.

```dockerfile
FROM node:alpine
```

```dockerfile
RUN ln -s /src/markdown-link-check /usr/local/bin/markdown-link-check
# hadolint ignore=DL3059
RUN npm test
ENTRYPOINT [ "markdown-link-check" ]
```

The test run is part of the image build, with a hadolint suppression for the package install on the line above it, so a broken build never produces a published image. The entrypoint is the CLI itself, which is why the run command only needs to name the file to check.

If you want a specific version rather than the current stable one, the images are also tagged with semantic versions, for example 3, 3.8 or 3.8.3, so pinning the tool in CI does not require building it.

## Pre-commit, GitLab and a separate GitHub action

The project documents three ways to run it in automation, and they differ in how much they cost you per commit.

The pre-commit hook is the cheapest and the most annoying, because it runs before every commit and checks only what is about to be committed.

```
- repo: https://github.com/tcort/markdown-link-check
  rev: ...
  hooks:
    - id: markdown-link-check
      args: [-q]
```

The -q argument quiets the output, which matters when the hook fires on every save. The rev field is left as a placeholder in the documentation, so you have to fill in a commit or tag yourself.

The GitLab pipeline example uses the container image and passes a directory rather than a file.

```yaml
linkchecker:
  stage: test
  image:
    name: ghcr.io/tcort/markdown-link-check:3.11.2
    entrypoint: ["/bin/sh", "-c"]
  script:
    - markdown-link-check ./docs
  rules:
    - changes:
      - "**/*.md"
```

Two details are worth copying. The entrypoint has to be overridden to a shell, because the image entrypoint is the CLI itself and the script needs a shell to run. And the rules block restricts the job to changes under markdown files, so a pipeline that touches only code does not pay for a network check.

For GitHub Actions the project does not include the action in this repository. It points to a separate repository, github-action-markdown-link-check, which wraps the same tool. The tool is also listed as a linter in Mega-Linter, the linters aggregator.

## The options decide what counts as alive

The API is markdownLinkCheck(markdown, [opts,] callback), and the options object is where the real configuration lives.

The timeout defaults to 10s and accepts the ms format, so 2000ms, 20s or 1m all work. A shorter timeout makes a pipeline faster and a slow site look broken, which is the usual trade.

aliveStatusCodes is the option that most people need. It is a list of HTTP codes to consider alive, for example [200, 206], which matters for servers that answer a redirect chain with a partial content response or with a 3xx that the client library does not follow in the way you expect.

The ignore options come in two shapes. ignorePatterns is an array of objects holding regular expressions a link is checked against and skipped in case of a match, for example [{ pattern: /foo/ }]. replacementPatterns rewrites the link before it is fetched, which is what makes a repository-relative link work: the special replacement {{BASEURL}} links to the base folder taken from projectBaseUrl, so a link that points to / in the repository becomes an absolute URL. You can add global: true to use a global regular expression and replace all instances.

httpHeaders applies URL specific headers, which is the mechanism for checking a link that sits behind basic auth. ignoreDisable turns off the disable comments. retryOn429 makes the tool retry a request when the response is HTTP 429, after the duration in the retry-after header, with retryCount defaulting to 2 and fallbackRetryDelay, also in ms format, defaulting to 60s for when no retry-after header comes back or its value is invalid.

That last group is the difference between a flaky check and a usable one, because rate limiting is the normal response of a public host to a link checker.

## Disable comments for the parts you do not want checked

Not every link in a document should be checked, and the tool provides HTML comments to exclude regions.

The `<!-- markdown-link-check-disable -->` comment disables markdown link check, and `<!-- markdown-link-check-enable -->` reenables it. The `<!-- markdown-link-check-disable-next-line -->` comment disables the check for the next line, and `<!-- markdown-link-check-disable-line -->` disables it for the line the comment sits on.

The first pair brackets a region, disabling the check and then reenabling it. The next-line variant skips only the following line, and the line variant skips the line the comment sits on.

The useful pattern is the block form around generated content. A README that embeds a table of API endpoints produced by a generator, or a document that quotes an old issue thread, is a good candidate for the disable and enable pair, while a single link to a page that requires a login is a candidate for the line variant.

The option is off by default in the sense that ignoreDisable, when set to true, disables the comments, meaning the comments stop having an effect. That inversion is worth remembering, because the default behaviour is that a comment in the document is honoured.

## The callback gives you status, code and error per link

The module form is the same tool with a callback, and it is the part you would use to build a custom report or to feed the results into another system.

```js
'use strict';

var markdownLinkCheck = require('markdown-link-check');

markdownLinkCheck('[example](http://example.com)', function (err, results) {
    if (err) {
        console.error('Error', err);
        return;
    }
    results.forEach(function (result) {
        console.log('%s is %s', result.link, result.status);
    });
});
```

The callback receives an Error when the operation cannot be completed, otherwise null, and an array of results. The optional options argument sits between the markdown string and the callback, so you can call it with two arguments or three.

The second example shows the options form, adding URL specific headers with a basic auth value for one URL.

```js
markdownLinkCheck('[example](http://example.com)', { httpHeaders: [{ urls: ['http://example.com'], headers: { 'Authorization': 'Basic Zm9vOmJhcg==' }}] }, function (err, results) {
```

The status vocabulary is three values, and the third one is easy to forget. A link can be ignored, not because the check failed but because it matched an ignore pattern, and if you are counting failures you have to separate ignored from alive rather than treating anything that is not dead as a pass.

The reporters option extends the output. It takes an array of reporter functions, and if it is not specified the default output is generated, which is described as useful when using the API programmatically. The built-in reporters can be imported from the command line tool.

## ISC licensed, small dependency set, regular releases

The licence is ISC, with the terms in the LICENSE.md file at the root of the repository, and the package metadata records the same identifier. The author is Thomas Cort. For a tool that is likely to sit in a documentation pipeline rather than in a shipped product, an ISC licence is about as unobtrusive as it gets.

The dependency set is modest and modern. The runtime dependencies are async, chalk, commander, link-check, markdown-link-extractor, needle, progress, proxy-agent and xmlbuilder2, and the development dependencies are eslint with the flat config eslint.config.mjs, mocha, expect.js and express. The test command is mocha with the spec reporter, and a pretest hook runs eslint first, so a lint failure stops the test run.

The project is small in the way a good CLI is small. The top level holds index.js for the module, the markdown-link-check script for the command, a Dockerfile, a .pre-commit-hooks.yaml for the hook integration, a CHANGELOG.md, a CONTRIBUTING.md and a test directory.

Releases have been steady. Version 3.15.0 was published on 2026-07-28, 3.14.2 on 2025-11-19 and 3.14.1 on 2025-10-11, and the last push was on 2026-07-28, the same day as the newest tag. The repository is not archived.

That regularity matters more than the version number for a checker, because a checker that stops being updated starts reporting false failures as websites change their response codes, and a project still tagging releases is a project still tracking that.

## Conclusion

Use markdown-link-check if your documentation lives in markdown and broken references are worth failing a build over, and set the timeout, aliveStatusCodes and ignorePatterns before you enable it on a large repository, because the defaults are tuned for public pages rather than internal ones. Do not reach for it if you need to check rendered HTML rather than markdown source. Before wiring it into CI, try it once against a single file with the command line tool, then confirm the exit code behaviour in your pipeline, since the tool treats a dead link as an error.

## FAQ

### How do I install markdown-link-check?

Run npm install --save-dev markdown-link-check to add the module to a project, or npm install -g markdown-link-check to install the command line tool globally. Docker images are also published with each release, using the stable tag for the current release.

### What statuses can markdown-link-check report for a link?

Each result has a status of alive, ignored or dead, along with the HTTP status code, which is 0 when no status was returned, for example when the server is down, and any connection error that occurred.

### How do I stop markdown-link-check from checking part of a document?

Use the HTML comments. The disable and enable pair brackets a region, the disable-next-line comment skips the following line, and the disable-line comment skips the line it sits on. The ignoreDisable option turns these comments off.

### How do I make markdown-link-check accept a link that does not return 200?

Use the aliveStatusCodes option with a list of HTTP codes to consider alive, for example [200, 206]. The timeout option, which defaults to 10s, and the retry options retryOn429, retryCount and fallbackRetryDelay control how long and how often a slow or rate-limited link is retried.

### Can markdown-link-check handle links that are relative to the repository?

Yes. The replacementPatterns option rewrites a link before it is checked, and the special replacement {{BASEURL}} uses the URL given in projectBaseUrl as the base folder, so a link pointing at the repository root becomes an absolute URL.

### How do I run markdown-link-check in GitLab CI?

Use the published image with the entrypoint overridden to a shell, for example name: ghcr.io/tcort/markdown-link-check:3.11.2 with entrypoint: ["/bin/sh", "-c"], and run markdown-link-check ./docs in the script block. A rules block restricting the job to changes under **/*.md keeps the job from running on unrelated commits.

## Sources

- [Issues](https://github.com/tcort/markdown-link-check/issues)
- [License: ISC](https://github.com/tcort/markdown-link-check/blob/master/LICENSE)
- [README](https://github.com/tcort/markdown-link-check/blob/master/README.md)
- [Releases](https://github.com/tcort/markdown-link-check/releases)
- [tcort/markdown-link-check on GitHub](https://github.com/tcort/markdown-link-check)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tcort-markdown-link-check
