diff --git a/README.md b/README.md
index aa91aa0..ccf2e8b 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ The user is `root` with password `admin1234`. The runner is registered with:
 ```
 cd setup-forgejo
 docker exec --user 1000 forgejo forgejo actions generate-runner-token > forgejo-runner-token
-../runner/forgejo-runner register --no-interactive --instance "$(cat forgejo-url)" --name runner --token $(cat forgejo-runner-token) --labels docker:docker://node:16-bullseye,self-hosted:host://-self-hosted,lxc:lxc://debian:bullseye
+../runner/forgejo-runner register --no-interactive --instance "$(cat forgejo-url)" --name runner --token $(cat forgejo-runner-token) --labels docker:docker://node:20-bullseye,self-hosted:host://-self-hosted,lxc:lxc://debian:bullseye
 ```
 
 And launched with:
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index cbf737b..45512ee 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -7,6 +7,7 @@
 * [Fix the kubernetes dind example](https://code.forgejo.org/forgejo/runner/pulls/169).
 * [Rewrite ::group:: and ::endgroup:: commands like github](https://code.forgejo.org/forgejo/runner/pulls/183).
 * [Added opencontainers labels to the image](https://code.forgejo.org/forgejo/runner/pulls/195)
+* [Upgrade the default container to node:20](https://code.forgejo.org/forgejo/runner/pulls/203)
 
 ## 3.4.1
 
diff --git a/internal/app/cmd/exec.go b/internal/app/cmd/exec.go
index 30a8c76..3e111fe 100644
--- a/internal/app/cmd/exec.go
+++ b/internal/app/cmd/exec.go
@@ -486,7 +486,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
 	execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
 	execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
 	execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
-	execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "node:16-bullseye", "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
+	execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "node:20-bullseye", "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
 	execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
 	execCmd.PersistentFlags().BoolVarP(&execArg.enableIPv6, "enable-ipv6", "6", false, "Create network with IPv6 enabled.")
 	execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go
index d5ee299..5c6378c 100644
--- a/internal/app/cmd/register.go
+++ b/internal/app/cmd/register.go
@@ -91,7 +91,7 @@ const (
 )
 
 var defaultLabels = []string{
-	"docker:docker://node:16-bullseye",
+	"docker:docker://node:20-bullseye",
 }
 
 type registerInputs struct {
@@ -176,7 +176,7 @@ func (r *registerInputs) assignToNext(stage registerStage, value string, cfg *co
 		}
 
 		if validateLabels(r.Labels) != nil {
-			log.Infoln("Invalid labels, please input again, leave blank to use the default labels (for example, ubuntu-20.04:docker://node:16-bullseye,ubuntu-18.04:docker://node:16-buster,linux_arm:host)")
+			log.Infoln("Invalid labels, please input again, leave blank to use the default labels (for example, ubuntu-20.04:docker://node:20-bookworm,ubuntu-18.04:docker://node:20-bookworm)")
 			return StageInputLabels
 		}
 		return StageWaitingForRegistration
@@ -240,7 +240,7 @@ func printStageHelp(stage registerStage) {
 		hostname, _ := os.Hostname()
 		log.Infof("Enter the runner name (if set empty, use hostname: %s):\n", hostname)
 	case StageInputLabels:
-		log.Infoln("Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-20.04:docker://node:16-bullseye,ubuntu-18.04:docker://node:16-buster,linux_arm:host):")
+		log.Infoln("Enter the runner labels, leave blank to use the default labels (comma-separated, for example, ubuntu-20.04:docker://node:20-bookworm,ubuntu-18.04:docker://node:20-bookworm):")
 	case StageWaitingForRegistration:
 		log.Infoln("Waiting for registration...")
 	}
diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml
index 54e49c0..bc26489 100644
--- a/internal/pkg/config/config.example.yaml
+++ b/internal/pkg/config/config.example.yaml
@@ -30,7 +30,7 @@ runner:
   # The interval for fetching the job from the Forgejo instance.
   fetch_interval: 2s
   # The labels of a runner are used to determine which jobs the runner can run, and how to run them.
-  # Like: ["macos-arm64:host", "ubuntu-latest:docker://node:16-bullseye", "ubuntu-22.04:docker://node:16-bullseye"]
+  # Like: ["macos-arm64:host", "ubuntu-latest:docker://node:20-bookworm", "ubuntu-22.04:docker://node:20-bookworm"]
   # If it's empty when registering, it will ask for inputting labels.
   # If it's empty when execute `deamon`, will use labels in `.runner` file.
   labels: []
diff --git a/internal/pkg/labels/labels.go b/internal/pkg/labels/labels.go
index 6230c0b..f448fdf 100644
--- a/internal/pkg/labels/labels.go
+++ b/internal/pkg/labels/labels.go
@@ -56,7 +56,6 @@ func (l Labels) PickPlatform(runsOn []string) string {
 		switch label.Schema {
 		case SchemeDocker:
 			// "//" will be ignored
-			// TODO maybe we should use 'ubuntu-18.04:docker:node:16-buster' instead
 			platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
 		case SchemeHost:
 			platforms[label.Name] = "-self-hosted"
@@ -83,7 +82,7 @@ func (l Labels) PickPlatform(runsOn []string) string {
 	// So the runner receives a task with a label that the runner doesn't have,
 	// it happens when the user have edited the label of the runner in the web UI.
 	// TODO: it may be not correct, what if the runner is used as host mode only?
-	return "node:16-bullseye"
+	return "node:20-bullseye"
 }
 
 func (l Labels) Names() []string {