diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml
index f2dbb718415cd77bf461ac7d77a98cdc4f9ecce0..6400bfdd07cac43777e781c7b0ee80886282abb5 100644
--- a/.github/workflows/danger.yml
+++ b/.github/workflows/danger.yml
@@ -19,7 +19,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: Danger
         run: npm run danger ci
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index e966903c5fc3d7dc4fd3ed3b72fb6047c4a5afef..7077b5c18139d546445320fcef78194e085a64ec 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -19,7 +19,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: Build
         run: npm run build-docs
diff --git a/.github/workflows/test-bug-run-badge.yml b/.github/workflows/test-bug-run-badge.yml
index fe07e95f55a3dfcebaa8ab238f04254015b86025..cb1e60dce8b3864177310a33d4d3b8999b701872 100644
--- a/.github/workflows/test-bug-run-badge.yml
+++ b/.github/workflows/test-bug-run-badge.yml
@@ -38,7 +38,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
           cypress: false
 
       - name: Output debug info
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index dcc6b9506fc4bcfe246fc5fa12a64bd49ee85bd9..746333abd3d4d6dada6368f3541b2330f47ef0de 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -26,7 +26,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
           cypress: true
 
       - name: Run tests
diff --git a/.github/workflows/test-integration-18.yml b/.github/workflows/test-integration-20.yml
similarity index 94%
rename from .github/workflows/test-integration-18.yml
rename to .github/workflows/test-integration-20.yml
index c1e7a5a774bd2f97cffb38290d60e0b7d22015e5..a0c5aaefbee68ee329feef4c4f025151e639338d 100644
--- a/.github/workflows/test-integration-18.yml
+++ b/.github/workflows/test-integration-20.yml
@@ -1,4 +1,4 @@
-name: Integration@node 18
+name: Integration@node 20
 on:
   pull_request:
     types: [opened, reopened, synchronize]
@@ -8,7 +8,7 @@ on:
       - 'dependabot/**'
 
 jobs:
-  test-integration-18:
+  test-integration-20:
     runs-on: ubuntu-latest
     env:
       PAT_EXISTS: ${{ secrets.GH_PAT != '' }}
@@ -35,7 +35,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 18
+          node-version: 20
         env:
           NPM_CONFIG_ENGINE_STRICT: 'false'
 
diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml
index b86b9a43462faab374af3c25a1503ceddc4a1098..5c533ba225030606e73d22ea989545f02bbea79f 100644
--- a/.github/workflows/test-integration.yml
+++ b/.github/workflows/test-integration.yml
@@ -35,7 +35,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: Integration Tests (with PAT)
         if: ${{ env.PAT_EXISTS == 'true' }}
diff --git a/.github/workflows/test-lint.yml b/.github/workflows/test-lint.yml
index ef5e177d38a8d51c1272a1a8408fa09ea5776071..a5a5f6b1f379e327f50a3285179ab045c8268c7b 100644
--- a/.github/workflows/test-lint.yml
+++ b/.github/workflows/test-lint.yml
@@ -17,7 +17,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: ESLint
         if: always()
diff --git a/.github/workflows/test-main-18.yml b/.github/workflows/test-main-20.yml
similarity index 87%
rename from .github/workflows/test-main-18.yml
rename to .github/workflows/test-main-20.yml
index 247cbb68ebc42d8180eddeadaaad196808dcfb76..bba911cb3bc838cd85a94f9a4e991e80f287205d 100644
--- a/.github/workflows/test-main-18.yml
+++ b/.github/workflows/test-main-20.yml
@@ -1,4 +1,4 @@
-name: Main@node 18
+name: Main@node 20
 on:
   pull_request:
     types: [opened, reopened, synchronize]
@@ -8,7 +8,7 @@ on:
       - 'dependabot/**'
 
 jobs:
-  test-main-18:
+  test-main-20:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
@@ -17,7 +17,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 18
+          node-version: 20
         env:
           NPM_CONFIG_ENGINE_STRICT: 'false'
 
diff --git a/.github/workflows/test-main.yml b/.github/workflows/test-main.yml
index 6eff088bd90c4f8a8e06f4a1580d306a3aec7945..b91cd7cb6af9949b997cad3212ad700a83831d50 100644
--- a/.github/workflows/test-main.yml
+++ b/.github/workflows/test-main.yml
@@ -22,7 +22,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: Core tests
         uses: ./.github/actions/core-tests
diff --git a/.github/workflows/test-package-lib.yml b/.github/workflows/test-package-lib.yml
index 30267bb8a43dcaf0b5763147e84910a3748b8e7f..b806990056cccb2785a2c2f6a956708caf1a9adf 100644
--- a/.github/workflows/test-package-lib.yml
+++ b/.github/workflows/test-package-lib.yml
@@ -14,9 +14,9 @@ jobs:
       matrix:
         include:
           - node: '16'
-            engine-strict: 'true'
-          - node: '18'
             engine-strict: 'false'
+          - node: '18'
+            engine-strict: 'true'
           - node: '20'
             engine-strict: 'false'
     steps:
diff --git a/.github/workflows/test-services-18.yml b/.github/workflows/test-services-20.yml
similarity index 95%
rename from .github/workflows/test-services-18.yml
rename to .github/workflows/test-services-20.yml
index a690a2860acf2b761b62ab31d4a134d12f8af579..29749693728710d0d225ba2b66d482c1ee2d6836 100644
--- a/.github/workflows/test-services-18.yml
+++ b/.github/workflows/test-services-20.yml
@@ -1,4 +1,4 @@
-name: Services@node 18
+name: Services@node 20
 on:
   pull_request:
     types: [opened, edited, reopened, synchronize]
