diff --git a/.eslintrc.js b/.eslintrc.js
index 580ed71797f97f769a414eac10f498c1c9c32518..87f08fb0cfb5c2f1c0b2d524170f8f8c6e359d19 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -224,5 +224,14 @@ module.exports = {
         'import/extensions': 0,
       },
     },
+    {
+      files: ['tools/docs/test/**/*.mjs'],
+      env: {
+        jest: false,
+      },
+      rules: {
+        '@typescript-eslint/no-floating-promises': 0,
+      },
+    },
   ],
 };
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 6f78625fa2de0b6149762cf2d3f7601a4fa6127d..493af84c3760ce23a93abdf2f59055696e0429fc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -558,6 +558,9 @@ jobs:
       - name: Build
         run: pnpm build:docs
 
+      - name: Test docs
+        run: pnpm test:docs
+
       - name: Upload
         uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
         with:
diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 284fdeb33910afaeb4ed02b74a94657e4a646018..2397c16316a08ede2bcef5f88f3006e9fd51fb38 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -1272,7 +1272,7 @@ It is valid only as a top-level configuration option and not, for example, withi
 
 <!-- prettier-ignore -->
 !!! warning
-    The bot administrator must configure a list of allowed environment names in the [`allowedEnv`](./self-hosted-configuration.md#allowedEnv) config option, before users can use those allowed names in the `env` option.
+    The bot administrator must configure a list of allowed environment names in the [`allowedEnv`](./self-hosted-configuration.md#allowedenv) config option, before users can use those allowed names in the `env` option.
 
 Behavior:
 
@@ -1376,7 +1376,7 @@ Renovate can fetch changelogs when they are hosted on one of these platforms:
 - GitHub (.com and Enterprise Server)
 - GitLab (.com and CE/EE)
 
-If you are running on any platform except `github.com`, you need to [configure a Personal Access Token](./getting-started/running.md#githubcom-token-for-release-notes) to allow Renovate to fetch changelogs notes from `github.com`.
+If you are running on any platform except `github.com`, you need to [configure a Personal Access Token](./getting-started/running.md#githubcom-token-for-changelogs) to allow Renovate to fetch changelogs notes from `github.com`.
 
 <!-- prettier-ignore -->
 !!! note
@@ -1869,7 +1869,7 @@ Enable got [http2](https://github.com/sindresorhus/got/blob/v11.5.2/readme.md#ht
 You can provide a `headers` object that includes fields to be forwarded to the HTTP request headers.
 By default, all headers starting with "X-" are allowed.
 
-A bot administrator may configure an override for [`allowedHeaders`](./self-hosted-configuration.md#allowedHeaders) to configure more permitted headers.
+A bot administrator may configure an override for [`allowedHeaders`](./self-hosted-configuration.md#allowedheaders) to configure more permitted headers.
 
 `headers` value(s) configured in the bot admin `hostRules` (for example in a `config.js` file) are _not_ validated, so it may contain any header regardless of `allowedHeaders`.
 
diff --git a/docs/usage/getting-started/private-packages.md b/docs/usage/getting-started/private-packages.md
index b1689c6ab7892d980207397ddb894d35363070b4..75d400aa17d990e274456a46746ef5ec3023d3da 100644
--- a/docs/usage/getting-started/private-packages.md
+++ b/docs/usage/getting-started/private-packages.md
@@ -616,4 +616,4 @@ For instructions on this, see the above section on encrypting secrets for the Me
 
 ### hostRules configuration using environment variables
 
-Self-hosted users can enable the option [`detectHostRulesFromEnv`](../self-hosted-configuration.md#detectHostRulesFromEnv) to configure the most common types of `hostRules` via environment variables.
+Self-hosted users can enable the option [`detectHostRulesFromEnv`](../self-hosted-configuration.md#detecthostrulesfromenv) to configure the most common types of `hostRules` via environment variables.
diff --git a/docs/usage/key-concepts/changelogs.md b/docs/usage/key-concepts/changelogs.md
index 8af4280e864286e38e9472d14ede1f08a391debf..ddf5d60983e69caa12c5befcf66dc0db729df83e 100644
--- a/docs/usage/key-concepts/changelogs.md
+++ b/docs/usage/key-concepts/changelogs.md
@@ -98,11 +98,11 @@ If your repository uses the monorepo pattern make sure _each_ `package.json` fil
 
 ### maven package maintainers
 
-Read [`maven` datasource, making your changelogs fetchable](https://docs.renovatebot.com/modules/datasource/maven/#making-your-changelogs-fetchable).
+Read [`maven` datasource, making your changelogs fetchable](../modules/datasource/maven/index.md#making-your-changelogs-fetchable).
 
 ### Docker image maintainers
 
-Read the [Docker datasource](https://docs.renovatebot.com/modules/datasource/docker/) docs.
+Read the [Docker datasource](../modules/datasource/docker/index.md) docs.
 
 ### Nuget package maintainers
 
diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md
index 80fed5ff440ddf835a74caa8fe84bc608e811693..ffead1ce362a4e22bf22b8a8ef1ee037d03f9663 100644
--- a/docs/usage/self-hosted-configuration.md
+++ b/docs/usage/self-hosted-configuration.md
@@ -997,7 +997,7 @@ Defines how the report is exposed:
 - `<unset>` If unset, no report will be provided, though the debug logs will still have partial information of the report
 - `logging` The report will be printed as part of the log messages on `INFO` level
 - `file` The report will be written to a path provided by [`reportPath`](#reportpath)
-- `s3` The report is pushed to an S3 bucket defined by [`reportPath`](#reportpath). This option reuses [`RENOVATE_X_S3_ENDPOINT`](./self-hosted-experimental.md#renovatexs3endpoint) and [`RENOVATE_X_S3_PATH_STYLE`](./self-hosted-experimental.md#renovatexs3pathstyle)
+- `s3` The report is pushed to an S3 bucket defined by [`reportPath`](#reportpath). This option reuses [`RENOVATE_X_S3_ENDPOINT`](./self-hosted-experimental.md#renovate_x_s3_endpoint) and [`RENOVATE_X_S3_PATH_STYLE`](./self-hosted-experimental.md#renovate_x_s3_path_style)
 
 ## repositories
 
diff --git a/docs/usage/self-hosted-experimental.md b/docs/usage/self-hosted-experimental.md
index 1dc8fd40f5f6a010da95931a8c6a2bcb5cbba0e9..2e59653983c624f52e5f7a799c1b6168fad09135 100644
--- a/docs/usage/self-hosted-experimental.md
+++ b/docs/usage/self-hosted-experimental.md
@@ -138,7 +138,7 @@ If you use the Mend Renovate Enterprise Edition (Renovate EE) and:
 
 Then you must set this variable at the _server_ and the _workers_.
 
-But if you have specified the token as a [`matchConfidence`](https://docs.renovatebot.com/configuration-options/#matchconfidence) `hostRule`, you only need to set this variable at the _workers_.
+But if you have specified the token as a [`matchConfidence`](configuration-options.md#matchconfidence) `hostRule`, you only need to set this variable at the _workers_.
 
 This feature is in private beta.
 
diff --git a/lib/modules/platform/gerrit/readme.md b/lib/modules/platform/gerrit/readme.md
index b6ff385e9f522db7358be4969f8d15ee8f3b3d65..01a539311cf65dadb456f15247cebaf2c8eb531c 100644
--- a/lib/modules/platform/gerrit/readme.md
+++ b/lib/modules/platform/gerrit/readme.md
@@ -42,7 +42,7 @@ It works similar to the default option `"pr"`.
 
 You can use the `statusCheckNames` configuration to map any of the available branch checks (like `minimumReleaseAge`, `mergeConfidence`, and so on) to a Gerrit label.
 
-For example, if you want to use the [Merge Confidence](https://docs.renovatebot.com/merge-confidence/) feature and map the result of the Merge Confidence check to your Gerrit label "Renovate-Merge-Confidence" you can configure:
+For example, if you want to use the [Merge Confidence](../../../merge-confidence.md) feature and map the result of the Merge Confidence check to your Gerrit label "Renovate-Merge-Confidence" you can configure:
 
 ```json
 {
diff --git a/package.json b/package.json
index a2132adf054cffe9bae1d5837bef579a0c883318..ba2c898b4feb28def43113b9e5f559577e84ebe6 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
     "test-e2e:install": "cd test/e2e && npm install --no-package-lock --prod",
     "test-e2e:run": "cd test/e2e && npm test",
     "test-schema": "run-s create-json-schema",
+    "test:docs": "node --test tools/docs/test/",
     "schedule-test-shards": "SCHEDULE_TEST_SHARDS=true ts-node jest.config.ts",
     "tsc": "tsc",
     "type-check": "run-s 'generate:*' 'tsc --noEmit {@}' --",
@@ -300,6 +301,7 @@
     "@types/semver-utils": "1.1.3",
     "@types/tar": "6.1.13",
     "@types/traverse": "0.6.36",
+    "@types/unist": "2.0.10",
     "@types/url-join": "4.0.3",
     "@types/validate-npm-package-name": "4.0.2",
     "@types/xmldoc": "1.1.9",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 742d8385c9386a44896ab6bafdea096749a270bc..4d9c8a3c06c8584502da77489904686c43791619 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -475,6 +475,9 @@ importers:
       '@types/traverse':
         specifier: 0.6.36
         version: 0.6.36
+      '@types/unist':
+        specifier: 2.0.10
+        version: 2.0.10
       '@types/url-join':
         specifier: 4.0.3
         version: 4.0.3
diff --git a/tools/docs/test/index.test.mjs b/tools/docs/test/index.test.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..983c8790310c4475ff8b962d579b8cc53bb0994d
--- /dev/null
+++ b/tools/docs/test/index.test.mjs
@@ -0,0 +1,75 @@
+import assert from 'node:assert';
+import path from 'node:path';
+import { describe, it } from 'node:test';
+import fs from 'fs-extra';
+import { glob } from 'glob';
+import remark from 'remark';
+import github from 'remark-github';
+
+const root = path.resolve('tmp/docs');
+
+/**
+ * @param {any} node
+ * @param {Set<string>} files
+ * @param {string} file
+ */
+function checkNode(node, files, file) {
+  if (node.type === 'link') {
+    /** @type {import('mdast').Link} */
+    const link = node;
+    assert.ok(
+      !link.url.startsWith('/'),
+      `Link should be external or relative: ${link.url}`,
+    );
+
+    if (link.url.startsWith('.') && !/^https?:\/\//.test(link.url)) {
+      // absolute path
+      const absPath = path.resolve(
+        'tmp/docs',
+        path.dirname(file),
+        link.url.replace(/#.*/, ''),
+      );
+      // relative path
+      const relPath = absPath.substring(root.length + 1);
+
+      assert.ok(
+        files.has(relPath),
+        `File not found: ${link.url} in ${file} -> ${relPath}`,
+      );
+    } else {
+      assert.ok(
+        !link.url.startsWith('https://docs.renovatebot.com/'),
+        `Docs links should be relative: ${link.url}`,
+      );
+    }
+  } else if ('children' in node) {
+    for (const child of node.children) {
+      checkNode(child, files, file);
+    }
+  }
+}
+
+describe('index', async () => {
+  await describe('validate links', async () => {
+    const todo = await glob('**/*.md', { cwd: 'tmp/docs' });
+    const files = new Set(todo);
+
+    // Files from https://github.com/renovatebot/renovatebot.github.io/tree/main/src
+    files.add('index.md');
+
+    let c = 0;
+
+    for (const file of todo) {
+      c++;
+
+      await it(`${file}`, async () => {
+        const node = remark()
+          .use(github)
+          .parse(await fs.readFile(`tmp/docs/${file}`, 'utf8'));
+        checkNode(node, files, file);
+      });
+    }
+
+    assert.ok(c > 0, 'Should find at least one file');
+  });
+});