@@ -7,7 +7,7 @@ on:
       - 'gh-readonly-queue/**'
 
 jobs:
-  test-services-18:
+  test-services-20:
     runs-on: ubuntu-latest
 
     steps:
@@ -17,7 +17,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 18
+          node-version: 20
         env:
           NPM_CONFIG_ENGINE_STRICT: 'false'
 
diff --git a/.github/workflows/test-services.yml b/.github/workflows/test-services.yml
index 9d269b41c1e37578958cfcc0320314fa15e76f02..0e4a614d48d1be2e05f9534bc8e9ac07b04db771 100644
--- a/.github/workflows/test-services.yml
+++ b/.github/workflows/test-services.yml
@@ -17,7 +17,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: Service tests (triggered from local branch)
         if: github.event.pull_request.head.repo.full_name == github.repository
diff --git a/.github/workflows/update-github-api.yml b/.github/workflows/update-github-api.yml
index 191aaa98b4ba3e699d93713e0de533d56b73dc2d..027613cb44d61a7b19a2edcf871e58a44e89ede1 100644
--- a/.github/workflows/update-github-api.yml
+++ b/.github/workflows/update-github-api.yml
@@ -19,7 +19,7 @@ jobs:
       - name: Setup
         uses: ./.github/actions/setup
         with:
-          node-version: 16
+          node-version: 18
 
       - name: Check for new GitHub API version
         run: node scripts/update-github-api.js
diff --git a/Dockerfile b/Dockerfile
index 88d898a16d3d5a97099f01af735e05a77b6ee977..123d013e4f8fba5b0fc2c3425a4ad302df0ccead 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:16-alpine AS Builder
+FROM node:18-alpine AS Builder
 
 RUN mkdir -p /usr/src/app
 RUN mkdir /usr/src/app/private
@@ -19,7 +19,7 @@ RUN npm prune --production
 RUN npm cache clean --force
 
 # Use multi-stage build to reduce size
-FROM node:16-alpine
+FROM node:18-alpine
 
 ARG version=dev
 ENV DOCKER_SHIELDS_VERSION=$version
diff --git a/README.md b/README.md
index 7e9d549096f0088fbdfce462187079ecfe228e7c..41a158b7433bfe4fe4f037ba20ba4b42b2999928 100644
--- a/README.md
+++ b/README.md
@@ -98,8 +98,8 @@ You can read a [tutorial on how to add a badge][tutorial].
 
 ## Development
 
-1. Install Node 16 or later. You can use the [package manager][] of your choice.
-   Tests need to pass in Node 16 and 17.
+1. Install Node 18 or later. You can use the [package manager][] of your choice.
+   Tests need to pass in Node 18 and 20.
 2. Clone this repository.
 3. Run `npm ci` to install the dependencies.
 4. Run `npm start` to start the badge server and the frontend dev server.
diff --git a/doc/TUTORIAL.md b/doc/TUTORIAL.md
index 82856fa68d0e225015832e9afc484add2f566c6c..2ab20a3d44f7f877f82ed03857cfeec3267b8f60 100644
--- a/doc/TUTORIAL.md
+++ b/doc/TUTORIAL.md
@@ -25,7 +25,7 @@ and learn about the [GitHub workflow](http://try.github.io/).
 
 #### Node, NPM
 
-Node >=16 and NPM 9.x is required. If you don't already have them,
+Node >=18 and NPM 9.x is required. If you don't already have them,
 install node and npm: https://nodejs.org/en/download/
 
 ### Setup a dev install
diff --git a/doc/self-hosting.md b/doc/self-hosting.md
index 63d5acf1d31df0ebee9976fc4cfeac05c30e2e38..5d83f42e94d5a6613d55fbc9bb5642d5945a4972 100644
--- a/doc/self-hosting.md
+++ b/doc/self-hosting.md
@@ -4,13 +4,13 @@ This document describes how to host your own shields server either from source o
 
 ## Installing from Source
 
-You will need Node 16 or later, which you can install using a
+You will need Node 18 or later, which you can install using a
 [package manager][].
 
 On Ubuntu / Debian:
 
 ```sh
-curl -sL https://deb.nodesource.com/setup_16.x | sudo -E bash -; sudo apt-get install -y nodejs
+curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash -; sudo apt-get install -y nodejs
 ```
 
 ```sh
diff --git a/package-lock.json b/package-lock.json
index 0a47c030c8d1c405a72500cfa9f7d883e52ae9f1..02bb904091fd89b507c0a8812d4e3a07467a2f19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -125,7 +125,7 @@
         "url": "^0.11.1"
       },
       "engines": {
-        "node": "^16.13.0",
+        "node": "^18.16.0",
         "npm": "^9.0.0"
       }
     },
diff --git a/package.json b/package.json
index 65221c53490eef5d15e76e3592104c81d5b055e6..3cc5e9ac61adcdff6ea54cb4661ef4a351565d68 100644
--- a/package.json
+++ b/package.json
@@ -96,7 +96,7 @@
     "pretest": "cross-env BASE_URL=http://localhost:8080 run-s --silent defs",
     "test": "run-s --silent --continue-on-error lint test:package test:core test:entrypoint check-types:package prettier:check",
     "check-types:package": "tsd badge-maker",
-    "depcheck": "check-node-version --node \">= 16.0\"",
+    "depcheck": "check-node-version --node \">= 18.0\"",
     "prebuild": "run-s --silent depcheck",
     "defs": "node scripts/export-openapi-cli.js",
     "build": "rimraf public && run-s defs docusaurus:build",
@@ -212,7 +212,7 @@
     "url": "^0.11.1"
   },
   "engines": {
-    "node": "^16.13.0",
+    "node": "^18.16.0",
     "npm": "^9.0.0"
   },
   "type": "module",