Repository: lima-vm/lima

Issue:
Improve flexibility of `limactl shell --preserve-env`
I would like to make the following changes to the `--preserve-env` functionality:

1. Throw an error when the patterns in `LIMA_SHELLENV_ALLOW` or `LIMA_SHELLENV_BLOCK` use any characters outside of `[a-zA-Z0-9_]`.

2. Allow `*` wildcards anywhere in a pattern and not just at the end.

3. Setting `LIMA_SHELLENV_BLOCK` should no longer be ignored when `LIMA_SHELLENV_ALLOW` is set. The allow list should just override the block list entries, but additional variables that are not blocked should still be implicitly allowed.

    The current behaviour is still available by setting `LIMA_SHELLENV_BLOCK=*`, blocking everything not explicitly allowed by `LIMA_SHELLENV_ALLOW`.

These changes make more comprehensive block lists possible (like `*TOKEN*` patterns), and reserve additional meta-characters that are not valid in environment variable names anyways. Combined they will also make it possible to implement ideas like #3852 later without breaking backwards compatibility. So I would like to see these changes in the 2.0 release.

Of course (3) is changing functionality right now, but we haven't released `--preserve-env` in a release yet, and I think the added functionality is really desirable, especially when you want to use more comprehensive block lists.

---

Optional (can be added after 2.0), but I think worthwhile and easy to implement:

* Add `--allow-env PATTERN` and `--block-env PATTERN` options to `limactl shell`.

They would (just for this command) append the pattern to the respective lists and automatically enable `--preserve-env`.

These commands:

```shell
limactl shell --allow-env "GITHUB_TOKEN" default gh ...
limactl shell --block-env "PRIVATE*" default ...
```

would be equivalent to

```shell
LIMA_SHELLENV_ALLOW="$LIMA_SHELLENV_ALLOW,GITHUB_TOKEN" limactl shell --preserve-env default gh ...
LIMA_SHELLENV_BLOCK="$LIMA_SHELLENV_BLOCK,PRIVATE*" limactl shell --preserve-env default ...
```

Except in case `LIMA_SHELLENV_BLOCK` was empty, it would be adding the pattern to the default list, not the empty list:

```shell
LIMA_SHELLENV_BLOCK="+PRIVATE*" limactl shell --preserve-env default ...
```


Hints from issue comments:
@olamilekan000: kindly assign to me@AkihiroSuda: SGTM

Solution patch:
diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml
index 359e7ac4..63385948 100644
--- a/.github/workflows/codeql.yaml
+++ b/.github/workflows/codeql.yaml
@@ -1,10 +1,19 @@
 name: "CodeQL Advanced"
 
 on:
+  # paths-ignore should be kept in sync with test.yml
   push:
     branches: ["master"]
+    paths-ignore:
+    - "docs/**"
+    - "website/**"
+    - "**.md"
   pull_request:
     branches: ["master"]
+    paths-ignore:
+    - "docs/**"
+    - "website/**"
+    - "**.md"
   schedule:
   - cron: '33 19 * * 5'
   workflow_dispatch:
@@ -32,7 +41,7 @@ jobs:
       uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885  # v3.30.6
+      uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5  # v4.30.8
       with:
         languages: ${{ matrix.language }}
         build-mode: ${{ matrix.build-mode }}
@@ -48,6 +57,6 @@ jobs:
         exit 1
 
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885  # v3.30.6
+      uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5  # v4.30.8
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8c462728..35597e63 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,14 +3,23 @@
 
 name: Release
 on:
+  # paths-ignore should be kept in sync with test.yml
   push:
     branches:
     - 'master'
     tags:
     - 'v*'
+    paths-ignore:
+    - "docs/**"
+    - "website/**"
+    - "**.md"
   pull_request:
     branches:
     - 'master'
+    paths-ignore:
+    - "docs/**"
+    - "website/**"
+    - "**.md"
 env:
   GO111MODULE: on
   GOTOOLCHAIN: local
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 9f34eda8..c46db5ae 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -60,6 +60,6 @@ jobs:
     # Upload the results to GitHub's code scanning dashboard (optional).
     # Commenting out will disable upload of results to your repo's Code Scanning dashboard
     - name: "Upload to code-scanning"
-      uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885  # v3.30.6
+      uses: github/codeql-action/upload-sarif@f443b600d91635bebf5b0d9ebc620189c0d6fba5  # v4.30.8
       with:
         sarif_file: results.sarif
diff --git a/.github/workflows/spell.yml b/.github/workflows/spell.yml
new file mode 100644
index 00000000..0efe7cc0
--- /dev/null
+++ b/.github/workflows/spell.yml
@@ -0,0 +1,22 @@
+# split from test.yml so as to run spell checks for doc-only PRs too
+name: spell
+
+on:
+  push:
+    branches:
+    - master
+    - 'release/**'
+  pull_request:
+
+jobs:
+  spell:
+    name: "Spell check"
+    runs-on: ubuntu-24.04
+    timeout-minutes: 5
+    steps:
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
+    - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630  # v2.1
+      with:
+        check_filenames: true
+        check_hidden: true
+        # by default, codespell uses configuration from the .codespellrc
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2483e683..985056c2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -101,18 +101,6 @@ jobs:
     steps:
     - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee  # v1.0.4
 
-  spell:
-    name: "Spell check"
-    runs-on: ubuntu-24.04
-    timeout-minutes: 5
-    steps:
-    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-    - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630  # v2.1
-      with:
-        check_filenames: true
-        check_hidden: true
-        # by default, codespell uses configuration from the .codespellrc
-
   unit:
     name: "Unit tests"
     runs-on: ubuntu-24.04
@@ -189,7 +177,7 @@ jobs:
     - name: Integration tests (WSL2, Windows host)
       run: |
         $env:PATH = "$pwd\_output\bin;" + 'C:\msys64\usr\bin;' + $env:PATH
-        pacman -Sy --noconfirm openbsd-netcat diffutils
+        pacman -Sy --noconfirm openbsd-netcat diffutils socat
         $env:MSYS2_ENV_CONV_EXCL = 'HOME_HOST;HOME_GUEST;_LIMA_WINDOWS_EXTRA_PATH'
         $env:HOME_HOST = $(cygpath.exe "$env:USERPROFILE")
         $env:HOME_GUEST = "/mnt$env:HOME_HOST"
@@ -218,7 +206,7 @@ jobs:
     - name: Integration tests (QEMU, Windows host)
       run: |
         $env:PATH = "$pwd\_output\bin;" + 'C:\msys64\usr\bin;' + 'C:\Program Files\QEMU;' + $env:PATH
-        pacman -Sy --noconfirm openbsd-netcat diffutils
+        pacman -Sy --noconfirm openbsd-netcat diffutils socat
         $env:MSYS2_ENV_CONV_EXCL = 'HOME_HOST;HOME_GUEST;_LIMA_WINDOWS_EXTRA_PATH'
         $env:HOME_HOST = $(cygpath.exe "$env:USERPROFILE")
         $env:HOME_GUEST = "$env:HOME_HOST"
@@ -251,7 +239,8 @@ jobs:
       # bash:      required by test-templates.sh (OS version of bash is too old)
       # coreutils: required by test-templates.sh for the "timeout" command
       # w3m :      required by test-templates.sh for port forwarding tests
-      run: brew install qemu bash coreutils w3m
+      # socat:     required by test-templates.sh for port forwarding tests
+      run: brew install qemu bash coreutils w3m socat
     - name: "Adjust LIMACTL_CREATE_ARGS"
       run: echo "LIMACTL_CREATE_ARGS=${LIMACTL_CREATE_ARGS} --vm-type=qemu" >>$GITHUB_ENV
     - name: "Inject `no_timer_check` to kernel cmdline"
@@ -301,11 +290,6 @@ jobs:
         - ../hack/test-templates/test-misc.yaml  # TODO: merge net-user-v2 into test-misc
     steps:
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
-      with:
-        # BATS tests don't expect lima version warnings like:
-        # msg="treating lima version \"ea336ae\" from \"/Users/runner/.lima-bats/dummy/lima-version\" as very latest release"
-        fetch-depth: 0
-        submodules: true
     - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
       with:
         go-version: 1.25.x
@@ -320,13 +304,8 @@ jobs:
     - name: Install test dependencies
       run: |
         sudo apt-get update
-        sudo apt-get install -y --no-install-recommends ovmf qemu-system-x86 qemu-utils w3m
-        sudo modprobe kvm
-        # `sudo usermod -aG kvm $(whoami)` does not take an effect on GHA
-        sudo chown $(whoami) /dev/kvm
-    - name: "Run BATS integration tests"
-      run: make bats
-      if: matrix.template == '../hack/test-templates/test-misc.yaml'
+        sudo ./hack/install-qemu.sh
+        sudo apt-get install -y --no-install-recommends socat w3m
     - name: Install ansible-playbook
       run: |
         sudo apt-get install -y --no-install-recommends ansible
@@ -347,6 +326,48 @@ jobs:
     - name: "Show cache"
       run: ./hack/debug-cache.sh
 
+  bats:
+    name: "Integration tests (BATS)"
+    runs-on: ubuntu-24.04
+    timeout-minutes: 30
+    steps:
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
+      with:
+        # BATS tests don't expect lima version warnings like:
+        # msg="treating lima version \"ea336ae\" from \"/Users/runner/.lima-bats/dummy/lima-version\" as very latest release"
+        fetch-depth: 0
+        submodules: true
+    - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
+      with:
+        go-version: 1.25.x
+    - name: Make
+      run: make
+    - name: Install
+      run: sudo make install
+    - name: Install test dependencies
+      run: |
+        sudo apt-get update
+        sudo ./hack/install-qemu.sh
+    - name: Cache image used by templates/default.yaml
+      uses: ./.github/actions/setup_cache_for_template
+      with:
+        template: templates/default.yaml
+    - name: "Run BATS integration tests"
+      run: make bats
+    - name: Cache image used by templates/k8s.yaml
+      uses: ./.github/actions/setup_cache_for_template
+      with:
+        template: templates/k8s.yaml
+    - name: "Run BATS k8s tests"
+      # Wish we could use BATS_TEST_RETRIES=3 as an environment variable here,
+      # but bats does not seem to support it.
+      uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08  # v3.0.2
+      with:
+        timeout_minutes: 30
+        retry_on: error
+        max_attempts: 3
+        command: ./hack/bats/lib/bats-core/bin/bats --timing ./hack/bats/extras/k8s.bats
+
   colima:
     name: "Colima tests (QEMU, Linux host)"
     runs-on: ubuntu-24.04
@@ -383,10 +404,7 @@ jobs:
     - name: Install test dependencies
       run: |
         sudo apt-get update
-        sudo apt-get install -y --no-install-recommends ovmf qemu-system-x86 qemu-utils
-        sudo modprobe kvm
-        # `sudo usermod -aG kvm $(whoami)` does not take an effect on GHA
-        sudo chown $(whoami) /dev/kvm
+        sudo ./hack/install-qemu.sh
     - name: "Show cache"
       run: ./hack/debug-cache.sh
     - name: "Test"
@@ -422,7 +440,7 @@ jobs:
       with:
         template: templates/default.yaml
     - name: Install test dependencies
-      run: brew install qemu bash coreutils w3m
+      run: brew install qemu bash coreutils w3m socat
     - name: Install socket_vmnet
       env:
         SOCKET_VMNET_VERSION: v1.2.0
@@ -501,6 +519,9 @@ jobs:
         template:
         - default.yaml
     steps:
+    - name: "Adjust LIMACTL_CREATE_ARGS"
+      # --cpus=1 is needed for running vz on GHA: https://github.com/lima-vm/lima/pull/1511#issuecomment-1574937888
+      run: echo "LIMACTL_CREATE_ARGS=${LIMACTL_CREATE_ARGS} --cpus 1 --memory 1" >>$GITHUB_ENV
     - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8  # v5.0.0
     - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00  # v6.0.0
       with:
@@ -514,7 +535,7 @@ jobs:
       with:
         template: templates/${{ matrix.template }}
     - name: Install test dependencies
-      run: brew install bash coreutils w3m
+      run: brew install bash coreutils w3m socat
     - name: Uninstall qemu
       run: brew uninstall --ignore-dependencies --force qemu
     - name: Test
@@ -588,10 +609,6 @@ jobs:
     - name: Install test dependencies
       run: |
         sudo apt-get update
-        sudo apt-get install -y --no-install-recommends ovmf qemu-system-x86 qemu-utils
-        qemu-system-x86_64 --version
-        sudo modprobe kvm
-        # `sudo usermod -aG kvm $(whoami)` does not take an effect on GHA
-        sudo chown $(whoami) /dev/kvm
+        sudo ./hack/install-qemu.sh
     - name: Smoke test
       run: limactl start --tty=false
diff --git a/.golangci.yml b/.golangci.yml
index 3227784e..3fdc979b 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -102,6 +102,7 @@ linters:
       - name: indent-error-flow
       - name: package-comments
         disabled: true
+      - name: package-directory-mismatch
       - name: range
       - name: receiver-naming
       - name: redefines-builtin-id
diff --git a/Makefile b/Makefile
index 673888e7..7c5f7d1e 100644
--- a/Makefile
+++ b/Makefile
@@ -552,7 +552,7 @@ check-generated:
 		(echo "Please run 'make generate' when making changes to proto files and check-in the generated file changes" && false)
 
 .PHONY: bats
-bats: native
+bats: native limactl-plugins
 	PATH=$$PWD/_output/bin:$$PATH ./hack/bats/lib/bats-core/bin/bats --timing ./hack/bats/tests
 
 .PHONY: lint
diff --git a/README.md b/README.md
index ac029760..7e529a01 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ GUI:
 Lima follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md).
 
 - - -
-**We are a [Cloud Native Computing Foundation](https://cncf.io/) sandbox project.**
+**We are a [Cloud Native Computing Foundation](https://cncf.io/) incubating project.**
 
 <picture>
   <source media="(prefers-color-scheme: dark)" srcset="https://www.cncf.io/wp-content/uploads/2022/07/cncf-white-logo.svg">
diff --git a/cmd/limactl/clone.go b/cmd/limactl/clone.go
index dc016e83..e7946eb4 100644
--- a/cmd/limactl/clone.go
+++ b/cmd/limactl/clone.go
@@ -16,7 +16,7 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/instance"
 	"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
 	"github.com/lima-vm/lima/v2/pkg/limayaml"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/v2/pkg/store"
 	"github.com/lima-vm/lima/v2/pkg/yqutil"
 )
@@ -30,7 +30,7 @@ func newCloneCommand() *cobra.Command {
 Not to be confused with 'limactl copy' ('limactl cp').
 `,
 		Args:              WrapArgsError(cobra.ExactArgs(2)),
-		RunE:              cloneAction,
+		RunE:              cloneOrRenameAction,
 		ValidArgsFunction: cloneBashComplete,
 		GroupID:           advancedCommand,
 	}
@@ -38,7 +38,22 @@ Not to be confused with 'limactl copy' ('limactl cp').
 	return cloneCommand
 }
 
-func cloneAction(cmd *cobra.Command, args []string) error {
+func newRenameCommand() *cobra.Command {
+	renameCommand := &cobra.Command{
+		Use: "rename OLDINST NEWINST",
+		// No "mv" alias, to avoid confusion with a theoretical equivalent of `limactl cp` but s/cp/mv/.
+		Short:             "Rename an instance of Lima",
+		Args:              WrapArgsError(cobra.ExactArgs(2)),
+		RunE:              cloneOrRenameAction,
+		ValidArgsFunction: cloneBashComplete,
+		GroupID:           advancedCommand,
+	}
+	editflags.RegisterEdit(renameCommand, "[limactl edit] ")
+	return renameCommand
+}
+
+func cloneOrRenameAction(cmd *cobra.Command, args []string) error {
+	rename := cmd.Name() == "rename"
 	ctx := cmd.Context()
 	flags := cmd.Flags()
 	tty, err := flags.GetBool("tty")
@@ -55,7 +70,7 @@ func cloneAction(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	newInst, err := instance.Clone(ctx, oldInst, newInstName)
+	newInst, err := instance.CloneOrRename(ctx, oldInst, newInstName, rename)
 	if err != nil {
 		return err
 	}
@@ -109,7 +124,7 @@ func cloneAction(cmd *cobra.Command, args []string) error {
 	if !startNow {
 		return nil
 	}
-	err = networks.Reconcile(ctx, newInst.Name)
+	err = reconcile.Reconcile(ctx, newInst.Name)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/limactl/delete.go b/cmd/limactl/delete.go
index 168f41d4..2c00f5fd 100644
--- a/cmd/limactl/delete.go
+++ b/cmd/limactl/delete.go
@@ -14,7 +14,7 @@ import (
 
 	"github.com/lima-vm/lima/v2/pkg/autostart"
 	"github.com/lima-vm/lima/v2/pkg/instance"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/v2/pkg/store"
 )
 
@@ -60,7 +60,7 @@ func deleteAction(cmd *cobra.Command, args []string) error {
 		}
 		logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
 	}
-	return networks.Reconcile(cmd.Context(), "")
+	return reconcile.Reconcile(cmd.Context(), "")
 }
 
 func deleteBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
diff --git a/cmd/limactl/edit.go b/cmd/limactl/edit.go
index 80e263a2..5eccfcfe 100644
--- a/cmd/limactl/edit.go
+++ b/cmd/limactl/edit.go
@@ -21,7 +21,7 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
 	"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
 	"github.com/lima-vm/lima/v2/pkg/limayaml"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/v2/pkg/store"
 	"github.com/lima-vm/lima/v2/pkg/uiutil"
 	"github.com/lima-vm/lima/v2/pkg/yqutil"
@@ -155,7 +155,7 @@ func editAction(cmd *cobra.Command, args []string) error {
 	if !startNow {
 		return nil
 	}
-	err = networks.Reconcile(ctx, inst.Name)
+	err = reconcile.Reconcile(ctx, inst.Name)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go
index 7047a79c..b62a08c7 100644
--- a/cmd/limactl/editflags/editflags.go
+++ b/cmd/limactl/editflags/editflags.go
@@ -66,7 +66,7 @@ func RegisterEdit(cmd *cobra.Command, commentPrefix string) {
 
 	flags.Bool("rosetta", false, commentPrefix+"Enable Rosetta (for vz instances)")
 
-	flags.String("set", "", commentPrefix+"Modify the template inplace, using yq syntax")
+	flags.StringArray("set", []string{}, commentPrefix+"Modify the template inplace, using yq syntax. Can be passed multiple times.")
 
 	flags.Uint16("ssh-port", 0, commentPrefix+"SSH port (0 for random)") // colima-compatible
 	_ = cmd.RegisterFlagCompletionFunc("ssh-port", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -115,9 +115,9 @@ func RegisterCreate(cmd *cobra.Command, commentPrefix string) {
 	})
 }
 
-func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) {
-	return func(v *flag.Flag) (string, error) {
-		return fmt.Sprintf(expr, v.Value), nil
+func defaultExprFunc(expr string) func(v *flag.Flag) ([]string, error) {
+	return func(v *flag.Flag) ([]string, error) {
+		return []string{fmt.Sprintf(expr, v.Value)}, nil
 	}
 }
 
@@ -193,7 +193,7 @@ func buildMountListExpression(ss []string) (string, error) {
 func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 	type def struct {
 		flagName                 string
-		exprFunc                 func(*flag.Flag) (string, error)
+		exprFunc                 func(*flag.Flag) ([]string, error)
 		onlyValidForNewInstances bool
 		experimental             bool
 	}
@@ -202,10 +202,10 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 		{"cpus", d(".cpus = %s"), false, false},
 		{
 			"dns",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				ipSlice, err := flags.GetIPSlice("dns")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				expr := `.dns += [`
 				for i, ip := range ipSlice {
@@ -216,7 +216,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 				}
 				expr += `] | .dns |= unique | .hostResolver.enabled=false`
 				logrus.Warnf("Disabling HostResolver, as custom DNS addresses (%v) are specified", ipSlice)
-				return expr, nil
+				return []string{expr}, nil
 			},
 			false,
 			false,
@@ -224,61 +224,61 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 		{"memory", d(".memory = \"%sGiB\""), false, false},
 		{
 			"mount",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				ss, err := flags.GetStringSlice("mount")
 				slices.Reverse(ss)
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				mountListExpr, err := buildMountListExpression(ss)
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				// mount options take precedence over template settings
 				expr := fmt.Sprintf(".mounts = %s + .mounts", mountListExpr)
 				mountOnly, err := flags.GetStringSlice("mount-only")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				if len(mountOnly) > 0 {
-					return "", errors.New("flag `--mount` conflicts with `--mount-only`")
+					return nil, errors.New("flag `--mount` conflicts with `--mount-only`")
 				}
-				return expr, nil
+				return []string{expr}, nil
 			},
 			false,
 			false,
 		},
 		{
 			"mount-only",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				ss, err := flags.GetStringSlice("mount-only")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				mountListExpr, err := buildMountListExpression(ss)
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				expr := `.mounts = ` + mountListExpr
-				return expr, nil
+				return []string{expr}, nil
 			},
 			false,
 			false,
 		},
 		{
 			"mount-none",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				incompatibleFlagNames := []string{"mount", "mount-only"}
 				for _, name := range incompatibleFlagNames {
 					ss, err := flags.GetStringSlice(name)
 					if err != nil {
-						return "", err
+						return nil, err
 					}
 					if len(ss) > 0 {
-						return "", errors.New("flag `--mount-none` conflicts with `" + name + "`")
+						return nil, errors.New("flag `--mount-none` conflicts with `" + name + "`")
 					}
 				}
-				return ".mounts = null", nil
+				return []string{".mounts = null"}, nil
 			},
 			false,
 			false,
@@ -289,10 +289,10 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 		{"mount-writable", d(".mounts[].writable = %s"), false, false},
 		{
 			"network",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				ss, err := flags.GetStringSlice("network")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				expr := `.networks += [`
 				for i, s := range ss {
@@ -304,14 +304,14 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 						network := strings.TrimPrefix(s, "lima:")
 						expr += fmt.Sprintf(`{"lima": %q}`, network)
 					default:
-						return "", fmt.Errorf("network name must be \"vzNAT\" or \"lima:*\", got %q", s)
+						return nil, fmt.Errorf("network name must be \"vzNAT\" or \"lima:*\", got %q", s)
 					}
 					if i < len(ss)-1 {
 						expr += ","
 					}
 				}
 				expr += `] | .networks |= unique_by(.lima)`
-				return expr, nil
+				return []string{expr}, nil
 			},
 			false,
 			false,
@@ -319,28 +319,30 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 
 		{
 			"rosetta",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				b, err := flags.GetBool("rosetta")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
-				return fmt.Sprintf(".vmOpts.vz.rosetta.enabled = %v | .vmOpts.vz.rosetta.binfmt = %v", b, b), nil
+				return []string{fmt.Sprintf(".vmOpts.vz.rosetta.enabled = %v | .vmOpts.vz.rosetta.binfmt = %v", b, b)}, nil
 			},
 			false,
 			false,
 		},
-		{"set", d("%s"), false, false},
+		{"set", func(v *flag.Flag) ([]string, error) {
+			return v.Value.(flag.SliceValue).GetSlice(), nil
+		}, false, false},
 		{
 			"video",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				b, err := flags.GetBool("video")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				if b {
-					return ".video.display = \"default\"", nil
+					return []string{".video.display = \"default\""}, nil
 				}
-				return ".video.display = \"none\"", nil
+				return []string{".video.display = \"none\""}, nil
 			},
 			false,
 			false,
@@ -349,22 +351,22 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 		{"arch", d(".arch = %q"), true, false},
 		{
 			"containerd",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				s, err := flags.GetString("containerd")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
 				switch s {
 				case "user":
-					return `.containerd.user = true | .containerd.system = false`, nil
+					return []string{`.containerd.user = true | .containerd.system = false`}, nil
 				case "system":
-					return `.containerd.user = false | .containerd.system = true`, nil
+					return []string{`.containerd.user = false | .containerd.system = true`}, nil
 				case "user+system", "system+user":
-					return `.containerd.user = true | .containerd.system = true`, nil
+					return []string{`.containerd.user = true | .containerd.system = true`}, nil
 				case "none":
-					return `.containerd.user = false | .containerd.system = false`, nil
+					return []string{`.containerd.user = false | .containerd.system = false`}, nil
 				default:
-					return "", fmt.Errorf(`expected one of ["user", "system", "user+system", "none"], got %q`, s)
+					return nil, fmt.Errorf(`expected one of ["user", "system", "user+system", "none"], got %q`, s)
 				}
 			},
 			true,
@@ -374,12 +376,16 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 		{"plain", d(".plain = %s"), true, false},
 		{
 			"port-forward",
-			func(_ *flag.Flag) (string, error) {
+			func(_ *flag.Flag) ([]string, error) {
 				ss, err := flags.GetStringArray("port-forward")
 				if err != nil {
-					return "", err
+					return nil, err
 				}
-				return BuildPortForwardExpression(ss)
+				value, err := BuildPortForwardExpression(ss)
+				if err != nil {
+					return nil, err
+				}
+				return []string{value}, nil
 			},
 			false,
 			false,
@@ -397,11 +403,11 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) {
 					def.flagName, def.flagName, v.Value.String())
 				continue
 			}
-			expr, err := def.exprFunc(v)
+			newExprs, err := def.exprFunc(v)
 			if err != nil {
 				return exprs, fmt.Errorf("error while processing flag %q: %w", def.flagName, err)
 			}
-			exprs = append(exprs, expr)
+			exprs = append(exprs, newExprs...)
 		}
 	}
 	return exprs, nil
diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go
index 874a4a10..a91332da 100644
--- a/cmd/limactl/main.go
+++ b/cmd/limactl/main.go
@@ -202,6 +202,7 @@ func newApp() *cobra.Command {
 		newStartAtLoginCommand(),
 		newNetworkCommand(),
 		newCloneCommand(),
+		newRenameCommand(),
 	)
 	addPluginCommands(rootCmd)
 
diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go
index d7693085..68c91a31 100644
--- a/cmd/limactl/shell.go
+++ b/cmd/limactl/shell.go
@@ -24,7 +24,7 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/instance"
 	"github.com/lima-vm/lima/v2/pkg/ioutilx"
 	"github.com/lima-vm/lima/v2/pkg/limatype"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/v2/pkg/sshutil"
 	"github.com/lima-vm/lima/v2/pkg/store"
 )
@@ -101,7 +101,7 @@ func shellAction(cmd *cobra.Command, args []string) error {
 			return nil
 		}
 
-		err = networks.Reconcile(ctx, inst.Name)
+		err = reconcile.Reconcile(ctx, inst.Name)
 		if err != nil {
 			return err
 		}
diff --git a/cmd/limactl/start.go b/cmd/limactl/start.go
index 24a041fd..02e56d8f 100644
--- a/cmd/limactl/start.go
+++ b/cmd/limactl/start.go
@@ -25,7 +25,7 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
 	"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
 	"github.com/lima-vm/lima/v2/pkg/limayaml"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/v2/pkg/registry"
 	"github.com/lima-vm/lima/v2/pkg/store"
 	"github.com/lima-vm/lima/v2/pkg/templatestore"
@@ -580,7 +580,7 @@ func startAction(cmd *cobra.Command, args []string) error {
 		logrus.Warnf("expected status %q, got %q", limatype.StatusStopped, inst.Status)
 	}
 	ctx := cmd.Context()
-	err = networks.Reconcile(ctx, inst.Name)
+	err = reconcile.Reconcile(ctx, inst.Name)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/limactl/stop.go b/cmd/limactl/stop.go
index 37a29778..08a2c20e 100644
--- a/cmd/limactl/stop.go
+++ b/cmd/limactl/stop.go
@@ -7,7 +7,7 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/lima-vm/lima/v2/pkg/instance"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 	"github.com/lima-vm/lima/v2/pkg/store"
 )
 
@@ -48,7 +48,7 @@ func stopAction(cmd *cobra.Command, args []string) error {
 	}
 	// TODO: should we also reconcile networks if graceful stop returned an error?
 	if err == nil {
-		err = networks.Reconcile(ctx, "")
+		err = reconcile.Reconcile(ctx, "")
 	}
 	return err
 }
diff --git a/go.mod b/go.mod
index 878338f6..1cd875f6 100644
--- a/go.mod
+++ b/go.mod
@@ -22,7 +22,7 @@ require (
 	github.com/foxcpp/go-mockdns v1.1.0
 	github.com/goccy/go-yaml v1.18.0
 	github.com/google/go-cmp v0.7.0
-	github.com/google/yamlfmt v0.17.2
+	github.com/google/yamlfmt v0.18.1
 	github.com/invopop/jsonschema v0.13.0
 	github.com/lima-vm/go-qcow2reader v0.6.0
 	github.com/lima-vm/sshocker v0.3.8 // gomodjail:unconfined
@@ -31,7 +31,7 @@ require (
 	github.com/mdlayher/netlink v1.8.0
 	github.com/mdlayher/vsock v1.2.1 // gomodjail:unconfined
 	github.com/miekg/dns v1.1.68 // gomodjail:unconfined
-	github.com/mikefarah/yq/v4 v4.47.2
+	github.com/mikefarah/yq/v4 v4.48.1
 	github.com/modelcontextprotocol/go-sdk v1.0.0
 	github.com/nxadm/tail v1.4.11 // gomodjail:unconfined
 	github.com/opencontainers/go-digest v1.0.0
@@ -44,11 +44,11 @@ require (
 	github.com/spf13/cobra v1.10.1 // gomodjail:unconfined
 	github.com/spf13/pflag v1.0.10
 	github.com/wk8/go-ordered-map/v2 v2.1.8
-	golang.org/x/net v0.44.0
+	golang.org/x/net v0.46.0
 	golang.org/x/sync v0.17.0
-	golang.org/x/sys v0.36.0 // gomodjail:unconfined
-	golang.org/x/text v0.29.0
-	google.golang.org/grpc v1.75.1
+	golang.org/x/sys v0.37.0 // gomodjail:unconfined
+	golang.org/x/text v0.30.0
+	google.golang.org/grpc v1.76.0
 	google.golang.org/protobuf v1.36.10 // gomodjail:unconfined
 	gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473
 	gotest.tools/v3 v3.5.2
@@ -116,13 +116,13 @@ require (
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
 	github.com/yuin/gopher-lua v1.1.1 // indirect
-	golang.org/x/crypto v0.42.0 // indirect
-	golang.org/x/mod v0.27.0 // indirect
+	golang.org/x/crypto v0.43.0 // indirect
+	golang.org/x/mod v0.28.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
-	golang.org/x/term v0.35.0 // indirect
+	golang.org/x/term v0.36.0 // indirect
 	golang.org/x/time v0.9.0 // indirect
-	golang.org/x/tools v0.36.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
+	golang.org/x/tools v0.37.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
diff --git a/go.sum b/go.sum
index 06f24b8f..17c3cd96 100644
--- a/go.sum
+++ b/go.sum
@@ -18,8 +18,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
 github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
-github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
-github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
 github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
 github.com/apparentlymart/go-cidr v1.1.0 h1:2mAhrMoF+nhXqxTzSZMUzDHkLjmIHC+Zzn4tdgBZjnU=
@@ -126,8 +126,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/yamlfmt v0.17.2 h1:TkXxhmj7dnpmOnlWGOXog92Gs6MWcTZqnf3kuyp8yFQ=
-github.com/google/yamlfmt v0.17.2/go.mod h1:gs0UEklJOYkUJ+OOCG0hg9n+DzucKDPlJElTUasVNK8=
+github.com/google/yamlfmt v0.18.1 h1:VyqQXtGk7KP5NciLCtdVC5Q+hhrRgERgZJcH4oCMO/w=
+github.com/google/yamlfmt v0.18.1/go.mod h1:gs0UEklJOYkUJ+OOCG0hg9n+DzucKDPlJElTUasVNK8=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -194,8 +194,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyex
 github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
 github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
 github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
-github.com/mikefarah/yq/v4 v4.47.2 h1:Jb5fHlvgK5eeaPbreG9UJs1E5w6l5hUzXjeaY6LTTWY=
-github.com/mikefarah/yq/v4 v4.47.2/go.mod h1:ulYbZUzGJsBDDwO5ohvk/KOW4vW5Iddd/DBeAY1Q09g=
+github.com/mikefarah/yq/v4 v4.48.1 h1:xfw9oDxur6z+friOtMAuzSvQ9LPLb0LRDsl1e36DGCo=
+github.com/mikefarah/yq/v4 v4.48.1/go.mod h1:NbCTmYAY+KjwCZemVDqLxSAeZKjXFK04lgIiSx13tD0=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
@@ -311,8 +311,8 @@ golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
-golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -324,8 +324,8 @@ golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
-golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -340,8 +340,8 @@ golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
-golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -381,8 +381,8 @@ golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -394,8 +394,8 @@ golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
 golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
-golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -406,8 +406,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
 golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -421,18 +421,18 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
-golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
 gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
-google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
-google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
+google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/hack/bats/extras/README.md b/hack/bats/extras/README.md
new file mode 100644
index 00000000..419a0948
--- /dev/null
+++ b/hack/bats/extras/README.md
@@ -0,0 +1,6 @@
+# Extra tests
+
+The extra tests located in this directory are not automatically executed via `make bats`.
+
+Some tests are executed on the CI, some ones are not.
+Refer to the configuration of the GitHub Actions to see what tests are executed.
\ No newline at end of file
diff --git a/hack/bats/extras/k8s.bats b/hack/bats/extras/k8s.bats
new file mode 100644
index 00000000..fa6dc1c3
--- /dev/null
+++ b/hack/bats/extras/k8s.bats
@@ -0,0 +1,64 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+# This test verifies that a Kubernetes cluster can be started and that the single node is ready.
+
+load "../helpers/load"
+
+: "${TEMPLATE:=k8s}"
+
+# Instance names are "${NAME}-0", "${NAME}-1", ...
+NAME="k8s"
+
+get_num_nodes() {
+    local nodes=0
+    for tag in "${BATS_TEST_TAGS[@]}"; do
+        if [[ $tag =~ ^nodes:([0-9]+)$ ]]; then
+            nodes="${BASH_REMATCH[1]}"
+        fi
+    done
+    if [[ $nodes -eq 0 ]]; then
+        echo >&2 "nodes:N tag is required"
+        exit 1
+    fi
+    echo "$nodes"
+}
+
+local_setup() {
+    local nodes=$(get_num_nodes)
+    for ((i=0; i<nodes; i++)); do
+        limactl delete --force "${NAME}-$i" || :
+        limactl start --tty=false --name "${NAME}-$i" "template:${TEMPLATE}" 3>&- 4>&-
+        # NOTE: No support for multi-node clusters yet.
+    done
+    for node in $(k get node -o name); do
+	    k wait --timeout=5m --for=condition=ready "${node}"
+    done
+}
+
+local_teardown() {
+    local nodes=$(get_num_nodes)
+    for ((i=0; i<nodes; i++)); do
+        limactl delete --force "${NAME}-$i" || :
+    done
+}
+
+k() {
+    # The host home directory is not mounted in the case of k8s.
+    limactl shell --workdir=/ "${NAME}-0" -- kubectl "$@"
+}
+
+# bats test_tags=nodes:1
+@test 'Single-node' {
+    k create deployment nginx --image="${TEST_CONTAINER_IMAGES["nginx"]}"
+    k rollout status deployment nginx --timeout 60s
+    k create service nodeport nginx --node-port=31080 --tcp=80:80
+    run curl --fail --silent --show-error --retry 30 --retry-all-errors http://localhost:31080
+    assert_success
+    assert_output --partial "Welcome to nginx"
+    # TODO: support UDP
+    k delete service nginx
+    k delete deployment nginx
+}
+
+# TODO: add a test for multi-node
\ No newline at end of file
diff --git a/hack/bats/helpers/load.bash b/hack/bats/helpers/load.bash
index db2a69ca..0469585c 100644
--- a/hack/bats/helpers/load.bash
+++ b/hack/bats/helpers/load.bash
@@ -65,3 +65,9 @@ teardown() {
 assert_output_lines_count() {
     assert_equal "${#lines[@]}" "$1"
 }
+
+# Use GHCR and ECR to avoid hitting Docker Hub rate limit.
+# NOTE: keep this list in sync with hack/test-templates.sh .
+declare -A -g TEST_CONTAINER_IMAGES=(
+    ["nginx"]="ghcr.io/stargz-containers/nginx:1.19-alpine-org"
+)
\ No newline at end of file
diff --git a/hack/fix-qemu-signature.sh b/hack/fix-qemu-signature.sh
deleted file mode 100755
index f571c329..00000000
--- a/hack/fix-qemu-signature.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/bin/sh
-
-# SPDX-FileCopyrightText: Copyright The Lima Authors
-# SPDX-License-Identifier: Apache-2.0
-
-# This script fixes the signature of QEMU binary with the "com.apple.security.hypervisor" entitlement.
-#
-# A workaround for "QEMU (homebrew) is broken on Intel: `[hostagent] Driver stopped due to error: "signal: abort trap"` ..."
-#
-# https://github.com/lima-vm/lima/issues/1742
-# https://github.com/Homebrew/homebrew-core/issues/140244
-
-set -eux
-
-cat >entitlements.xml <<EOF
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
-<dict>
-    <key>com.apple.security.hypervisor</key>
-    <true/>
-</dict>
-</plist>
-EOF
-
-codesign --sign - --entitlements entitlements.xml --force "$(which qemu-system-"$(uname -m | sed -e s/arm64/aarch64/)")"
-
-rm -f entitlements.xml
diff --git a/hack/gogenerate/protoc.sh b/hack/gogenerate/protoc.sh
index d9c5bd36..d7c68adb 100755
--- a/hack/gogenerate/protoc.sh
+++ b/hack/gogenerate/protoc.sh
@@ -26,4 +26,4 @@ protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=p
 # +// - protoc version [omitted for reproducibility]
 #
 # perl is used because `sed -i` is not portable across BSD (macOS) and GNU.
-perl -pi -E 's@(^//.*)protoc[[:blank:]]+v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+@\1protoc version [omitted for reproducibility]@g' "$PB_GO" "$GRPC_PB_GO"
+perl -pi -E 's@(^//.*protoc.*)[[:blank:]]+v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+@\1 [version omitted for reproducibility]@g' "$PB_GO" "$GRPC_PB_GO"
diff --git a/hack/install-qemu.sh b/hack/install-qemu.sh
new file mode 100755
index 00000000..6358f0cc
--- /dev/null
+++ b/hack/install-qemu.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+# Install qemu on GitHub Actions runner.
+# Not expected to be used outside GitHub Actions.
+
+set -eux
+
+# apt-get update has to be run beforehand
+apt-get install -y --no-install-recommends ovmf qemu-system-x86 qemu-utils
+modprobe kvm
+# `usermod -aG kvm ${SUDO_USER}` does not take an effect on GHA
+chown "${SUDO_USER}" /dev/kvm
+qemu-system-x86_64 --version
diff --git a/hack/test-port-forwarding.pl b/hack/test-port-forwarding.pl
index 9e98db43..269b10a8 100755
--- a/hack/test-port-forwarding.pl
+++ b/hack/test-port-forwarding.pl
@@ -8,7 +8,7 @@
 # ./hack/test-port-forwarding.pl templates/default.yaml
 # limactl --tty=false start templates/default.yaml
 # git restore templates/default.yaml
-# ./hack/test-port-forwarding.pl default
+# ./hack/test-port-forwarding.pl default [nc|socat [nc|socat]] [timeout]
 #
 # TODO: support for ipv6 host addresses
 
@@ -21,7 +21,23 @@ use IO::Handle qw();
 use Socket qw(inet_ntoa);
 use Sys::Hostname qw(hostname);
 
+my $connectionTimeout = 1; # seconds
+
 my $instance = shift;
+my $listener;
+my $writer;
+while (my $arg = shift) {
+    if ($arg eq "nc" || $arg eq "socat") {
+        $listener = $arg unless defined $listener;
+        $writer = $arg if defined $listener && !defined $writer;
+    } elsif ($arg =~ /^\d+$/) {
+        $connectionTimeout = $arg;
+    } else {
+        die "Usage: $0 [instance|yaml-file] [nc|socat [nc|socat]] [timeout]\n";
+    }
+}
+$listener ||= "nc";
+$writer ||= $listener;
 
 my $addr = scalar gethostbyname(hostname());
 # If hostname address cannot be determines, use localhost to trigger fallback to system_profiler lookup
@@ -146,7 +162,8 @@ print $lima <<'EOF';
 set -e
 cd $HOME
 sudo pkill -x nc || true
-rm -f nc.*
+sudo pkill -x socat || true
+rm -f nc.* socat.*
 EOF
 
 # Give the hostagent some time to remove any port forwards from a previous (crashed?) test run
@@ -161,13 +178,19 @@ my $ha_log_size = -s $ha_log or die;
 # Setup a netcat listener on the guest for each test
 foreach my $id (0..@test-1) {
     my $test = $test[$id];
-    my $nc = "nc -l $test->{guest_ip} $test->{guest_port}";
-    if ($instance =~ /^alpine/) {
-        $nc = "nc -l -s $test->{guest_ip} -p $test->{guest_port}";
+    my $cmd;
+    if ($listener eq "nc") {
+        $cmd = "nc -l $test->{guest_ip} $test->{guest_port}";
+        if ($instance =~ /^alpine/) {
+            $cmd = "nc -l -s $test->{guest_ip} -p $test->{guest_port}";
+        }
+    } elsif ($listener eq "socat") {
+        my $proto = $test->{guest_ip} =~ /:/ ? "TCP6" : "TCP";
+        $cmd = "socat -u $proto-LISTEN:$test->{guest_port},bind=$test->{guest_ip} STDOUT";
     }
 
     my $sudo = $test->{guest_port} < 1024 ? "sudo " : "";
-    print $lima "${sudo}${nc} >nc.${id} 2>/dev/null &\n";
+    print $lima "${sudo}${cmd} >$listener.${id} 2>/dev/null &\n";
 }
 
 # Make sure the guest- and hostagents had enough time to set up the forwards
@@ -176,8 +199,20 @@ sleep 5;
 # Try to reach each listener from the host
 foreach my $test (@test) {
     next if $test->{host_port} == $sshLocalPort;
-    my $nc = $test->{host_socket} eq "" ? "nc -w 1 $test->{host_ip} $test->{host_port}" : "nc -w 1 -U $test->{host_socket}";
-    open(my $netcat, "| $nc") or die "Can't run '$nc': $!";
+    my $cmd;
+    if ($writer eq "nc") {
+        if ($Config{osname} eq "darwin") {
+            # macOS nc doesn't support -w for connection timeout, so use -G instead
+            $cmd = $test->{host_socket} eq "" ? "nc -G $connectionTimeout $test->{host_ip} $test->{host_port}" : "nc -G $connectionTimeout -U $test->{host_socket}";
+        } else {
+            $cmd = $test->{host_socket} eq "" ? "nc -w $connectionTimeout $test->{host_ip} $test->{host_port}" : "nc -w $connectionTimeout -U $test->{host_socket}";
+        }
+    } elsif ($writer eq "socat") {
+        my $tcp_dest = $test->{host_ip} =~ /:/ ? "TCP6:[$test->{host_ip}]:$test->{host_port}" : "TCP:$test->{host_ip}:$test->{host_port}";
+        $cmd = $test->{host_socket} eq "" ? "socat -u STDIN $tcp_dest,connect-timeout=$connectionTimeout" : "socat -u STDIN UNIX-CONNECT:$test->{host_socket}";
+    }
+    print "Running: $cmd\n";
+    open(my $netcat, "| $cmd") or die "Can't run '$cmd': $!";
     print $netcat "$test->{log_msg}\n";
     # Don't check for errors on close; macOS nc seems to return non-zero exit code even on success
     close($netcat);
@@ -187,9 +222,11 @@ foreach my $test (@test) {
 open(my $log, "< $ha_log") or die "Can't read $ha_log: $!";
 seek($log, $ha_log_size, 0) or die "Can't seek $ha_log to $ha_log_size: $!";
 my %seen;
+my %failed_to_listen_tcp;
 while (<$log>) {
     $seen{$1}++ if /(Forwarding TCP from .*? to ((\d.*?|\[.*?\]):\d+|\/[^"]+))/;
     $seen{$1}++ if /(Not forwarding TCP .*?:\d+)/;
+    $failed_to_listen_tcp{$2}=$1 if /(failed to listen tcp: listen tcp (.*?:\d+):[^"]+)/;
 }
 close $log or die;
 
@@ -202,7 +239,7 @@ foreach my $id (0..@test-1) {
     unless ($seen{$test->{log_msg}}) {
         $err .= "\n   Message missing from ha.stderr.log";
     }
-    my $log = qx(limactl shell --workdir / $instance sh -c "cd; cat nc.$id");
+    my $log = qx(limactl shell --workdir / $instance sh -c "cd; cat $listener.$id");
     chomp $log;
     if ($test->{mode} eq "forward" && $test->{log_msg} ne $log) {
         $err .= "\n   Guest received: '$log'";
@@ -220,8 +257,26 @@ foreach (keys %seen) {
     print "😕 Unexpected log message: $_\n";
 }
 
+if (%failed_to_listen_tcp) {
+    foreach (keys %failed_to_listen_tcp) {
+        print "⚠️  $failed_to_listen_tcp{$_}\n";
+    }
+    my @tcp_list = keys %failed_to_listen_tcp; 
+    if ($Config{osname} eq "darwin") {
+        my @lsof_args = map { "-iTCP\@$_" } @tcp_list;
+        print `lsof -P @lsof_args`;
+    } elsif ($Config{osname} eq "linux") {
+        my @lss_args = map { "src = $_" } @tcp_list;
+        my $ss_expression = join(" or ", @lss_args);
+        print `sudo ss -lnpt "$ss_expression"`;
+    } elsif ($Config{osname} eq "cygwin") {
+        my @awk_args = map { "-e'/$_/'" } @tcp_list;
+        print `netstat -aon | awk -e'/^ +Proto/' @awk_args`;
+    }
+}
+
 # Cleanup remaining netcat instances (and port forwards)
-print $lima "sudo pkill -x nc";
+print $lima "sudo pkill -x $listener";
 
 exit $rc;
 
diff --git a/hack/test-preserve-env.sh b/hack/test-preserve-env.sh
deleted file mode 100755
index 20959107..00000000
--- a/hack/test-preserve-env.sh
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/usr/bin/env bash
-
-# SPDX-FileCopyrightText: Copyright The Lima Authors
-# SPDX-License-Identifier: Apache-2.0
-
-set -eu -o pipefail
-
-scriptdir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-# shellcheck source=common.inc.sh
-source "${scriptdir}/common.inc.sh"
-
-if [ "$#" -ne 1 ]; then
-	ERROR "Usage: $0 NAME"
-	exit 1
-fi
-
-NAME="$1"
-
-INFO "Testing --preserve-env flag"
-
-# Environment variable propagation with --preserve-env
-INFO "=== Environment variable propagation with --preserve-env ==="
-test_var_name="LIMA_TEST_PRESERVE_ENV_VAR"
-test_var_value="test-value-${RANDOM}"
-
-got="$(LIMA_SHELLENV_ALLOW="LIMA_TEST_*" LIMA_TEST_PRESERVE_ENV_VAR="$test_var_value" limactl shell --preserve-env "$NAME" printenv "$test_var_name" 2>/dev/null || echo "NOT_FOUND")"
-INFO "$test_var_name: expected=${test_var_value}, got=${got}"
-if [ "$got" != "$test_var_value" ]; then
-	ERROR "Environment variable was not propagated with --preserve-env"
-	exit 1
-fi
-
-# Clean up before next test
-unset LIMA_TEST_PRESERVE_ENV_VAR
-unset LIMA_SHELLENV_ALLOW
-
-# Environment variable NOT propagated without --preserve-env
-INFO "=== Environment variable NOT propagated without --preserve-env ==="
-got_without_flag="$(LIMA_TEST_PRESERVE_ENV_VAR="$test_var_value" limactl shell "$NAME" printenv "$test_var_name" 2>/dev/null || echo "NOT_FOUND")"
-INFO "$test_var_name without --preserve-env: got=${got_without_flag}"
-if [ "$got_without_flag" != "NOT_FOUND" ]; then
-	ERROR "Environment variable was unexpectedly propagated without --preserve-env"
-	exit 1
-fi
-
-# Blocked environment variables should not be propagated even with --preserve-env
-INFO "=== Blocked environment variables are not propagated with --preserve-env ==="
-blocked_var_name="HOME"
-fake_home="/tmp/fake-home-${RANDOM}"
-got_blocked="$(HOME="$fake_home" limactl shell --preserve-env "$NAME" printenv "$blocked_var_name" 2>/dev/null || echo "NOT_FOUND")"
-INFO "$blocked_var_name: host=${fake_home}, guest=${got_blocked}"
-if [ "$got_blocked" = "$fake_home" ]; then
-	ERROR "Blocked environment variable $blocked_var_name was propagated"
-	exit 1
-fi
-
-# LIMA_SHELLENV_BLOCK functionality
-INFO "=== LIMA_SHELLENV_BLOCK with custom pattern ==="
-custom_test_var="LIMA_TEST_CUSTOM_BLOCK"
-custom_test_value="should-be-blocked"
-got_custom_blocked="$(LIMA_SHELLENV_BLOCK="+LIMA_TEST_CUSTOM_*" LIMA_TEST_CUSTOM_BLOCK="$custom_test_value" limactl shell --preserve-env "$NAME" printenv "$custom_test_var" 2>/dev/null || echo "NOT_FOUND")"
-INFO "$custom_test_var with LIMA_SHELLENV_BLOCK: got=${got_custom_blocked}"
-if [ "$got_custom_blocked" != "NOT_FOUND" ]; then
-	ERROR "Custom blocked environment variable was propagated"
-	exit 1
-fi
-
-# Clean up before next test
-unset LIMA_TEST_CUSTOM_BLOCK 2>/dev/null || true
-unset LIMA_SHELLENV_BLOCK 2>/dev/null || true
-
-# LIMA_SHELLENV_ALLOW functionality
-INFO "=== LIMA_SHELLENV_ALLOW with custom pattern ==="
-allow_test_var="LIMA_TEST_ALLOW_VAR"
-allow_test_value="should-be-allowed"
-got_allowed="$(LIMA_SHELLENV_ALLOW="LIMA_TEST_ALLOW_*" LIMA_TEST_ALLOW_VAR="$allow_test_value" limactl shell --preserve-env "$NAME" printenv "$allow_test_var" 2>/dev/null || echo "NOT_FOUND")"
-INFO "$allow_test_var with LIMA_SHELLENV_ALLOW: got=${got_allowed}"
-if [ "$got_allowed" != "$allow_test_value" ]; then
-	ERROR "Allowed environment variable was not propagated"
-	exit 1
-fi
-
-# Non-allowed variables are blocked when LIMA_SHELLENV_ALLOW is set
-INFO "=== Non-allowed variables are blocked when LIMA_SHELLENV_ALLOW is set ==="
-other_test_var="LIMA_TEST_OTHER_VAR"
-other_test_value="should-be-blocked"
-got_other="$(LIMA_SHELLENV_ALLOW="LIMA_TEST_ALLOW_*" LIMA_TEST_OTHER_VAR="$other_test_value" limactl shell --preserve-env "$NAME" printenv "$other_test_var" 2>/dev/null || echo "NOT_FOUND")"
-INFO "$other_test_var with LIMA_SHELLENV_ALLOW (should be blocked): got=${got_other}"
-if [ "$got_other" != "NOT_FOUND" ]; then
-	ERROR "Non-allowed environment variable was propagated when LIMA_SHELLENV_ALLOW was set"
-	exit 1
-fi
-
-INFO "All --preserve-env tests passed"
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index e3a586a7..d786d6df 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -34,8 +34,7 @@ fi
 INFO "Validating \"$FILE_HOST\""
 limactl validate "$FILE_HOST"
 
-# --cpus=1 is needed for running vz on GHA: https://github.com/lima-vm/lima/pull/1511#issuecomment-1574937888
-LIMACTL_CREATE=(limactl --tty=false create --cpus=1 --memory=1)
+LIMACTL_CREATE=(limactl --tty=false create)
 
 CONTAINER_ENGINE="nerdctl"
 
@@ -153,6 +152,7 @@ function diagnose() {
 	mkdir -p failure-logs
 	cp -pf "$HOME_HOST/.lima/${NAME}"/*.log failure-logs/
 	limactl shell "$NAME" sudo cat /var/log/cloud-init-output.log | tee failure-logs/cloud-init-output.log
+	limactl shell "$NAME" sh -c "command -v journalctl >/dev/null && sudo journalctl -b --no-pager" >failure-logs/journal.log
 	set +x -e
 }
 
@@ -316,13 +316,6 @@ limactl shell "$NAME" bash -c "echo 'foo \"bar\"'"
 
 if [[ -n ${CHECKS["systemd"]} ]]; then
 	set -x
-	# agetty segfaults on Ubuntu 25.04 (x86_64)
-	# > __strncmp_evex () at ../sysdeps/x86_64/multiarch/strcmp-evex.S:316
-	# Should be fixed in Ubuntu 25.10 with util-linux >= 2.41
-	# https://github.com/lima-vm/lima/pull/3643#issuecomment-3006788732
-	if limactl shell "$NAME" systemctl -q is-failed serial-getty@ttyS0.service; then
-		limactl shell "$NAME" sudo systemctl reset-failed serial-getty@ttyS0.service
-	fi
 	if ! limactl shell "$NAME" systemctl is-system-running --wait; then
 		ERROR '"systemctl is-system-running" failed'
 		diagnose "$NAME"
@@ -339,10 +332,6 @@ if [[ -n ${CHECKS["mount-home"]} ]]; then
 	"${scriptdir}"/test-mount-home.sh "$NAME"
 fi
 
-if [[ -n ${CHECKS["preserve-env"]} ]]; then
-	"${scriptdir}"/test-preserve-env.sh "$NAME"
-fi
-
 if [[ -n ${CHECKS["ssh-over-vsock"]} ]]; then
 	if [[ "$(limactl ls "${NAME}" --yq .vmType)" == "vz" ]]; then
 		INFO "Testing SSH over vsock"
@@ -433,24 +422,34 @@ if [[ -n ${CHECKS["container-engine"]} ]]; then
 fi
 
 if [[ -n ${CHECKS["port-forwards"]} ]]; then
-	INFO "Testing port forwarding rules using netcat"
+	PORT_FORWARDING_CONNECTION_TIMEOUT=1
+	INFO "Testing port forwarding rules using netcat and socat with connection timeout ${PORT_FORWARDING_CONNECTION_TIMEOUT}s"
 	set -x
-	if [ "${NAME}" = "archlinux" ]; then
-		limactl shell "$NAME" sudo pacman -Syu --noconfirm openbsd-netcat
+	if [[ ${NAME} == "alpine"* ]]; then
+		limactl shell "${NAME}" sudo apk add socat
+	fi
+	if [[ ${NAME} == "archlinux" ]]; then
+		limactl shell "${NAME}" sudo pacman -Syu --noconfirm openbsd-netcat socat
 	fi
-	if [ "${NAME}" = "debian" ]; then
-		limactl shell "$NAME" sudo apt-get install -y netcat-openbsd
+	if [[ ${NAME} == "debian" || ${NAME} == "default" || ${NAME} == "docker" || ${NAME} == "test-misc" ]]; then
+		limactl shell "${NAME}" sudo apt-get install -y netcat-openbsd socat
 	fi
-	if [ "${NAME}" == "fedora" ]; then
-		limactl shell "$NAME" sudo dnf install -y nc
+	if [[ ${NAME} == "fedora" || ${NAME} == "wsl2" ]]; then
+		limactl shell "${NAME}" sudo dnf install -y nc socat
 	fi
-	if [ "${NAME}" = "opensuse" ]; then
-		limactl shell "$NAME" sudo zypper in -y netcat-openbsd
+	if [[ ${NAME} == "opensuse" ]]; then
+		limactl shell "${NAME}" sudo zypper in -y netcat-openbsd socat
 	fi
-	if limactl shell "$NAME" command -v dnf; then
-		limactl shell "$NAME" sudo dnf install -y nc
+	if limactl shell "${NAME}" command -v dnf; then
+		limactl shell "${NAME}" sudo dnf install -y nc socat
+	fi
+	if "${scriptdir}/test-port-forwarding.pl" "${NAME}" socat $PORT_FORWARDING_CONNECTION_TIMEOUT; then
+		INFO "Port forwarding rules work"
+	else
+		ERROR "Port forwarding rules do not work with socat"
+		diagnose "$NAME"
+		exit 1
 	fi
-	"${scriptdir}/test-port-forwarding.pl" "${NAME}"
 
 	if [[ -n ${CHECKS["container-engine"]} || ${NAME} == "alpine"* ]]; then
 		INFO "Testing that \"${CONTAINER_ENGINE} run\" binds to 0.0.0.0 and is forwarded to the host (non-default behavior, configured via test-port-forwarding.pl)"
diff --git a/hack/tools/go.mod b/hack/tools/go.mod
index fa207c13..ee0f061e 100644
--- a/hack/tools/go.mod
+++ b/hack/tools/go.mod
@@ -16,8 +16,8 @@ require (
 	github.com/containerd/ltag v0.3.0
 	github.com/golangci/golangci-lint/v2 v2.5.0
 	github.com/yoheimuta/protolint v0.56.4
-	google.golang.org/grpc v1.75.1
-	google.golang.org/protobuf v1.36.8
+	google.golang.org/grpc v1.76.0
+	google.golang.org/protobuf v1.36.10
 	mvdan.cc/sh/v3 v3.12.0
 )
 
@@ -224,13 +224,13 @@ require (
 	go.uber.org/zap v1.27.0 // indirect
 	golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621 // indirect
 	golang.org/x/mod v0.28.0 // indirect
-	golang.org/x/net v0.44.0 // indirect
+	golang.org/x/net v0.46.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
-	golang.org/x/sys v0.36.0 // indirect
-	golang.org/x/term v0.35.0 // indirect
-	golang.org/x/text v0.29.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
+	golang.org/x/term v0.36.0 // indirect
+	golang.org/x/text v0.30.0 // indirect
 	golang.org/x/tools v0.37.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/hack/tools/go.sum b/hack/tools/go.sum
index abcc4db5..645374ba 100644
--- a/hack/tools/go.sum
+++ b/hack/tools/go.sum
@@ -775,8 +775,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
-golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
-golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -855,16 +855,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
-golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
-golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -875,8 +875,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -993,8 +993,8 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
 google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1007,8 +1007,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
-google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
-google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
+google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
+google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -1023,8 +1023,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
-google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh b/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh
index 182dd3ac..9a458dee 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh
+++ b/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh
@@ -18,6 +18,7 @@ if [ "${LIMA_CIDATA_MOUNTTYPE}" = "reverse-sshfs" ]; then
 fi
 
 # Install or update the guestagent binary
+mkdir -p "${LIMA_CIDATA_GUEST_INSTALL_PREFIX}"/bin
 install -m 755 "${LIMA_CIDATA_MNT}"/lima-guestagent "${LIMA_CIDATA_GUEST_INSTALL_PREFIX}"/bin/lima-guestagent
 
 # Launch the guestagent service
diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go
index 888e17c5..6eac7496 100644
--- a/pkg/cidata/cidata.go
+++ b/pkg/cidata/cidata.go
@@ -400,7 +400,7 @@ func GenerateISO9660(ctx context.Context, drv driver.Driver, instDir, name strin
 		case limatype.ProvisionModeSystem, limatype.ProvisionModeUser, limatype.ProvisionModeDependency:
 			layout = append(layout, iso9660util.Entry{
 				Path:   fmt.Sprintf("provision.%s/%08d", f.Mode, i),
-				Reader: strings.NewReader(f.Script),
+				Reader: strings.NewReader(*f.Script),
 			})
 		case limatype.ProvisionModeData:
 			layout = append(layout, iso9660util.Entry{
@@ -490,7 +490,7 @@ func getBootCmds(p []limatype.Provision) []BootCmds {
 	for _, f := range p {
 		if f.Mode == limatype.ProvisionModeBoot {
 			lines := []string{}
-			for _, line := range strings.Split(f.Script, "\n") {
+			for _, line := range strings.Split(*f.Script, "\n") {
 				if line == "" {
 					continue
 				}
diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go
index 199119e7..1c372f7d 100644
--- a/pkg/driver/driver.go
+++ b/pkg/driver/driver.go
@@ -105,6 +105,7 @@ type Info struct {
 type DriverFeatures struct {
 	CanRunGUI            bool `json:"canRunGui,omitempty"`
 	DynamicSSHAddress    bool `json:"dynamicSSHAddress"`
+	StaticSSHPort        bool `json:"staticSSHPort"`
 	SkipSocketForwarding bool `json:"skipSocketForwarding"`
 	NoCloudInit          bool `json:"noCloudInit"`
 	RosettaEnabled       bool `json:"rosettaEnabled"`
diff --git a/pkg/driver/external/driver.pb.go b/pkg/driver/external/driver.pb.go
index 99026734..60dc80ad 100644
--- a/pkg/driver/external/driver.pb.go
+++ b/pkg/driver/external/driver.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.36.8
-// 	protoc version [omitted for reproducibility]
+// 	protoc-gen-go [version omitted for reproducibility]
+// 	protoc        [version omitted for reproducibility]
 // source: driver.proto
 
 package external
diff --git a/pkg/driver/external/driver_grpc.pb.go b/pkg/driver/external/driver_grpc.pb.go
index cc6e2b51..01b2a0f1 100644
--- a/pkg/driver/external/driver_grpc.pb.go
+++ b/pkg/driver/external/driver_grpc.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
 // versions:
-// - protoc-gen-go-grpc v1.5.1
-// - protoc version [omitted for reproducibility]
+// - protoc-gen-go-grpc [version omitted for reproducibility]
+// - protoc             [version omitted for reproducibility]
 // source: driver.proto
 
 package external
diff --git a/pkg/driver/qemu/qemu.go b/pkg/driver/qemu/qemu.go
index c3d3b6e3..ee7456d7 100644
--- a/pkg/driver/qemu/qemu.go
+++ b/pkg/driver/qemu/qemu.go
@@ -19,6 +19,7 @@ import (
 	"slices"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/coreos/go-semver/semver"
@@ -427,8 +428,8 @@ func defaultCPUType() limatype.CPUType {
 		limatype.S390X:   "max",
 	}
 	for arch := range cpuType {
-		if limayaml.IsNativeArch(arch) && limayaml.IsAccelOS() {
-			if limayaml.HasHostCPU() {
+		if limayaml.IsNativeArch(arch) && Accel(arch) != "tcg" {
+			if hasHostCPU() {
 				cpuType[arch] = "host"
 			}
 		}
@@ -449,7 +450,11 @@ func defaultCPUType() limatype.CPUType {
 func resolveCPUType(y *limatype.LimaYAML) string {
 	cpuType := defaultCPUType()
 	var overrideCPUType bool
-	for k, v := range y.VMOpts.QEMU.CPUType {
+	var qemuOpts limatype.QEMUOpts
+	if err := limayaml.Convert(y.VMOpts[limatype.QEMU], &qemuOpts, "vmOpts.qemu"); err != nil {
+		logrus.WithError(err).Warnf("Couldn't convert %q", y.VMOpts[limatype.QEMU])
+	}
+	for k, v := range qemuOpts.CPUType {
 		if !slices.Contains(limatype.ArchTypes, *y.Arch) {
 			logrus.Warnf("field `vmOpts.qemu.cpuType` uses unsupported arch %q", k)
 			continue
@@ -460,7 +465,11 @@ func resolveCPUType(y *limatype.LimaYAML) string {
 		}
 	}
 	if overrideCPUType {
-		y.VMOpts.QEMU.CPUType = cpuType
+		qemuOpts.CPUType = cpuType
+		if y.VMOpts == nil {
+			y.VMOpts = limatype.VMOpts{}
+		}
+		y.VMOpts[limatype.QEMU] = qemuOpts
 	}
 
 	return cpuType[*y.Arch]
@@ -490,8 +499,12 @@ func Cmdline(ctx context.Context, cfg Config) (exe string, args []string, err er
 		if version.LessThan(softMin) {
 			logrus.Warnf("QEMU %v is too old, %v or later is recommended", version, softMin)
 		}
-		if y.VMOpts.QEMU.MinimumVersion != nil && version.LessThan(*semver.New(*y.VMOpts.QEMU.MinimumVersion)) {
-			logrus.Fatalf("QEMU %v is too old, template requires %q or later", version, *y.VMOpts.QEMU.MinimumVersion)
+		var qemuOpts limatype.QEMUOpts
+		if err := limayaml.Convert(y.VMOpts[limatype.QEMU], &qemuOpts, "vmOpts.qemu"); err != nil {
+			logrus.WithError(err).Warnf("Couldn't convert %q", y.VMOpts[limatype.QEMU])
+		}
+		if qemuOpts.MinimumVersion != nil && version.LessThan(*semver.New(*qemuOpts.MinimumVersion)) {
+			logrus.Fatalf("QEMU %v is too old, template requires %q or later", version, *qemuOpts.MinimumVersion)
 		}
 	}
 
@@ -1111,8 +1124,13 @@ func Accel(arch limatype.Arch) string {
 	if limayaml.IsNativeArch(arch) {
 		switch runtime.GOOS {
 		case "darwin":
+			// TODO: return "tcg" if HVF is not available
 			return "hvf"
 		case "linux":
+			if _, err := os.Stat("/dev/kvm"); err != nil {
+				logrus.WithError(err).Warn("/dev/kvm is not available. Disabling KVM. Expect very poor performance.")
+				return "tcg"
+			}
 			return "kvm"
 		case "netbsd":
 			return "nvmm"
@@ -1260,3 +1278,43 @@ func getFirmwareVars(qemuExe string, arch limatype.Arch) (string, error) {
 
 	return "", fmt.Errorf("could not find firmware vars for %q", arch)
 }
+
+var hasSMEDarwin = sync.OnceValue(func() bool {
+	if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
+		return false
+	}
+	// golang.org/x/sys/cpu does not support inspecting the availability of SME yet
+	s, err := osutil.Sysctl(context.Background(), "hw.optional.arm.FEAT_SME")
+	if err != nil {
+		logrus.WithError(err).Debug("failed to check hw.optional.arm.FEAT_SME")
+	}
+	return s == "1"
+})
+
+func hasHostCPU() bool {
+	switch runtime.GOOS {
+	case "darwin":
+		if hasSMEDarwin() {
+			// [2025-02-05]
+			// SME is available since Apple M4 running macOS 15.2, but it was broken on macOS 15.2.
+			// It has been fixed in 15.3.
+			//
+			// https://github.com/lima-vm/lima/issues/3032
+			// https://gitlab.com/qemu-project/qemu/-/issues/2665
+			// https://gitlab.com/qemu-project/qemu/-/issues/2721
+
+			// [2025-02-12]
+			// SME got broken again after upgrading QEMU from 9.2.0 to 9.2.1 (Homebrew bottle).
+			// Possibly this regression happened in some build process rather than in QEMU itself?
+			// https://github.com/lima-vm/lima/issues/3226
+			return false
+		}
+		return true
+	case "linux":
+		return true
+	case "netbsd", "windows":
+		return false
+	}
+	// Not reached
+	return false
+}
diff --git a/pkg/driver/qemu/qemu_driver.go b/pkg/driver/qemu/qemu_driver.go
index e468e55b..339bca2c 100644
--- a/pkg/driver/qemu/qemu_driver.go
+++ b/pkg/driver/qemu/qemu_driver.go
@@ -78,22 +78,16 @@ func (l *LimaQemuDriver) Configure(inst *limatype.Instance) *driver.ConfiguredDr
 }
 
 func (l *LimaQemuDriver) Validate(ctx context.Context) error {
-	return validateConfig(ctx, l.Instance.Config)
+	if err := validateArch(ctx, l.Instance.Config); err != nil {
+		return err
+	}
+	return validateConfig(l.Instance.Config)
 }
 
-func validateConfig(ctx context.Context, cfg *limatype.LimaYAML) error {
+func validateConfig(cfg *limatype.LimaYAML) error {
 	if cfg == nil {
 		return errors.New("configuration is nil")
 	}
-	if runtime.GOOS == "darwin" {
-		var vmArch string
-		if cfg.Arch != nil {
-			vmArch = *cfg.Arch
-		}
-		if err := checkBinarySignature(ctx, vmArch); err != nil {
-			return err
-		}
-	}
 	if err := validateMountType(cfg); err != nil {
 		return err
 	}
@@ -110,15 +104,33 @@ func validateConfig(ctx context.Context, cfg *limatype.LimaYAML) error {
 		}
 	}
 
-	if cfg.VMOpts.QEMU.MinimumVersion != nil {
-		if _, err := semver.NewVersion(*cfg.VMOpts.QEMU.MinimumVersion); err != nil {
-			return fmt.Errorf("field `vmOpts.qemu.minimumVersion` must be a semvar value, got %q: %w", *cfg.VMOpts.QEMU.MinimumVersion, err)
+	var qemuOpts limatype.QEMUOpts
+	if err := limayaml.Convert(cfg.VMOpts[limatype.QEMU], &qemuOpts, "vmOpts.qemu"); err != nil {
+		return err
+	}
+	if qemuOpts.MinimumVersion != nil {
+		if _, err := semver.NewVersion(*qemuOpts.MinimumVersion); err != nil {
+			return fmt.Errorf("field `vmOpts.qemu.minimumVersion` must be a semvar value, got %q: %w", *qemuOpts.MinimumVersion, err)
 		}
 	}
 
 	return nil
 }
 
+// Helper method for checking the binary signature on macOS.
+func validateArch(ctx context.Context, cfg *limatype.LimaYAML) error {
+	if runtime.GOOS == "darwin" {
+		var vmArch string
+		if cfg.Arch != nil {
+			vmArch = *cfg.Arch
+		}
+		if err := checkBinarySignature(ctx, vmArch); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // Helper method for mount type validation.
 func validateMountType(cfg *limatype.LimaYAML) error {
 	if cfg.MountType != nil && *cfg.MountType == limatype.VIRTIOFS && runtime.GOOS != "linux" {
@@ -135,7 +147,7 @@ func validateMountType(cfg *limatype.LimaYAML) error {
 	return nil
 }
 
-func (l *LimaQemuDriver) FillConfig(ctx context.Context, cfg *limatype.LimaYAML, filePath string) error {
+func (l *LimaQemuDriver) FillConfig(_ context.Context, cfg *limatype.LimaYAML, filePath string) error {
 	if cfg.VMType == nil {
 		cfg.VMType = ptr.Of(limatype.QEMU)
 	}
@@ -146,8 +158,12 @@ func (l *LimaQemuDriver) FillConfig(ctx context.Context, cfg *limatype.LimaYAML,
 		cfg.Video.VNC.Display = ptr.Of("127.0.0.1:0,to=9")
 	}
 
-	if cfg.VMOpts.QEMU.CPUType == nil {
-		cfg.VMOpts.QEMU.CPUType = limatype.CPUType{}
+	var qemuOpts limatype.QEMUOpts
+	if err := limayaml.Convert(cfg.VMOpts[limatype.QEMU], &qemuOpts, "vmOpts.qemu"); err != nil {
+		logrus.WithError(err).Warnf("Couldn't convert %q", cfg.VMOpts[limatype.QEMU])
+	}
+	if qemuOpts.CPUType == nil {
+		qemuOpts.CPUType = limatype.CPUType{}
 	}
 
 	//nolint:staticcheck // Migration of top-level CPUTYPE if specified
@@ -157,13 +173,22 @@ func (l *LimaQemuDriver) FillConfig(ctx context.Context, cfg *limatype.LimaYAML,
 			if v == "" {
 				continue
 			}
-			if existing, ok := cfg.VMOpts.QEMU.CPUType[arch]; ok && existing != "" && existing != v {
+			if existing, ok := qemuOpts.CPUType[arch]; ok && existing != "" && existing != v {
 				logrus.Warnf("Conflicting cpuType for arch %q: top-level=%q, vmOpts.qemu=%q; using vmOpts.qemu value", arch, v, existing)
 				continue
 			}
-			cfg.VMOpts.QEMU.CPUType[arch] = v
+			qemuOpts.CPUType[arch] = v
 		}
 		cfg.CPUType = nil
+
+		var opts any
+		if err := limayaml.Convert(qemuOpts, &opts, ""); err != nil {
+			logrus.WithError(err).Warnf("Couldn't convert %+v", qemuOpts)
+		}
+		if cfg.VMOpts == nil {
+			cfg.VMOpts = limatype.VMOpts{}
+		}
+		cfg.VMOpts[limatype.QEMU] = opts
 	}
 
 	mountTypesUnsupported := make(map[string]struct{})
@@ -198,7 +223,7 @@ func (l *LimaQemuDriver) FillConfig(ctx context.Context, cfg *limatype.LimaYAML,
 		return fmt.Errorf("mount type %q is explicitly unsupported", *cfg.MountType)
 	}
 
-	return validateConfig(ctx, cfg)
+	return validateConfig(cfg)
 }
 
 func (l *LimaQemuDriver) BootScripts() (map[string][]byte, error) {
diff --git a/pkg/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go
index 8d1a0577..ec45e398 100644
--- a/pkg/driver/vz/vm_darwin.go
+++ b/pkg/driver/vz/vm_darwin.go
@@ -602,7 +602,12 @@ func attachFolderMounts(inst *limatype.Instance, vmConfig *vz.VirtualMachineConf
 		}
 	}
 
-	if *inst.Config.VMOpts.VZ.Rosetta.Enabled {
+	var vzOpts limatype.VZOpts
+	if err := limayaml.Convert(inst.Config.VMOpts[limatype.VZ], &vzOpts, "vmOpts.vz"); err != nil {
+		logrus.WithError(err).Warnf("Couldn't convert %q", inst.Config.VMOpts[limatype.VZ])
+	}
+
+	if vzOpts.Rosetta.Enabled != nil && *vzOpts.Rosetta.Enabled {
 		logrus.Info("Setting up Rosetta share")
 		directorySharingDeviceConfig, err := createRosettaDirectoryShareConfiguration()
 		if err != nil {
diff --git a/pkg/driver/vz/vz_driver_darwin.go b/pkg/driver/vz/vz_driver_darwin.go
index ec901b73..927e9b90 100644
--- a/pkg/driver/vz/vz_driver_darwin.go
+++ b/pkg/driver/vz/vz_driver_darwin.go
@@ -108,13 +108,20 @@ func (l *LimaVzDriver) Configure(inst *limatype.Instance) *driver.ConfiguredDriv
 		}
 	}
 
+	var vzOpts limatype.VZOpts
+	if l.Instance.Config.VMOpts[limatype.VZ] != nil {
+		if err := limayaml.Convert(l.Instance.Config.VMOpts[limatype.VZ], &vzOpts, "vmOpts.vz"); err != nil {
+			logrus.WithError(err).Warnf("Couldn't convert %q", l.Instance.Config.VMOpts[limatype.VZ])
+		}
+	}
+
 	if runtime.GOOS == "darwin" && limayaml.IsNativeArch(limatype.AARCH64) {
-		if l.Instance.Config.VMOpts.VZ.Rosetta.Enabled != nil {
-			l.rosettaEnabled = *l.Instance.Config.VMOpts.VZ.Rosetta.Enabled
+		if vzOpts.Rosetta.Enabled != nil {
+			l.rosettaEnabled = *vzOpts.Rosetta.Enabled
 		}
 	}
-	if l.Instance.Config.VMOpts.VZ.Rosetta.BinFmt != nil {
-		l.rosettaBinFmt = *l.Instance.Config.VMOpts.VZ.Rosetta.BinFmt
+	if vzOpts.Rosetta.BinFmt != nil {
+		l.rosettaBinFmt = *vzOpts.Rosetta.BinFmt
 	}
 
 	return &driver.ConfiguredDriver{
@@ -131,22 +138,36 @@ func (l *LimaVzDriver) FillConfig(ctx context.Context, cfg *limatype.LimaYAML, _
 		cfg.MountType = ptr.Of(limatype.VIRTIOFS)
 	}
 
+	var vzOpts limatype.VZOpts
+	if err := limayaml.Convert(cfg.VMOpts[limatype.VZ], &vzOpts, "vmOpts.vz"); err != nil {
+		logrus.WithError(err).Warnf("Couldn't convert %q", cfg.VMOpts[limatype.VZ])
+	}
+
 	//nolint:staticcheck // Migration of top-level Rosetta if specified
-	if (cfg.VMOpts.VZ.Rosetta.Enabled == nil && cfg.VMOpts.VZ.Rosetta.BinFmt == nil) && (!isEmpty(cfg.Rosetta)) {
+	if (vzOpts.Rosetta.Enabled == nil && vzOpts.Rosetta.BinFmt == nil) && (!isEmpty(cfg.Rosetta)) {
 		logrus.Debug("Migrating top-level Rosetta configuration to vmOpts.vz.rosetta")
-		cfg.VMOpts.VZ.Rosetta = cfg.Rosetta
+		vzOpts.Rosetta = cfg.Rosetta
 	}
 	//nolint:staticcheck // Warning about both top-level and vmOpts.vz.Rosetta being set
-	if (cfg.VMOpts.VZ.Rosetta.Enabled != nil && cfg.VMOpts.VZ.Rosetta.BinFmt != nil) && (!isEmpty(cfg.Rosetta)) {
+	if (vzOpts.Rosetta.Enabled != nil && vzOpts.Rosetta.BinFmt != nil) && (!isEmpty(cfg.Rosetta)) {
 		logrus.Warn("Both top-level 'rosetta' and 'vmOpts.vz.rosetta' are configured. Using vmOpts.vz.rosetta. Top-level 'rosetta' is deprecated.")
 	}
 
-	if cfg.VMOpts.VZ.Rosetta.Enabled == nil {
-		cfg.VMOpts.VZ.Rosetta.Enabled = ptr.Of(false)
+	if vzOpts.Rosetta.Enabled == nil {
+		vzOpts.Rosetta.Enabled = ptr.Of(false)
+	}
+	if vzOpts.Rosetta.BinFmt == nil {
+		vzOpts.Rosetta.BinFmt = ptr.Of(false)
+	}
+
+	var opts any
+	if err := limayaml.Convert(vzOpts, &opts, ""); err != nil {
+		logrus.WithError(err).Warnf("Couldn't convert %+v", vzOpts)
 	}
-	if cfg.VMOpts.VZ.Rosetta.BinFmt == nil {
-		cfg.VMOpts.VZ.Rosetta.BinFmt = ptr.Of(false)
+	if cfg.VMOpts == nil {
+		cfg.VMOpts = limatype.VMOpts{}
 	}
+	cfg.VMOpts[limatype.VZ] = opts
 
 	return validateConfig(ctx, cfg)
 }
diff --git a/pkg/driver/wsl2/wsl_driver_windows.go b/pkg/driver/wsl2/wsl_driver_windows.go
index 8c48c0ad..4468dbf5 100644
--- a/pkg/driver/wsl2/wsl_driver_windows.go
+++ b/pkg/driver/wsl2/wsl_driver_windows.go
@@ -309,6 +309,7 @@ func (l *LimaWslDriver) Info() driver.Info {
 
 	info.Features = driver.DriverFeatures{
 		DynamicSSHAddress:    true,
+		StaticSSHPort:        true,
 		SkipSocketForwarding: true,
 		NoCloudInit:          true,
 		CanRunGUI:            l.canRunGUI(),
diff --git a/pkg/driverutil/instance.go b/pkg/driverutil/instance.go
index dfb1422c..ce055ef7 100644
--- a/pkg/driverutil/instance.go
+++ b/pkg/driverutil/instance.go
@@ -22,7 +22,6 @@ func CreateConfiguredDriver(inst *limatype.Instance, sshLocalPort int) (*driver.
 		return nil, fmt.Errorf("unknown or unsupported VM type: %s", *limaDriver)
 	}
 
-	inst.SSHLocalPort = sshLocalPort
 	if extDriver != nil {
 		extDriver.Logger.Debugf("Using external driver %q", extDriver.Name)
 		if extDriver.Client == nil || extDriver.Command == nil {
@@ -36,9 +35,15 @@ func CreateConfiguredDriver(inst *limatype.Instance, sshLocalPort int) (*driver.
 			extDriver.InstanceName = inst.Name
 		}
 
+		if !extDriver.Client.Info().Features.StaticSSHPort {
+			inst.SSHLocalPort = sshLocalPort
+		}
 		return extDriver.Client.Configure(inst), nil
 	}
 
 	logrus.Debugf("Using internal driver %q", intDriver.Info().Name)
+	if !intDriver.Info().Features.StaticSSHPort {
+		inst.SSHLocalPort = sshLocalPort
+	}
 	return intDriver.Configure(inst), nil
 }
diff --git a/pkg/envutil/envutil.go b/pkg/envutil/envutil.go
index 023b9e0e..d5147d5a 100644
--- a/pkg/envutil/envutil.go
+++ b/pkg/envutil/envutil.go
@@ -4,7 +4,9 @@
 package envutil
 
 import (
+	"fmt"
 	"os"
+	"regexp"
 	"slices"
 	"strings"
 
@@ -42,27 +44,55 @@ var defaultBlockList = []string{
 	"_*", // Variables starting with underscore are typically internal
 }
 
+func validatePattern(pattern string) error {
+	invalidChar := regexp.MustCompile(`([^a-zA-Z0-9_*])`)
+	if matches := invalidChar.FindStringSubmatch(pattern); matches != nil {
+		invalidCharacter := matches[1]
+		pos := strings.Index(pattern, invalidCharacter)
+		return fmt.Errorf("pattern %q contains invalid character %q at position %d",
+			pattern, invalidCharacter, pos)
+	}
+	return nil
+}
+
 // getBlockList returns the list of environment variable patterns to be blocked.
-// The second return value indicates whether the list was explicitly set via LIMA_SHELLENV_BLOCK.
-func getBlockList() ([]string, bool) {
+func getBlockList() []string {
 	blockEnv := os.Getenv("LIMA_SHELLENV_BLOCK")
 	if blockEnv == "" {
-		return defaultBlockList, false
+		return defaultBlockList
 	}
-	after, found := strings.CutPrefix(blockEnv, "+")
-	if !found {
-		return parseEnvList(blockEnv), true
+
+	shouldAppend := strings.HasPrefix(blockEnv, "+")
+	patterns := parseEnvList(strings.TrimPrefix(blockEnv, "+"))
+
+	for _, pattern := range patterns {
+		if err := validatePattern(pattern); err != nil {
+			logrus.Fatalf("Invalid LIMA_SHELLENV_BLOCK pattern: %v", err)
+		}
+	}
+
+	if shouldAppend {
+		return slices.Concat(defaultBlockList, patterns)
 	}
-	return slices.Concat(defaultBlockList, parseEnvList(after)), true
+	return patterns
 }
 
 // getAllowList returns the list of environment variable patterns to be allowed.
-// The second return value indicates whether the list was explicitly set via LIMA_SHELLENV_ALLOW.
-func getAllowList() ([]string, bool) {
-	if allowEnv := os.Getenv("LIMA_SHELLENV_ALLOW"); allowEnv != "" {
-		return parseEnvList(allowEnv), true
+func getAllowList() []string {
+	allowEnv := os.Getenv("LIMA_SHELLENV_ALLOW")
+	if allowEnv == "" {
+		return nil
 	}
-	return nil, false
+
+	patterns := parseEnvList(allowEnv)
+
+	for _, pattern := range patterns {
+		if err := validatePattern(pattern); err != nil {
+			logrus.Fatalf("Invalid LIMA_SHELLENV_ALLOW pattern: %v", err)
+		}
+	}
+
+	return patterns
 }
 
 func parseEnvList(envList string) []string {
@@ -82,8 +112,14 @@ func matchesPattern(name, pattern string) bool {
 		return true
 	}
 
-	prefix, found := strings.CutSuffix(pattern, "*")
-	return found && strings.HasPrefix(name, prefix)
+	regexPattern := strings.ReplaceAll(pattern, "*", ".*")
+	regexPattern = "^" + regexPattern + "$"
+
+	match, err := regexp.MatchString(regexPattern, name)
+	if err != nil {
+		return false
+	}
+	return match
 }
 
 func matchesAnyPattern(name string, patterns []string) bool {
@@ -96,17 +132,10 @@ func matchesAnyPattern(name string, patterns []string) bool {
 // It returns a slice of environment variables that are not blocked by the current configuration.
 // The filtering is controlled by LIMA_SHELLENV_BLOCK and LIMA_SHELLENV_ALLOW environment variables.
 func FilterEnvironment() []string {
-	allowList, isAllowListSet := getAllowList()
-	blockList, isBlockListSet := getBlockList()
-
-	if isBlockListSet && isAllowListSet {
-		logrus.Warn("Both LIMA_SHELLENV_BLOCK and LIMA_SHELLENV_ALLOW are set. Block list will be ignored.")
-		blockList = nil
-	}
 	return filterEnvironmentWithLists(
 		os.Environ(),
-		allowList,
-		blockList,
+		getAllowList(),
+		getBlockList(),
 	)
 }
 
@@ -121,10 +150,7 @@ func filterEnvironmentWithLists(env, allowList, blockList []string) []string {
 
 		name := parts[0]
 
-		if len(allowList) > 0 {
-			if !matchesAnyPattern(name, allowList) {
-				continue
-			}
+		if len(allowList) > 0 && matchesAnyPattern(name, allowList) {
 			filtered = append(filtered, envVar)
 			continue
 		}
diff --git a/pkg/guestagent/api/guestservice.pb.go b/pkg/guestagent/api/guestservice.pb.go
index b17b7156..ced2552b 100644
--- a/pkg/guestagent/api/guestservice.pb.go
+++ b/pkg/guestagent/api/guestservice.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
-// 	protoc-gen-go v1.36.8
-// 	protoc version [omitted for reproducibility]
+// 	protoc-gen-go [version omitted for reproducibility]
+// 	protoc        [version omitted for reproducibility]
 // source: guestservice.proto
 
 package api
diff --git a/pkg/guestagent/api/guestservice_grpc.pb.go b/pkg/guestagent/api/guestservice_grpc.pb.go
index 65c6086c..bcceba25 100644
--- a/pkg/guestagent/api/guestservice_grpc.pb.go
+++ b/pkg/guestagent/api/guestservice_grpc.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
 // versions:
-// - protoc-gen-go-grpc v1.5.1
-// - protoc version [omitted for reproducibility]
+// - protoc-gen-go-grpc [version omitted for reproducibility]
+// - protoc             [version omitted for reproducibility]
 // source: guestservice.proto
 
 package api
diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go
index 4593b89b..d66f2812 100644
--- a/pkg/hostagent/hostagent.go
+++ b/pkg/hostagent/hostagent.go
@@ -60,7 +60,8 @@ type HostAgent struct {
 	portForwarder     *portForwarder // legacy SSH port forwarder
 	grpcPortForwarder *portfwd.Forwarder
 
-	onClose []func() error // LIFO
+	onClose   []func() error // LIFO
+	onCloseMu sync.Mutex
 
 	driver   driver.Driver
 	signalCh chan os.Signal
@@ -140,9 +141,6 @@ func New(ctx context.Context, instName string, stdout io.Writer, signalCh chan o
 	if err != nil {
 		return nil, err
 	}
-	if *inst.Config.VMType == limatype.WSL2 {
-		sshLocalPort = inst.SSHLocalPort
-	}
 
 	var udpDNSLocalPort, tcpDNSLocalPort int
 	if *inst.Config.HostResolver.Enabled {
@@ -160,6 +158,7 @@ func New(ctx context.Context, instName string, stdout io.Writer, signalCh chan o
 	if err != nil {
 		return nil, fmt.Errorf("failed to create driver instance: %w", err)
 	}
+	sshLocalPort = inst.SSHLocalPort
 
 	vSockPort := limaDriver.Info().VsockPort
 	virtioPort := limaDriver.Info().VirtioPort
@@ -462,26 +461,19 @@ func (a *HostAgent) startRoutinesAndWait(ctx context.Context, errCh <-chan error
 		stRunning.Running = true
 		a.emitEvent(ctx, events.Event{Status: stRunning})
 	}()
-	for {
-		select {
-		case driverErr := <-errCh:
-			logrus.Infof("Driver stopped due to error: %q", driverErr)
-			cancelHA()
-			if closeErr := a.close(); closeErr != nil {
-				logrus.WithError(closeErr).Warn("an error during shutting down the host agent")
-			}
-			err := a.driver.Stop(ctx)
-			return err
-		case sig := <-a.signalCh:
-			logrus.Infof("Received %s, shutting down the host agent", osutil.SignalName(sig))
-			cancelHA()
-			if closeErr := a.close(); closeErr != nil {
-				logrus.WithError(closeErr).Warn("an error during shutting down the host agent")
-			}
-			err := a.driver.Stop(ctx)
-			return err
-		}
-	}
+	// wait for either the driver to stop or a signal to shut down
+	select {
+	case driverErr := <-errCh:
+		logrus.Infof("Driver stopped due to error: %q", driverErr)
+	case sig := <-a.signalCh:
+		logrus.Infof("Received %s, shutting down the host agent", osutil.SignalName(sig))
+	}
+	// close the host agent routines before cancelling the context
+	if closeErr := a.close(); closeErr != nil {
+		logrus.WithError(closeErr).Warn("an error during shutting down the host agent")
+	}
+	cancelHA()
+	return a.driver.Stop(ctx)
 }
 
 func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) {
@@ -502,7 +494,7 @@ func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error {
 		}
 		logrus.Info(msg)
 	}
-	a.onClose = append(a.onClose, func() error {
+	a.cleanUp(func() error {
 		logrus.Debugf("shutting down the SSH master")
 		if exitMasterErr := ssh.ExitMaster(a.instSSHAddress, a.sshLocalPort, a.sshConfig); exitMasterErr != nil {
 			logrus.WithError(exitMasterErr).Warn("failed to exit SSH master")
@@ -531,7 +523,7 @@ sudo chown -R "${USER}" /run/host-services`
 		if err != nil {
 			errs = append(errs, err)
 		}
-		a.onClose = append(a.onClose, func() error {
+		a.cleanUp(func() error {
 			var unmountErrs []error
 			for _, m := range mounts {
 				if unmountErr := m.close(); unmountErr != nil {
@@ -542,7 +534,7 @@ sudo chown -R "${USER}" /run/host-services`
 		})
 	}
 	if len(a.instConfig.AdditionalDisks) > 0 {
-		a.onClose = append(a.onClose, func() error {
+		a.cleanUp(func() error {
 			var unlockErrs []error
 			for _, d := range a.instConfig.AdditionalDisks {
 				disk, inspectErr := store.InspectDisk(d.Name)
@@ -601,7 +593,7 @@ sudo chown -R "${USER}" /run/host-services`
 			errs = append(errs, err)
 		}
 	}
-	a.onClose = append(a.onClose, func() error {
+	a.cleanUp(func() error {
 		var rmErrs []error
 		for _, rule := range a.instConfig.CopyToHost {
 			if rule.DeleteOnStop {
@@ -616,7 +608,17 @@ sudo chown -R "${USER}" /run/host-services`
 	return errors.Join(errs...)
 }
 
+// cleanUp registers a cleanup function to be called when the host agent is stopped.
+// The cleanup functions are called before the context is cancelled, in the reverse order of their registration.
+func (a *HostAgent) cleanUp(fn func() error) {
+	a.onCloseMu.Lock()
+	defer a.onCloseMu.Unlock()
+	a.onClose = append(a.onClose, fn)
+}
+
 func (a *HostAgent) close() error {
+	a.onCloseMu.Lock()
+	defer a.onCloseMu.Unlock()
 	logrus.Infof("Shutting down the host agent")
 	var errs []error
 	for i := len(a.onClose) - 1; i >= 0; i-- {
@@ -632,7 +634,7 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 	// TODO: use vSock (when QEMU for macOS gets support for vSock)
 
 	// Setup all socket forwards and defer their teardown
-	if !(a.driver.Info().Features.DynamicSSHAddress) {
+	if !(a.driver.Info().Features.SkipSocketForwarding) {
 		logrus.Debugf("Forwarding unix sockets")
 		for _, rule := range a.instConfig.PortForwards {
 			if rule.GuestSocket != "" {
@@ -645,7 +647,7 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 	localUnix := filepath.Join(a.instDir, filenames.GuestAgentSock)
 	remoteUnix := "/run/lima-guestagent.sock"
 
-	a.onClose = append(a.onClose, func() error {
+	a.cleanUp(func() error {
 		logrus.Debugf("Stop forwarding unix sockets")
 		var errs []error
 		for _, rule := range a.instConfig.PortForwards {
@@ -679,6 +681,9 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 		}
 	}()
 
+	// ensure close before ctx is cancelled
+	a.cleanUp(a.grpcPortForwarder.Close)
+
 	for {
 		if a.client == nil || !isGuestAgentSocketAccessible(ctx, a.client) {
 			if a.driver.ForwardGuestAgent() {
@@ -811,7 +816,6 @@ func (a *HostAgent) processGuestAgentEvents(ctx context.Context, client *guestag
 			a.grpcPortForwarder.OnEvent(ctx, client, ev)
 		}
 	}
-	defer a.grpcPortForwarder.Close()
 
 	if err := client.Events(ctx, onEvent); err != nil {
 		if status.Code(err) == codes.Canceled {
diff --git a/pkg/hostagent/mount.go b/pkg/hostagent/mount.go
index 1763e15b..412afab1 100644
--- a/pkg/hostagent/mount.go
+++ b/pkg/hostagent/mount.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/lima-vm/lima/v2/pkg/ioutilx"
 	"github.com/lima-vm/lima/v2/pkg/limatype"
+	"github.com/lima-vm/lima/v2/pkg/sshutil"
 )
 
 type mount struct {
@@ -76,6 +77,10 @@ func (a *HostAgent) setupMount(ctx context.Context, m limatype.Mount) (*mount, e
 		// 2. these errors still imply additional coms over mux socket, which resulted sftp-server to fail more often statistically during test runs.
 		// It is reasonable to disable this on Windows if required feature is not fully operational.
 		rsf.SSHConfig.Persist = false
+
+		// HostAgent's `sshConfig` already has some ControlMaster related args in `AdditionalArgs`,
+		// so it is necessary to remove them to avoid overrides above `Persist=false`.
+		rsf.SSHConfig.AdditionalArgs = sshutil.DisableControlMasterOptsFromSSHArgs(rsf.SSHConfig.AdditionalArgs)
 	}
 	if err := rsf.Prepare(); err != nil {
 		return nil, fmt.Errorf("failed to prepare reverse sshfs for %q on %q: %w", resolvedLocation, *m.MountPoint, err)
diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go
index 148d6785..a0d8b170 100644
--- a/pkg/hostagent/requirements.go
+++ b/pkg/hostagent/requirements.go
@@ -13,6 +13,7 @@ import (
 	"github.com/sirupsen/logrus"
 
 	"github.com/lima-vm/lima/v2/pkg/limatype"
+	"github.com/lima-vm/lima/v2/pkg/sshutil"
 )
 
 func (a *HostAgent) waitForRequirements(label string, requirements []requirement) error {
@@ -101,7 +102,15 @@ func (a *HostAgent) waitForRequirement(r requirement) error {
 	if err != nil {
 		return err
 	}
-	stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, script, r.description)
+	sshConfig := a.sshConfig
+	if r.noMaster {
+		sshConfig = &ssh.SSHConfig{
+			ConfigFile:     sshConfig.ConfigFile,
+			Persist:        false,
+			AdditionalArgs: sshutil.DisableControlMasterOptsFromSSHArgs(sshConfig.AdditionalArgs),
+		}
+	}
+	stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, sshConfig, script, r.description)
 	logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
 	if err != nil {
 		return fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err)
@@ -114,6 +123,7 @@ type requirement struct {
 	script      string
 	debugHint   string
 	fatal       bool
+	noMaster    bool
 }
 
 func (a *HostAgent) essentialRequirements() []requirement {
@@ -128,8 +138,17 @@ true
 Make sure that the YAML field "ssh.localPort" is not used by other processes on the host.
 If any private key under ~/.ssh is protected with a passphrase, you need to have ssh-agent to be running.
 `,
+			noMaster: true,
 		})
+	startControlMasterReq := requirement{
+		description: "Explicitly start ssh ControlMaster",
+		script: `#!/bin/bash
+true
+`,
+		debugHint: `The persistent ssh ControlMaster should be started immediately.`,
+	}
 	if *a.instConfig.Plain {
+		req = append(req, startControlMasterReq)
 		return req
 	}
 	req = append(req,
@@ -147,6 +166,7 @@ fi
 Terminating the session will break the persistent SSH tunnel, so
 it must not be created until the session reset is done.
 `,
+			noMaster: true,
 		})
 
 	if *a.instConfig.MountType == limatype.REVSSHFS && len(a.instConfig.Mounts) > 0 {
@@ -176,6 +196,8 @@ fi
 `,
 			debugHint: `Append "user_allow_other" to /etc/fuse.conf (/etc/fuse3.conf) in the guest`,
 		})
+	} else {
+		req = append(req, startControlMasterReq)
 	}
 	return req
 }
diff --git a/pkg/imgutil/nativeimgutil/nativeimgutil.go b/pkg/imgutil/nativeimgutil/nativeimgutil.go
index 3fda1ab0..2eb1e2ca 100644
--- a/pkg/imgutil/nativeimgutil/nativeimgutil.go
+++ b/pkg/imgutil/nativeimgutil/nativeimgutil.go
@@ -130,6 +130,9 @@ func convertRawToRaw(source, dest string, size *int64) error {
 		if err := containerdfs.CopyFile(dest, source); err != nil {
 			return fmt.Errorf("failed to copy %q into %q: %w", source, dest, err)
 		}
+		if err := os.Chmod(dest, 0o644); err != nil {
+			return fmt.Errorf("failed to set permissions on %q: %w", dest, err)
+		}
 	}
 	if size != nil {
 		logrus.Infof("Expanding to %s", units.BytesSize(float64(*size)))
diff --git a/pkg/instance/clone.go b/pkg/instance/clone.go
index f4d1a013..4f886deb 100644
--- a/pkg/instance/clone.go
+++ b/pkg/instance/clone.go
@@ -22,7 +22,11 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/store"
 )
 
-func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string) (*limatype.Instance, error) {
+func CloneOrRename(ctx context.Context, oldInst *limatype.Instance, newInstName string, rename bool) (*limatype.Instance, error) {
+	verb := "clone"
+	if rename {
+		verb = "rename"
+	}
 	if newInstName == "" {
 		return nil, errors.New("got empty instName")
 	}
@@ -30,7 +34,7 @@ func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string)
 		return nil, fmt.Errorf("new instance name %q must be different from %q", newInstName, oldInst.Name)
 	}
 	if oldInst.Status == limatype.StatusRunning {
-		return nil, errors.New("cannot clone a running instance")
+		return nil, errors.New("cannot " + verb + " a running instance")
 	}
 
 	newInstDir, err := dirnames.InstanceDir(newInstName)
@@ -80,6 +84,9 @@ func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string)
 		if slices.Contains(filenames.NullifyOnClone, base) {
 			return os.WriteFile(dst, nil, 0o666)
 		}
+		if rename {
+			return os.Rename(path, dst)
+		}
 		// CopyFile attempts copy-on-write when supported by the filesystem
 		return continuityfs.CopyFile(dst, path)
 	}
@@ -87,6 +94,10 @@ func Clone(ctx context.Context, oldInst *limatype.Instance, newInstName string)
 	if err = filepath.WalkDir(oldInst.Dir, walkDirFn); err != nil {
 		return nil, err
 	}
-
+	if rename {
+		if err = os.RemoveAll(oldInst.Dir); err != nil {
+			return nil, err
+		}
+	}
 	return store.Inspect(ctx, newInstName)
 }
diff --git a/pkg/instance/restart.go b/pkg/instance/restart.go
index 8797f03a..00d2a5ae 100644
--- a/pkg/instance/restart.go
+++ b/pkg/instance/restart.go
@@ -9,7 +9,7 @@ import (
 	"github.com/sirupsen/logrus"
 
 	"github.com/lima-vm/lima/v2/pkg/limatype"
-	networks "github.com/lima-vm/lima/v2/pkg/networks/reconcile"
+	"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
 )
 
 const (
@@ -21,7 +21,7 @@ func Restart(ctx context.Context, inst *limatype.Instance, showProgress bool) er
 		return err
 	}
 
-	if err := networks.Reconcile(ctx, inst.Name); err != nil {
+	if err := reconcile.Reconcile(ctx, inst.Name); err != nil {
 		return err
 	}
 
@@ -36,7 +36,7 @@ func RestartForcibly(ctx context.Context, inst *limatype.Instance, showProgress
 	logrus.Info("Restarting the instance forcibly")
 	StopForcibly(inst)
 
-	if err := networks.Reconcile(ctx, inst.Name); err != nil {
+	if err := reconcile.Reconcile(ctx, inst.Name); err != nil {
 		return err
 	}
 
diff --git a/pkg/limatmpl/embed.go b/pkg/limatmpl/embed.go
index a505af1d..823e64af 100644
--- a/pkg/limatmpl/embed.go
+++ b/pkg/limatmpl/embed.go
@@ -21,6 +21,7 @@ import (
 	"github.com/lima-vm/lima/v2/pkg/limatype"
 	"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
 	"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
+	"github.com/lima-vm/lima/v2/pkg/limayaml"
 	"github.com/lima-vm/lima/v2/pkg/version/versionutil"
 	"github.com/lima-vm/lima/v2/pkg/yqutil"
 )
@@ -179,9 +180,17 @@ func (tmpl *Template) mergeBase(base *Template) error {
 			tmpl.copyField(minimumLimaVersion, minimumLimaVersion)
 		}
 	}
-	if tmpl.Config.VMOpts.QEMU.MinimumVersion != nil && base.Config.VMOpts.QEMU.MinimumVersion != nil {
-		tmplVersion := *semver.New(*tmpl.Config.VMOpts.QEMU.MinimumVersion)
-		baseVersion := *semver.New(*base.Config.VMOpts.QEMU.MinimumVersion)
+	var tmplOpts limatype.QEMUOpts
+	if err := limayaml.Convert(tmpl.Config.VMOpts[limatype.QEMU], &tmplOpts, "vmOpts.qemu"); err != nil {
+		return err
+	}
+	var baseOpts limatype.QEMUOpts
+	if err := limayaml.Convert(base.Config.VMOpts[limatype.QEMU], &baseOpts, "vmOpts.qemu"); err != nil {
+		return err
+	}
+	if tmplOpts.MinimumVersion != nil && baseOpts.MinimumVersion != nil {
+		tmplVersion := *semver.New(*tmplOpts.MinimumVersion)
+		baseVersion := *semver.New(*baseOpts.MinimumVersion)
 		if tmplVersion.LessThan(baseVersion) {
 			const minimumQEMUVersion = "vmOpts.qemu.minimumVersion"
 			tmpl.copyField(minimumQEMUVersion, minimumQEMUVersion)
@@ -654,7 +663,7 @@ func (tmpl *Template) embedAllScripts(ctx context.Context, embedAll bool) error
 				continue
 			}
 		default:
-			if p.Script != "" {
+			if p.Script != nil && *p.Script != "" {
 				continue
 			}
 		}
diff --git a/pkg/limatmpl/github.go b/pkg/limatmpl/github.go
new file mode 100644
index 00000000..816a685f
--- /dev/null
+++ b/pkg/limatmpl/github.go
@@ -0,0 +1,210 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl
+
+import (
+	"cmp"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path"
+	"strings"
+)
+
+const defaultFilename = ".lima.yaml"
+
+// transformGitHubURL transforms a github: URL to a raw.githubusercontent.com URL.
+// Input format: ORG/REPO[/PATH][@BRANCH]
+//
+// If REPO is missing, it will be set the same as ORG.
+// If BRANCH is missing, it will be queried from GitHub.
+// If PATH filename has no extension, it will get .yaml.
+// If PATH is just a directory (trailing slash), it will be set to .lima.yaml
+// IF FILE is .lima.yaml and contents looks like a symlink, it will be replaced by the symlink target.
+func transformGitHubURL(ctx context.Context, input string) (string, error) {
+	input, origBranch, _ := strings.Cut(input, "@")
+
+	parts := strings.Split(input, "/")
+	for len(parts) < 2 {
+		parts = append(parts, "")
+	}
+	org := parts[0]
+	if org == "" {
+		return "", fmt.Errorf("github: URL must contain at least an ORG, got %q", input)
+	}
+	// If REPO is omitted (github:ORG or github:ORG//PATH), default it to ORG
+	repo := cmp.Or(parts[1], org)
+	filePath := strings.Join(parts[2:], "/")
+
+	if filePath == "" {
+		filePath = defaultFilename
+	} else {
+		// If path ends with / then it's a directory, so append .lima
+		if strings.HasSuffix(filePath, "/") {
+			filePath += defaultFilename
+		}
+
+		// If the filename (excluding first char for hidden files) has no extension, add .yaml
+		filename := path.Base(filePath)
+		if !strings.Contains(filename[1:], ".") {
+			filePath += ".yaml"
+		}
+	}
+
+	// Query default branch if no branch was specified
+	branch := origBranch
+	if branch == "" {
+		var err error
+		branch, err = getGitHubDefaultBranch(ctx, org, repo)
+		if err != nil {
+			return "", fmt.Errorf("failed to get default branch for %s/%s: %w", org, repo, err)
+		}
+	}
+
+	// If filename is .lima.yaml, check if it's a symlink/redirect to another file
+	if path.Base(filePath) == defaultFilename {
+		return resolveGitHubSymlink(ctx, org, repo, branch, filePath, origBranch)
+	}
+	return githubUserContentURL(org, repo, branch, filePath), nil
+}
+
+func githubUserContentURL(org, repo, branch, filePath string) string {
+	return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", org, repo, branch, filePath)
+}
+
+func getGitHubUserContent(ctx context.Context, org, repo, branch, filePath string) (*http.Response, error) {
+	url := githubUserContentURL(org, repo, branch, filePath)
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+	req.Header.Set("User-Agent", "lima")
+	return http.DefaultClient.Do(req)
+}
+
+// getGitHubDefaultBranch queries the GitHub API to get the default branch for a repository.
+func getGitHubDefaultBranch(ctx context.Context, org, repo string) (string, error) {
+	apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", org, repo)
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, http.NoBody)
+	if err != nil {
+		return "", fmt.Errorf("failed to create request: %w", err)
+	}
+
+	req.Header.Set("User-Agent", "lima")
+	req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+	// Check for GitHub token in environment for authenticated requests (higher rate limit)
+	token := cmp.Or(os.Getenv("GH_TOKEN"), os.Getenv("GITHUB_TOKEN"))
+	if token != "" {
+		req.Header.Set("Authorization", "token "+token)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("failed to query GitHub API: %w", err)
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("failed to read GitHub API response: %w", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
+	}
+
+	var repoData struct {
+		DefaultBranch string `json:"default_branch"`
+	}
+	if err := json.Unmarshal(body, &repoData); err != nil {
+		return "", fmt.Errorf("failed to parse GitHub API response: %w", err)
+	}
+	if repoData.DefaultBranch == "" {
+		return "", fmt.Errorf("repository %s/%s has no default branch", org, repo)
+	}
+	return repoData.DefaultBranch, nil
+}
+
+// resolveGitHubSymlink checks if a file at the given path is a symlink/redirect to another file.
+// If the file contains a single line without newline, space, or colon then it's treated as a path to the actual file.
+// Returns a URL to the redirect path if found, or a URL for original path otherwise.
+func resolveGitHubSymlink(ctx context.Context, org, repo, branch, filePath, origBranch string) (string, error) {
+	resp, err := getGitHubUserContent(ctx, org, repo, branch, filePath)
+	if err != nil {
+		return "", fmt.Errorf("failed to fetch file: %w", err)
+	}
+	defer resp.Body.Close()
+
+	// Special rule for branch/tag propagation for github:ORG// requests.
+	if resp.StatusCode == http.StatusNotFound && repo == org {
+		defaultBranch, err := getGitHubDefaultBranch(ctx, org, repo)
+		if err == nil {
+			return resolveGitHubRedirect(ctx, org, repo, defaultBranch, filePath, branch)
+		}
+	}
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
+	}
+
+	// Read first 1KB to check the file content
+	buf := make([]byte, 1024)
+	n, err := resp.Body.Read(buf)
+	if err != nil && !errors.Is(err, io.EOF) {
+		return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
+	}
+	content := string(buf[:n])
+
+	// Symlink can also be a github: redirect if we are in a github:ORG// repo.
+	if repo == org && strings.HasPrefix(content, "github:") {
+		return validateGitHubRedirect(content, org, origBranch, resp.Request.URL.String())
+	}
+
+	// A symlink must be a single line (without trailing newline), no spaces, no colons
+	if !(content == "" || strings.ContainsAny(content, "\n :")) {
+		// symlink is relative to the directory of filePath
+		filePath = path.Join(path.Dir(filePath), content)
+	}
+	return githubUserContentURL(org, repo, branch, filePath), nil
+}
+
+// resolveGitHubRedirect checks if a file at the given path is a github: URL to another file within the same repo.
+// Returns the URL, or an error if the file doesn't exist, or doesn't start with github:ORG.
+func resolveGitHubRedirect(ctx context.Context, org, repo, defaultBranch, filePath, origBranch string) (string, error) {
+	// Refetch the filepath from the defaultBranch
+	resp, err := getGitHubUserContent(ctx, org, repo, defaultBranch, filePath)
+	if err != nil {
+		return "", fmt.Errorf("failed to fetch file: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("file %q not found or inaccessible: status %d", resp.Request.URL, resp.StatusCode)
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("failed to read %q content: %w", resp.Request.URL, err)
+	}
+	return validateGitHubRedirect(string(body), org, origBranch, resp.Request.URL.String())
+}
+
+func validateGitHubRedirect(body, org, origBranch, url string) (string, error) {
+	redirect, _, _ := strings.Cut(body, "\n")
+	redirect = strings.TrimSpace(redirect)
+
+	if !strings.HasPrefix(redirect, "github:"+org+"/") {
+		return "", fmt.Errorf(`redirect %q is not a "github:%s" URL (from %q)`, redirect, org, url)
+	}
+	if strings.ContainsRune(redirect, '@') {
+		return "", fmt.Errorf("redirect %q must not include a branch/tag/sha (from %q)", redirect, url)
+	}
+	// If the origBranch is empty, then we need to look up the default branch in the redirect
+	if origBranch != "" {
+		redirect += "@" + origBranch
+	}
+	return redirect, nil
+}
diff --git a/pkg/limatmpl/locator.go b/pkg/limatmpl/locator.go
index 5627b087..3a94cf4f 100644
--- a/pkg/limatmpl/locator.go
+++ b/pkg/limatmpl/locator.go
@@ -296,7 +296,7 @@ func InstNameFromYAMLPath(yamlPath string) (string, error) {
 	return s, nil
 }
 
-func TransformCustomURL(ctx context.Context, locator string) (string, error) {
+func transformCustomURL(ctx context.Context, locator string) (string, error) {
 	u, err := url.Parse(locator)
 	if err != nil || len(u.Scheme) <= 1 {
 		return locator, nil
@@ -312,6 +312,10 @@ func TransformCustomURL(ctx context.Context, locator string) (string, error) {
 		return newLocator, nil
 	}
 
+	if u.Scheme == "github" {
+		return transformGitHubURL(ctx, u.Opaque)
+	}
+
 	plugin, err := plugins.Find("url-" + u.Scheme)
 	if err != nil {
 		return "", err
@@ -341,9 +345,31 @@ func TransformCustomURL(ctx context.Context, locator string) (string, error) {
 		}
 		return "", fmt.Errorf("command %q failed: %w", cmd.String(), err)
 	}
-	newLocator := strings.TrimSpace(string(stdout))
-	if newLocator != locator {
-		logrus.Debugf("Custom locator %q replaced with %q", locator, newLocator)
+	return strings.TrimSpace(string(stdout)), nil
+}
+
+func TransformCustomURL(ctx context.Context, locator string) (string, error) {
+	seen := make(map[string]bool)
+	origLocator := locator
+	githubSchemeDetected := false
+
+	for !seen[locator] {
+		seen[locator] = true
+		if strings.HasPrefix(locator, "github:") {
+			githubSchemeDetected = true
+		}
+		newLocator, err := transformCustomURL(ctx, locator)
+		if err != nil {
+			return "", err
+		}
+		if newLocator == locator {
+			if githubSchemeDetected {
+				logrus.Warn("The github: scheme is still EXPERIMENTAL")
+			}
+			return newLocator, nil
+		}
+		logrus.Debugf("Locator %q replaced with %q", locator, newLocator)
+		locator = newLocator
 	}
-	return newLocator, nil
+	return "", fmt.Errorf("custom locator %q has a redirect loop", origLocator)
 }
diff --git a/pkg/limatype/lima_yaml.go b/pkg/limatype/lima_yaml.go
index 367f5bc9..0933e45e 100644
--- a/pkg/limatype/lima_yaml.go
+++ b/pkg/limatype/lima_yaml.go
@@ -20,7 +20,7 @@ type LimaYAML struct {
 	OS                 *OS           `yaml:"os,omitempty" json:"os,omitempty" jsonschema:"nullable"`
 	Arch               *Arch         `yaml:"arch,omitempty" json:"arch,omitempty" jsonschema:"nullable"`
 	Images             []Image       `yaml:"images,omitempty" json:"images,omitempty" jsonschema:"nullable"`
-	// Deprecated: Use VMOpts.Qemu.CPUType instead.
+	// Deprecated: Use vmOpts.qemu.cpuType instead.
 	CPUType               CPUType       `yaml:"cpuType,omitempty" json:"cpuType,omitempty" jsonschema:"nullable"`
 	CPUs                  *int          `yaml:"cpus,omitempty" json:"cpus,omitempty" jsonschema:"nullable"`
 	Memory                *string       `yaml:"memory,omitempty" json:"memory,omitempty" jsonschema:"nullable"` // go-units.RAMInBytes
@@ -51,7 +51,7 @@ type LimaYAML struct {
 	// `useHostResolver` was deprecated in Lima v0.8.1, removed in Lima v0.14.0. Use `hostResolver.enabled` instead.
 	PropagateProxyEnv *bool          `yaml:"propagateProxyEnv,omitempty" json:"propagateProxyEnv,omitempty" jsonschema:"nullable"`
 	CACertificates    CACertificates `yaml:"caCerts,omitempty" json:"caCerts,omitempty"`
-	// Deprecated: Use VMOpts.VZ.Rosetta instead.
+	// Deprecated: Use vmOpts.vz.rosetta instead.
 	Rosetta              Rosetta `yaml:"rosetta,omitempty" json:"rosetta,omitempty"`
 	Plain                *bool   `yaml:"plain,omitempty" json:"plain,omitempty" jsonschema:"nullable"`
 	TimeZone             *string `yaml:"timezone,omitempty" json:"timezone,omitempty" jsonschema:"nullable"`
@@ -110,10 +110,7 @@ type User struct {
 	UID     *uint32 `yaml:"uid,omitempty" json:"uid,omitempty" jsonschema:"nullable"`
 }
 
-type VMOpts struct {
-	QEMU QEMUOpts `yaml:"qemu,omitempty" json:"qemu,omitempty"`
-	VZ   VZOpts   `yaml:"vz,omitempty" json:"vz,omitempty"`
-}
+type VMOpts map[VMType]any
 
 type QEMUOpts struct {
 	MinimumVersion *string `yaml:"minimumVersion,omitempty" json:"minimumVersion,omitempty" jsonschema:"nullable"`
@@ -241,7 +238,7 @@ const (
 type Provision struct {
 	Mode                            ProvisionMode      `yaml:"mode,omitempty" json:"mode,omitempty" jsonschema:"default=system"`
 	SkipDefaultDependencyResolution *bool              `yaml:"skipDefaultDependencyResolution,omitempty" json:"skipDefaultDependencyResolution,omitempty"`
-	Script                          string             `yaml:"script,omitempty" json:"script,omitempty"`
+	Script                          *string            `yaml:"script,omitempty" json:"script,omitempty"`
 	File                            *LocatorWithDigest `yaml:"file,omitempty" json:"file,omitempty" jsonschema:"nullable"`
 	Playbook                        string             `yaml:"playbook,omitempty" json:"playbook,omitempty"` // DEPRECATED
 	// All ProvisionData fields must be nil unless Mode is ProvisionModeData
diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go
index 9e546d72..1f617dbd 100644
--- a/pkg/limayaml/defaults.go
+++ b/pkg/limayaml/defaults.go
@@ -19,7 +19,6 @@ import (
 	"slices"
 	"strconv"
 	"strings"
-	"sync"
 	"text/template"
 
 	"github.com/docker/go-units"
@@ -440,12 +439,11 @@ func FillDefault(ctx context.Context, y, d, o *limatype.LimaYAML, filePath strin
 				provision.Permissions = ptr.Of("644")
 			}
 		}
-		// TODO Turn Script into a pointer; it is a plain string for historical reasons only
-		if provision.Script != "" {
-			if out, err := executeGuestTemplate(provision.Script, instDir, y.User, y.Param); err == nil {
-				provision.Script = out.String()
+		if provision.Script != nil && *provision.Script != "" {
+			if out, err := executeGuestTemplate(*provision.Script, instDir, y.User, y.Param); err == nil {
+				*provision.Script = out.String()
 			} else {
-				logrus.WithError(err).Warnf("Couldn't process provisioning script %q as a template", provision.Script)
+				logrus.WithError(err).Warnf("Couldn't process provisioning script %q as a template", *provision.Script)
 			}
 		}
 	}
@@ -974,56 +972,6 @@ func ResolveArch(s *string) limatype.Arch {
 	return *s
 }
 
-func IsAccelOS() bool {
-	switch runtime.GOOS {
-	case "darwin", "linux", "netbsd", "windows", "dragonfly":
-		// Accelerator
-		return true
-	}
-	// Using TCG
-	return false
-}
-
-var hasSMEDarwin = sync.OnceValue(func() bool {
-	if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" {
-		return false
-	}
-	// golang.org/x/sys/cpu does not support inspecting the availability of SME yet
-	s, err := osutil.Sysctl(context.Background(), "hw.optional.arm.FEAT_SME")
-	if err != nil {
-		logrus.WithError(err).Debug("failed to check hw.optional.arm.FEAT_SME")
-	}
-	return s == "1"
-})
-
-func HasHostCPU() bool {
-	switch runtime.GOOS {
-	case "darwin":
-		if hasSMEDarwin() {
-			// [2025-02-05]
-			// SME is available since Apple M4 running macOS 15.2, but it was broken on macOS 15.2.
-			// It has been fixed in 15.3.
-			//
-			// https://github.com/lima-vm/lima/issues/3032
-			// https://gitlab.com/qemu-project/qemu/-/issues/2665
-			// https://gitlab.com/qemu-project/qemu/-/issues/2721
-
-			// [2025-02-12]
-			// SME got broken again after upgrading QEMU from 9.2.0 to 9.2.1 (Homebrew bottle).
-			// Possibly this regression happened in some build process rather than in QEMU itself?
-			// https://github.com/lima-vm/lima/issues/3226
-			return false
-		}
-		return true
-	case "linux":
-		return true
-	case "netbsd", "windows":
-		return false
-	}
-	// Not reached
-	return false
-}
-
 func IsNativeArch(arch limatype.Arch) bool {
 	nativeX8664 := arch == limatype.X8664 && runtime.GOARCH == "amd64"
 	nativeAARCH64 := arch == limatype.AARCH64 && runtime.GOARCH == "arm64"
diff --git a/pkg/limayaml/marshal.go b/pkg/limayaml/marshal.go
index a43821f3..71d6d4d7 100644
--- a/pkg/limayaml/marshal.go
+++ b/pkg/limayaml/marshal.go
@@ -82,3 +82,20 @@ func Unmarshal(data []byte, y *limatype.LimaYAML, comment string) error {
 	}
 	return nil
 }
+
+// Convert converts from x to y, using YAML.
+// If x is nil, then y is left unmodified.
+func Convert(x, y any, comment string) error {
+	if x == nil {
+		return nil
+	}
+	b, err := yaml.Marshal(x)
+	if err != nil {
+		return err
+	}
+	err = yaml.Unmarshal(b, y)
+	if err != nil {
+		return fmt.Errorf("failed to unmarshal YAML (%s): %w", comment, err)
+	}
+	return nil
+}
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index 87ffb7e6..e13844e7 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -212,7 +212,7 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
 				errs = errors.Join(errs, fmt.Errorf("field `provision[%d].permissions` must be an octal number: %w", i, err))
 			}
 		default:
-			if p.Script == "" && p.Mode != limatype.ProvisionModeAnsible {
+			if (p.Script == nil || *p.Script == "") && p.Mode != limatype.ProvisionModeAnsible {
 				errs = errors.Join(errs, fmt.Errorf("field `provision[%d].script` must not be empty", i))
 			}
 			if p.Content != nil {
@@ -238,7 +238,7 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
 			if p.Mode != limatype.ProvisionModeAnsible {
 				errs = errors.Join(errs, fmt.Errorf("field `provision[%d].playbook can only be set when mode is %q", i, limatype.ProvisionModeAnsible))
 			}
-			if p.Script != "" {
+			if p.Script != nil && *p.Script != "" {
 				errs = errors.Join(errs, fmt.Errorf("field `provision[%d].script must be empty if playbook is set", i))
 			}
 			playbook := p.Playbook
@@ -247,8 +247,10 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
 			}
 			logrus.Warnf("provision mode %q is deprecated, use `ansible-playbook %q` instead", limatype.ProvisionModeAnsible, playbook)
 		}
-		if strings.Contains(p.Script, "LIMA_CIDATA") {
-			logrus.Warn("provisioning scripts should not reference the LIMA_CIDATA variables")
+		if p.Script != nil {
+			if strings.Contains(*p.Script, "LIMA_CIDATA") {
+				logrus.Warn("provisioning scripts should not reference the LIMA_CIDATA variables")
+			}
 		}
 	}
 	needsContainerdArchives := (y.Containerd.User != nil && *y.Containerd.User) || (y.Containerd.System != nil && *y.Containerd.System)
@@ -521,7 +523,7 @@ func validateParamIsUsed(y *limatype.LimaYAML) error {
 		}
 		keyIsUsed := false
 		for _, p := range y.Provision {
-			for _, ptr := range []*string{&p.Script, p.Content, p.Expression, p.Owner, p.Path, p.Permissions} {
+			for _, ptr := range []*string{p.Script, p.Content, p.Expression, p.Owner, p.Path, p.Permissions} {
 				if ptr != nil && re.MatchString(*ptr) {
 					keyIsUsed = true
 					break
diff --git a/pkg/networks/reconcile/reconcile.go b/pkg/networks/reconcile/reconcile.go
index 02076b0b..e192a1b3 100644
--- a/pkg/networks/reconcile/reconcile.go
+++ b/pkg/networks/reconcile/reconcile.go
@@ -1,7 +1,7 @@
 // SPDX-FileCopyrightText: Copyright The Lima Authors
 // SPDX-License-Identifier: Apache-2.0
 
-package networks
+package reconcile
 
 import (
 	"bytes"
diff --git a/pkg/osutil/exit.go b/pkg/osutil/exit.go
index ee14ecc2..588f1d00 100644
--- a/pkg/osutil/exit.go
+++ b/pkg/osutil/exit.go
@@ -4,18 +4,22 @@
 package osutil
 
 import (
-	"errors"
 	"os"
 	"os/exec"
 )
 
+// HandleExitError calls os.Exit immediately without printing an error, only if the error is an *exec.ExitError (non-nil).
+//
+// The function does not call os.Exit if the error is of any other type, even if it wraps an *exec.ExitError,
+// so that the caller can print the error message.
 func HandleExitError(err error) {
 	if err == nil {
 		return
 	}
 
-	var exitErr *exec.ExitError
-	if errors.As(err, &exitErr) {
+	// Do not use errors.As, because we want to match only *exec.ExitError, not wrapped ones.
+	// https://github.com/lima-vm/lima/pull/4168
+	if exitErr, ok := err.(*exec.ExitError); ok {
 		os.Exit(exitErr.ExitCode()) //nolint:revive // it's intentional to call os.Exit in this function
 		return
 	}
diff --git a/pkg/portfwd/control_others.go b/pkg/portfwd/control_others.go
index 529b0750..723862d0 100644
--- a/pkg/portfwd/control_others.go
+++ b/pkg/portfwd/control_others.go
@@ -17,11 +17,6 @@ func Control(_, _ string, c syscall.RawConn) (err error) {
 		if err != nil {
 			return
 		}
-
-		err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
-		if err != nil {
-			return
-		}
 	})
 	if controlErr != nil {
 		err = controlErr
diff --git a/pkg/portfwd/forward.go b/pkg/portfwd/forward.go
index e5b91d30..e442cc35 100644
--- a/pkg/portfwd/forward.go
+++ b/pkg/portfwd/forward.go
@@ -34,8 +34,8 @@ func NewPortForwarder(rules []limatype.PortForward, ignoreTCP, ignoreUDP bool) *
 	}
 }
 
-func (fw *Forwarder) Close() {
-	fw.closableListeners.Close()
+func (fw *Forwarder) Close() error {
+	return fw.closableListeners.Close()
 }
 
 func (fw *Forwarder) OnEvent(ctx context.Context, client *guestagentclient.GuestAgentClient, ev *api.Event) {
diff --git a/pkg/portfwd/listener.go b/pkg/portfwd/listener.go
index f1949ce8..58e992f9 100644
--- a/pkg/portfwd/listener.go
+++ b/pkg/portfwd/listener.go
@@ -38,19 +38,25 @@ func NewClosableListener() *ClosableListeners {
 	}
 }
 
-func (p *ClosableListeners) Close() {
+func (p *ClosableListeners) Close() error {
 	p.listenersRW.Lock()
 	defer p.listenersRW.Unlock()
+	var errs []error
 	for _, listener := range p.listeners {
-		listener.Close()
+		if err := listener.Close(); err != nil {
+			errs = append(errs, err)
+		}
 	}
 	clear(p.listeners)
 	p.udpListenersRW.Lock()
 	defer p.udpListenersRW.Unlock()
 	for _, listener := range p.udpListeners {
-		listener.Close()
+		if err := listener.Close(); err != nil {
+			errs = append(errs, err)
+		}
 	}
 	clear(p.udpListeners)
+	return errors.Join(errs...)
 }
 
 func (p *ClosableListeners) Forward(ctx context.Context, client *guestagentclient.GuestAgentClient,
diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go
index 00b6984f..f2b12665 100644
--- a/pkg/sshutil/sshutil.go
+++ b/pkg/sshutil/sshutil.go
@@ -295,6 +295,36 @@ func identityFileEntry(ctx context.Context, privateKeyPath string) (string, erro
 	return fmt.Sprintf(`IdentityFile="%s"`, privateKeyPath), nil
 }
 
+// DisableControlMasterOptsFromSSHArgs returns ssh args that disable ControlMaster, ControlPath, and ControlPersist.
+func DisableControlMasterOptsFromSSHArgs(sshArgs []string) []string {
+	argsForOverridingConfigFile := []string{
+		"-o", "ControlMaster=no",
+		"-o", "ControlPath=none",
+		"-o", "ControlPersist=no",
+	}
+	return slices.Concat(argsForOverridingConfigFile, removeOptsFromSSHArgs(sshArgs, "ControlMaster", "ControlPath", "ControlPersist"))
+}
+
+func removeOptsFromSSHArgs(sshArgs []string, removeOpts ...string) []string {
+	res := make([]string, 0, len(sshArgs))
+	isOpt := false
+	for _, arg := range sshArgs {
+		if isOpt {
+			isOpt = false
+			if !slices.ContainsFunc(removeOpts, func(opt string) bool {
+				return strings.HasPrefix(arg, opt)
+			}) {
+				res = append(res, "-o", arg)
+			}
+		} else if arg == "-o" {
+			isOpt = true
+		} else {
+			res = append(res, arg)
+		}
+	}
+	return res
+}
+
 // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist.
 func SSHOpts(ctx context.Context, sshExe SSHExe, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) {
 	controlSock := filepath.Join(instDir, filenames.SSHSock)
diff --git a/templates/README.md b/templates/README.md
index 6ea2b4a2..d00a7597 100644
--- a/templates/README.md
+++ b/templates/README.md
@@ -57,7 +57,7 @@ Container orchestration:
 - [`faasd`](./faasd.yaml): [Faasd](https://docs.openfaas.com/deployment/edge/)
 - [`k0s`](./k0s.yaml): [k0s](https://k0sproject.io/) Zero Friction Kubernetes
 - [`k3s`](./k3s.yaml): Kubernetes via k3s
-- [`k8s`](./k8s.yaml): Kubernetes via kubeadm
+- [`k8s`](./k8s.yaml): ⭐Kubernetes via kubeadm
 - [`experimental/u7s`](./experimental/u7s.yaml): [Usernetes](https://github.com/rootless-containers/usernetes): Rootless Kubernetes
 
 Optional feature enablers:
diff --git a/templates/_images/alpine-iso.yaml b/templates/_images/alpine-iso.yaml
index 875fac71..684d95bd 100644
--- a/templates/_images/alpine-iso.yaml
+++ b/templates/_images/alpine-iso.yaml
@@ -1,9 +1,9 @@
-# Using the Alpine 3.21 aarch64 image with vmType=vz requires macOS Ventura 13.3 or later.
+# Using the Alpine 3.22 aarch64 image with vmType=vz requires macOS Ventura 13.3 or later.
 images:
-- location: https://github.com/lima-vm/alpine-lima/releases/download/v0.2.44/alpine-lima-std-3.21.3-x86_64.iso
+- location: https://github.com/lima-vm/alpine-lima/releases/download/v0.2.46/alpine-lima-std-3.22.2-x86_64.iso
   arch: x86_64
-  digest: sha512:60d8c857ee2654ab26d3b7a68f3790e044cdef13e297e18167c2dcc885648a7b86100a4348aee03362edaa212c90b01c3c473c9b5a10500ba95ae6e9d0add2a5
+  digest: sha512:759d8e1efa5f74e9c9357b359711c58b2dbf26232ac7bb80ddd10ba328eb7bd528eb1a0e72aff0334951c9f28a8403b76dcaa8eacb8ae378fe48dd365630fd49
 
-- location: https://github.com/lima-vm/alpine-lima/releases/download/v0.2.44/alpine-lima-std-3.21.3-aarch64.iso
+- location: https://github.com/lima-vm/alpine-lima/releases/download/v0.2.46/alpine-lima-std-3.22.2-aarch64.iso
   arch: aarch64
-  digest: sha512:ac421aa43b791f41edbebe4d1ceaee34bf68876af1c75c1992bb497c7411b14a95f51294f09e44beb1cb029ef36e5a6262b01786a08737de07dca92599eb20e2
+  digest: sha512:53ef755c6b8fa7d6bf3c55ad96341f145c3500b685774c9e303e25651a273273d730c79603e87935d652d64f6db87b0f13abc9e44a4f4750d76ee856cd18ed43
diff --git a/templates/_images/alpine.yaml b/templates/_images/alpine.yaml
index 04a98899..626a944c 100644
--- a/templates/_images/alpine.yaml
+++ b/templates/_images/alpine.yaml
@@ -1,7 +1,7 @@
 images:
-- location: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.0-x86_64-uefi-cloudinit-r0.qcow2"
+- location: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.2-x86_64-uefi-cloudinit-r0.qcow2"
   arch: "x86_64"
-  digest: "sha512:2ebfc0d515dee0b8a0732d77c99f050bf2a413a5d6bc3634ac94cb48f7b31a3e59431f732810edccf4b39cc0045275a947496aff534804f2cac6e7e9d63c7c74"
-- location: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.0-aarch64-uefi-cloudinit-r0.qcow2"
+  digest: "sha512:6dcb2ec1ac3ff160f1a2609fbd7cb8417df6c1c937c259238090d5bc494778924c0eb360c926d90bfbe7d0176692ae00b049f4a06d5070618a3b087a47c96532"
+- location: "https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/nocloud_alpine-3.22.2-aarch64-uefi-cloudinit-r0.qcow2"
   arch: "aarch64"
-  digest: "sha512:30b347397387926eeb939d93c926e09833f5b49c6c6de5cc225ccdfe6e54aba88251c71da264c7e4260e78132b50e34b93409c8b4da2e843e68a4dc35fc6b155"
+  digest: "sha512:481ad2db4fe024dd5d2dddd2abf2f722b738173c9ff6b76425ee877706bd49e26b2877ccae7b389aebae1d5e8df0384cc3c8355fc903378997340ce6c1b85faf"
diff --git a/templates/_images/ubuntu-25.10.yaml b/templates/_images/ubuntu-25.10.yaml
new file mode 100644
index 00000000..1d3535e6
--- /dev/null
+++ b/templates/_images/ubuntu-25.10.yaml
@@ -0,0 +1,45 @@
+images:
+# Try to use release-yyyyMMdd image if available. Note that release-yyyyMMdd will be removed after several months.
+
+- location: "https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-amd64.img"
+  arch: "x86_64"
+  digest: "sha256:67825fe057c20aede30d86be90f876b4b687a3c7f7a7a409c68c0c1352760a55"
+- location: "https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-arm64.img"
+  arch: "aarch64"
+  digest: "sha256:a7b84e640e4dd1f1de036653fbdc18dc0600b5293e89c0958fc7de507c550cca"
+- location: "https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-riscv64.img"
+  arch: "riscv64"
+  digest: "sha256:58a16255fa68b952da5d0c9a6f717bb5b48a92b228b97388daa8ca7344979ed6"
+- location: "https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-armhf.img"
+  arch: "armv7l"
+  digest: "sha256:f1b7f8a2671716e1204df3868eedd41a0c4ea8afa71871868dff8260a618ca5a"
+- location: "https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-s390x.img"
+  arch: "s390x"
+  digest: "sha256:0092a721e20f26211dcd49e814d164dba1a75405c4f97bf31bdd3de6d205ed64"
+- location: "https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-ppc64el.img"
+  arch: "ppc64le"
+  digest: "sha256:c57e9a03fe20c9ec2eb2a909cb02945530c5d47ace5c436b0cff65665e62f2c6"
+# Fallback to the latest release image.
+# Hint: run `limactl prune` to invalidate the cache
+
+- location: https://cloud-images.ubuntu.com/releases/questing/release/ubuntu-25.10-server-cloudimg-amd64.img
+  arch: x86_64
+
+- location: https://cloud-images.ubuntu.com/releases/questing/release/ubuntu-25.10-server-cloudimg-arm64.img
+  arch: aarch64
+
+- location: https://cloud-images.ubuntu.com/releases/questing/release/ubuntu-25.10-server-cloudimg-riscv64.img
+  arch: riscv64
+
+- location: https://cloud-images.ubuntu.com/releases/questing/release/ubuntu-25.10-server-cloudimg-armhf.img
+  arch: armv7l
+
+- location: https://cloud-images.ubuntu.com/releases/questing/release/ubuntu-25.10-server-cloudimg-s390x.img
+  arch: s390x
+
+- location: https://cloud-images.ubuntu.com/releases/questing/release/ubuntu-25.10-server-cloudimg-ppc64el.img
+  arch: ppc64le
+
+# # NOTE: Intel Mac with macOS prior to 15.5 requires setting vmType to qemu
+# # https://github.com/lima-vm/lima/issues/3334
+# vmType: qemu
diff --git a/templates/_images/ubuntu.yaml b/templates/_images/ubuntu.yaml
index 3475af5c..fd760889 120000
--- a/templates/_images/ubuntu.yaml
+++ b/templates/_images/ubuntu.yaml
@@ -1 +1 @@
-ubuntu-25.04.yaml
\ No newline at end of file
+ubuntu-25.10.yaml
\ No newline at end of file
diff --git a/templates/docker-rootful.yaml b/templates/docker-rootful.yaml
index 53477203..1b35f3d9 100644
--- a/templates/docker-rootful.yaml
+++ b/templates/docker-rootful.yaml
@@ -69,7 +69,7 @@ message: |
   docker context use lima-{{.Name}}
   docker run hello-world
   ------
-  {{- if .Instance.Config.VMOpts.VZ.Rosetta.Enabled}}
+  {{- if .Instance.Config.VMOpts.vz.rosetta.enabled}}
   Rosetta is enabled in this VM, so you can run x86_64 containers on Apple Silicon.
   You can use Rosetta AOT Caching with the CDI spec:
   - To run a container, add `--device=lima-vm.io/rosetta=cached` to your `docker run` command:
diff --git a/templates/docker.yaml b/templates/docker.yaml
index 4d5c0e7a..37ba5644 100644
--- a/templates/docker.yaml
+++ b/templates/docker.yaml
@@ -76,7 +76,7 @@ message: |
   docker context use lima-{{.Name}}
   docker run hello-world
   ------
-  {{- if .Instance.Config.VMOpts.VZ.Rosetta.Enabled}}
+  {{- if .Instance.Config.VMOpts.vz.rosetta.enabled}}
   Rosetta is enabled in this VM, so you can run x86_64 containers on Apple Silicon.
   You can use Rosetta AOT Caching with the CDI spec:
   - To run a container, add `--device=lima-vm.io/rosetta=cached` to your `docker run` command:
diff --git a/templates/experimental/ubuntu-25.10.yaml b/templates/experimental/ubuntu-25.10.yaml
deleted file mode 100644
index 0dda6f81..00000000
--- a/templates/experimental/ubuntu-25.10.yaml
+++ /dev/null
@@ -1,17 +0,0 @@
-minimumLimaVersion: 1.1.0
-
-base: template://_default/mounts
-
-images:
-- location: https://cloud-images.ubuntu.com/questing/current/questing-server-cloudimg-amd64.img
-  arch: x86_64
-- location: https://cloud-images.ubuntu.com/questing/current/questing-server-cloudimg-arm64.img
-  arch: aarch64
-- location: https://cloud-images.ubuntu.com/questing/current/questing-server-cloudimg-armhf.img
-  arch: armv7l
-- location: https://cloud-images.ubuntu.com/questing/current/questing-server-cloudimg-riscv64.img
-  arch: riscv64
-- location: https://cloud-images.ubuntu.com/questing/current/questing-server-cloudimg-s390x.img
-  arch: s390x
-- location: https://cloud-images.ubuntu.com/questing/current/questing-server-cloudimg-ppc64el.img
-  arch: ppc64le
diff --git a/templates/experimental/ubuntu-next.yaml b/templates/experimental/ubuntu-next.yaml
index fd760889..17f3e55d 120000
--- a/templates/experimental/ubuntu-next.yaml
+++ b/templates/experimental/ubuntu-next.yaml
@@ -1 +1 @@
-ubuntu-25.10.yaml
\ No newline at end of file
+../ubuntu-25.10.yaml
\ No newline at end of file
diff --git a/templates/k8s.yaml b/templates/k8s.yaml
index b02bcca0..57243bdd 100644
--- a/templates/k8s.yaml
+++ b/templates/k8s.yaml
@@ -47,14 +47,6 @@ provision:
     echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v${VERSION}/deb/ /" | sudo tee /etc/apt/sources.list.d/kubernetes.list
     curl -fsSL https://pkgs.k8s.io/core:/stable:/v${VERSION}/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
     apt-get update
-    # cri-tools
-    apt-get install -y cri-tools
-    cat  <<EOF | sudo tee /etc/crictl.yaml
-    runtime-endpoint: unix:///run/containerd/containerd.sock
-    EOF
-    # cni-plugins
-    apt-get install -y kubernetes-cni
-    rm -f /etc/cni/net.d/*.conf*
     apt-get install -y kubelet kubeadm kubectl && apt-mark hold kubelet kubeadm kubectl
     systemctl enable --now kubelet
 # See <https://kubernetes.io/docs/setup/production-environment/container-runtimes/>
@@ -63,20 +55,22 @@ provision:
     #!/bin/bash
     set -eux -o pipefail
     [ -e /etc/containerd/conf.d/k8s.toml ] && exit 0
-    grep "version = 2" /etc/containerd/config.toml || exit 1
     mkdir -p /etc/containerd/conf.d
     # Configuring the systemd cgroup driver
     # Overriding the sandbox (pause) image
-    cat <<EOF >>/etc/containerd/conf.d/k8s.toml
-      [plugins]
-        [plugins."io.containerd.grpc.v1.cri"]
-          sandbox_image = "$(kubeadm config images list | grep pause | sort -r | head -n1)"
-          [plugins."io.containerd.grpc.v1.cri".containerd]
-            [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
-              [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
-                runtime_type = "io.containerd.runc.v2"
-                [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
-                  SystemdCgroup = true
+    cat <<EOF >/etc/containerd/conf.d/k8s.toml
+    version = 2
+    [plugins]
+      [plugins."io.containerd.grpc.v1.cri"]
+        sandbox_image = "$(kubeadm config images list | grep pause | sort -r | head -n1)"
+        [plugins."io.containerd.grpc.v1.cri".containerd]
+          [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
+            [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
+              runtime_type = "io.containerd.runc.v2"
+              [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
+                SystemdCgroup = true
+      [plugins."io.containerd.cri.v1.runtime".cni]
+        bin_dirs = ["/usr/local/libexec/cni","/opt/cni/bin"]
     EOF
     systemctl restart containerd
 # See <https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/>
@@ -140,7 +134,7 @@ probes:
   script: |
     #!/bin/bash
     set -eux -o pipefail
-    if ! timeout 30s bash -c "images=\"$(kubeadm config images list)\"; until for image in \$images; do sudo crictl image -q \$image | grep -q sha256; done; do sleep 3; done"; then
+    if ! timeout 30s bash -c "images=\"$(kubeadm config images list)\"; until for image in \$images; do sudo ctr -n k8s.io image inspect \$image >/dev/null; done; do sleep 3; done"; then
       echo >&2 "k8s images are not pulled yet"
       exit 1
     fi
diff --git a/templates/ubuntu-25.10.yaml b/templates/ubuntu-25.10.yaml
new file mode 100644
index 00000000..2af57bd5
--- /dev/null
+++ b/templates/ubuntu-25.10.yaml
@@ -0,0 +1,5 @@
+minimumLimaVersion: 1.1.0
+
+base:
+- template://_images/ubuntu-25.10
+- template://_default/mounts
diff --git a/templates/ubuntu.yaml b/templates/ubuntu.yaml
index 3475af5c..fd760889 120000
--- a/templates/ubuntu.yaml
+++ b/templates/ubuntu.yaml
@@ -1 +1 @@
-ubuntu-25.04.yaml
\ No newline at end of file
+ubuntu-25.10.yaml
\ No newline at end of file
diff --git a/website/content/en/docs/_index.md b/website/content/en/docs/_index.md
index fb0feca4..d95e8a2f 100644
--- a/website/content/en/docs/_index.md
+++ b/website/content/en/docs/_index.md
@@ -11,7 +11,7 @@ Lima launches Linux virtual machines with automatic file sharing and port forwar
 
 ✅ Automatic port forwarding
 
-✅ Built-in support for [containerd](https://containerd.io) ([Other container engines can be used too](./templates))
+✅ Built-in support for [containerd](https://containerd.io) ([Other container engines can be used too]({{< ref "/docs/examples/containers" >}}))
 
 ✅ Intel on Intel
 
@@ -27,9 +27,16 @@ Related project: [sshocker (ssh with file sharing and port forwarding)](https://
 
 This project is unrelated to [The Lima driver project (driver for ARM Mali GPUs)](https://gitlab.freedesktop.org/lima).
 
-## Motivation
+## Project history
 
-The original goal of Lima was to promote [containerd](https://containerd.io) including [nerdctl (contaiNERD ctl)](https://github.com/containerd/nerdctl)
-to Mac users, but Lima can be used for non-container applications as well.
+Lima began in May 2021, aiming at promoting [containerd](https://containerd.io) including [nerdctl (contaiNERD ctl)](https://github.com/containerd/nerdctl)
+to Mac users.
+Later the project scope was expanded to support other containers
+and non-container applications as well.
 Lima also supports non-macOS hosts (Linux, NetBSD, etc.).
+
+Lima joined the [Cloud Native Computing Foundation (CNCF)](https://cncf.io)
+in September 2022 as a Sandbox project.
+The project was promoted to the Incubating level in October 2025.
+
 {{% /fixlinks %}}
diff --git a/website/content/en/docs/config/_index.md b/website/content/en/docs/config/_index.md
index a9dd680e..bde6bd14 100644
--- a/website/content/en/docs/config/_index.md
+++ b/website/content/en/docs/config/_index.md
@@ -12,5 +12,3 @@ The current default spec:
 - Disk: 100 GiB
 - Mounts: `~` (read-only), `/tmp/lima` (writable; removed in Lima v2.0)
 - SSH: 127.0.0.1:<Random port>
-
-For environment variables, see [Environment Variables](./environment-variables/).
diff --git a/website/content/en/docs/config/multi-arch.md b/website/content/en/docs/config/multi-arch.md
index 443eeea4..e7836aea 100644
--- a/website/content/en/docs/config/multi-arch.md
+++ b/website/content/en/docs/config/multi-arch.md
@@ -10,24 +10,42 @@ Lima supports several modes for running Intel-on-ARM and ARM-on-Intel:
 
 ## [Slow mode: Intel VM on ARM Host / ARM VM on Intel Host](#slow-mode)
 
-Lima can run a VM with a foreign architecture, just by specifying `arch` in the YAML.
+| ⚡ Requirement | QEMU, lima-additional-guestagents |
+|---------------|-----------------------------------|
 
+Lima can run a VM with a foreign architecture, using [QEMU](./vmtype/qemu.md).
+
+For [port forwarding](./port.md), the [`lima-additional-guestagents`](../installation/) package has to be installed on the host.
+
+An example configuration:
+{{< tabpane text=true >}}
+{{% tab header="CLI" %}}
+```bash
+limactl start --vm-type=qemu --arch=x86_64 --plain
+```
+
+See the YAML tab for the explanation of the corresponding CLI flags.
+{{% /tab %}}
+{{% tab header="YAML" %}}
 ```yaml
+# Starting with Lima v2.0, `vmType` has to be expclitly set to "qemu" on non-Linux hosts.
+vmType: "qemu"
+
+# Supported architectures:
+#   Non-experimental: aarch64, x86_64
+#   Experimental:     armv7l, ppc64le, riscv64, s390x
+#
+# containerd is not automatically installed for the experimental architectures.
 arch: "x86_64"
-# arch: "aarch64"
-
-images:
-  - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
-    arch: "x86_64"
-  - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img"
-    arch: "aarch64"
-
-# Disable mounts and containerd, otherwise booting up may time out if the host is slow
-mounts: []
-containerd:
-  system: false
-  user: false
+
+# A slow host may need `plain` to disable mounts, port forwards, and containerd, so as to avoid timeout.
+plain: true
+
+base:
+- template://_images/ubuntu
 ```
+{{% /tab %}}
+{{< /tabpane >}}
 
 Running a VM with a foreign architecture is extremely slow.
 Consider using [Fast mode](#fast-mode) or [Fast mode 2](#fast-mode-2) whenever possible.
@@ -40,7 +58,7 @@ QEMU User Mode Emulation is significantly faster than QEMU System Mode Emulation
 Set up:
 ```bash
 lima sudo systemctl start containerd
-lima sudo nerdctl run --privileged --rm tonistiigi/binfmt:qemu-v7.0.0-28@sha256:66e11bea77a5ea9d6f0fe79b57cd2b189b5d15b93a2bdb925be22949232e4e55 --install all
+lima sudo nerdctl run --privileged --rm tonistiigi/binfmt:qemu-v10.0.4-56@sha256:30cc9a4d03765acac9be2ed0afc23af1ad018aed2c28ea4be8c2eb9afe03fbd1 --install all
 ```
 
 Run containers:
diff --git a/website/content/en/docs/config/network/vmnet.md b/website/content/en/docs/config/network/vmnet.md
index 3ec2b90b..6228bd78 100644
--- a/website/content/en/docs/config/network/vmnet.md
+++ b/website/content/en/docs/config/network/vmnet.md
@@ -37,6 +37,8 @@ The range of the IP address is not specifiable.
 
 The "vzNAT" network does not need the `socket_vmnet` binary and the `sudoers` file.
 
+The "vzNAT" network also has a significant advantage in the throughput. See the [benchmark result](../port.md#benchmarks).
+
 ## socket_vmnet
 ### Managed (192.168.105.0/24)
 
diff --git a/website/content/en/docs/config/plugin/cli.md b/website/content/en/docs/config/plugin/cli.md
index b2376aea..3ea8245c 100644
--- a/website/content/en/docs/config/plugin/cli.md
+++ b/website/content/en/docs/config/plugin/cli.md
@@ -104,6 +104,8 @@ After creating this alias:
 ```bash
 limactl sh default           # Equivalent to: limactl shell default
 limactl sh myinstance bash   # Equivalent to: limactl shell myinstance bash
+```
+
 ## How It Works
 
 1. When you run `limactl <unknown-command>`, Lima first tries to find a built-in command
diff --git a/website/content/en/docs/config/port.md b/website/content/en/docs/config/port.md
index ee6a0b06..05d538cd 100644
--- a/website/content/en/docs/config/port.md
+++ b/website/content/en/docs/config/port.md
@@ -23,7 +23,7 @@ The default was further reverted to GRPC in Lima v1.1, as the stability issues w
 
 ### Using SSH
 
-SSH based port forwarding is the legacy mode that was previously default.
+SSH based port forwarding was previously the default mode.
 
 To explicitly use SSH forwarding use the below command
 
@@ -31,6 +31,10 @@ To explicitly use SSH forwarding use the below command
 LIMA_SSH_PORT_FORWARDER=true limactl start
 ```
 
+#### Advantages
+
+- Outperforms GRPC when VSOCK is available
+
 #### Caveats
 
 - Doesn't support UDP based port forwarding
@@ -67,15 +71,36 @@ LIMA_SSH_PORT_FORWARDER=false limactl start
 #### Advantages
 
 - Supports both TCP and UDP based port forwarding
-- Performs faster compared to SSH based forwarding
+- Performs faster compared to SSH based forwarding, when VSOCK is not available
 - No additional child process for port forwarding
 
-### Benchmarks
 
-| Use case    | GRPC           | SSH            |
-|-------------|----------------|----------------|
-| TCP         | 3.80 Gbits/sec | 3.38 Gbits/sec |
-| TCP Reverse | 4.77 Gbits/sec | 3.08 Gbits/sec |
+## Accessing ports by IP address
+
+To access a guest's ports by its IP address, connect the guest to the `vzNAT` or the `lima:shared` network.
+
+The `vzNAT` network is extremely faster and easier to use, however, `vzNAT` is only available for [VZ](./vmtype/vz.md) guests.
+
+```bash
+limactl start --network vzNAT
+lima ip addr show lima0
+```
+
+See [Config » Network » VMNet networks](./network/vmnet.md) for the further information.
+
+## Benchmarks
+
+<!-- When updating the benchmark result, make sure to update the benchmarking environment too -->
+
+| By localhost | SSH (w/o VSOCK) | GRPC           | SSH (w/ VSOCK)  |
+|--------------|-----------------|----------------|-----------------|
+| TCP          | 4.06 Gbits/sec  | 5.37 Gbits/sec | 6.32 Gbits/sec  |
+| TCP Reverse  | 3.84 Gbits/sec  | 7.11 Gbits/sec | 7.47 Gbits/sec  |
+
+| By IP address | lima:shared    | vzNAT          |
+|---------------|----------------|----------------|
+| TCP           | 3.46 Gbits/sec | 59.2 Gbits/sec |
+| TCP Reverse   | 2.35 Gbits/sec | 130  Gbits/sec |
 
 The benchmarks detail above are obtained using the following commands
 
@@ -84,7 +109,24 @@ Host -> limactl start vz
 
 VZ Guest -> iperf3 -s
 
-Host -> iperf3 -c 127.0.0.1 //Benchmark for TCP 
-Host -> iperf3 -c 127.0.0.1 -R //Benchmark for TCP Reverse
+Host -> iperf3 -c 127.0.0.1 //Benchmark for TCP (average of "sender" and "receiver")
+Host -> iperf3 -c 127.0.0.1 -R //Benchmark for TCP Reverse (same as above)
 ```
 
+The benchmark result, especially the throughput of vzNAT, highly depends on the performance of the hardware.
+
+<details>
+<summary>Benchmarking environment</summary>
+<p>
+
+- Lima version: 2.0.0-alpha.2
+- Guest: Ubuntu 25.04
+  - OpenSSH 9.9p1
+  - iperf 3.18
+- Host: macOS 26.0.1
+  - OpenSSH 10.0p2
+  - iperf 3.19.1 (Homebrew)
+- Hardware: MacBook Pro 2024 (M4 Max, 128 GiB)
+
+</p>
+</details>
\ No newline at end of file
diff --git a/website/content/en/docs/config/vmtype/qemu.md b/website/content/en/docs/config/vmtype/qemu.md
index 8cb5e45b..543c0ade 100644
--- a/website/content/en/docs/config/vmtype/qemu.md
+++ b/website/content/en/docs/config/vmtype/qemu.md
@@ -5,6 +5,26 @@ weight: 1
 
 "qemu" option makes use of QEMU to run guest operating system.
 
+"qemu" is the default driver for Linux hosts.
+
 Recommended QEMU version:
 - v8.2.1 or later (macOS)
-- v6.2.0 or later (Linux)
\ No newline at end of file
+- v6.2.0 or later (Linux)
+
+An example configuration:
+{{< tabpane text=true >}}
+{{% tab header="CLI" %}}
+```bash
+limactl start --vm-type=qemu
+```
+{{% /tab %}}
+{{% tab header="YAML" %}}
+```yaml
+vmType: "qemu"
+
+base:
+- template://_images/ubuntu
+- template://_default/mounts
+```
+{{% /tab %}}
+{{< /tabpane >}}
\ No newline at end of file
diff --git a/website/content/en/docs/config/vmtype/vz.md b/website/content/en/docs/config/vmtype/vz.md
index cc35d294..bcd0cb28 100644
--- a/website/content/en/docs/config/vmtype/vz.md
+++ b/website/content/en/docs/config/vmtype/vz.md
@@ -8,7 +8,9 @@ weight: 2
 
 "vz" option makes use of native virtualization support provided by macOS Virtualization.Framework.
 
-An example configuration:
+"vz" has been the default driver for macOS hosts since Lima v1.0.
+
+An example configuration (no need to be specified manually):
 {{< tabpane text=true >}}
 {{% tab header="CLI" %}}
 ```bash
@@ -17,15 +19,11 @@ limactl start --vm-type=vz
 {{% /tab %}}
 {{% tab header="YAML" %}}
 ```yaml
-# Example to run ubuntu using vmType: vz instead of qemu (Default)
 vmType: "vz"
-images:
-- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img"
-  arch: "x86_64"
-- location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img"
-  arch: "aarch64"
-mounts:
-  - location: "~"
+
+base:
+- template://_images/ubuntu
+- template://_default/mounts
 ```
 {{% /tab %}}
 {{< /tabpane >}}
diff --git a/website/content/en/docs/dev/testing.md b/website/content/en/docs/dev/testing.md
index 58a65a54..b54ad611 100644
--- a/website/content/en/docs/dev/testing.md
+++ b/website/content/en/docs/dev/testing.md
@@ -17,10 +17,33 @@ The unit tests do not execute actual virtual machines.
 
 The integration tests incurs actual execution of virtual machines.
 
-The integration tests are written in bash and partially in Perl.
+The integration tests are written in [BATS (Bash Automated Testing System)](https://github.com/bats-core/bats-core).
+
+Run the following commands to run the BATS tests:
+
+```bash
+git submodule update --init --recursive
+make bats
+```
+
+The BATS tests are located under [`hack/bats/tests`](https://github.com/lima-vm/lima/tree/master/hack/bats/tests).
+
+### Extra tests
+There are also extra tests ([`hack/bats/extras`](https://github.com/lima-vm/lima/tree/master/hack/bats/extras)) that are not automatically
+invoked from `make bats`.
+
+Run the following command to run the extra BATS tests:
+
+```bash
+./hack/bats/lib/bats-core/bin/bats ./hack/bats/extras
+```
+
+## Template-specific tests
+
+Tests that are specific to template files are written in bash and partially in Perl.
 
 Use [`hack/test-templates.sh`](https://github.com/lima-vm/lima/blob/master/hack/test-templates.sh)
-to execute integration tests, with a virtual machine template file, e.g.,:
+to execute tests, with a virtual machine template file, e.g.,:
 
 ```bash
 ./hack/test-templates.sh ./templates/default.yaml
@@ -31,9 +54,9 @@ to execute integration tests, with a virtual machine template file, e.g.,:
 ## CI
 
 [`.github/workflows/test.yml`](https://github.com/lima-vm/lima/blob/master/.github/workflows/test.yml)
-executes the unit tests and the integration tests on the GitHub Actions with the ["Tier 1"](../../templates/) templates.
+executes the tests on the GitHub Actions with the ["Tier 1"](../../templates/) templates.
 
-Most integration tests are executed on Linux runners, as macOS runners are slow and flaky.
+Most tests are executed on Linux runners, as macOS runners are slow and flaky.
 
 The tests about macOS-specific features (e.g., vz and vmnet) are still executed on macOS runners.
 
diff --git a/website/content/en/docs/examples/_index.md b/website/content/en/docs/examples/_index.md
index 52a69605..094bbc48 100644
--- a/website/content/en/docs/examples/_index.md
+++ b/website/content/en/docs/examples/_index.md
@@ -54,7 +54,8 @@ docker run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
 ```bash
 limactl start template://k8s
 export KUBECONFIG=$(limactl list k8s --format 'unix://{{.Dir}}/copied-from-guest/kubeconfig.yaml')
-kubectl apply -f ...
+kubectl create deployment nginx --image nginx:alpine
+kubectl create service nodeport nginx --node-port=31080 --tcp=80:80
 ```
 {{% /tab %}}
 
@@ -62,12 +63,7 @@ kubectl apply -f ...
 
 - <http://127.0.0.1:8080> is accessible from the host, as well as from the VM.
 
-- For the usage of containerd and nerdctl (contaiNERD ctl), visit <https://github.com/containerd/containerd>
-and <https://github.com/containerd/nerdctl>.
-
-- If you have installed Lima by `make install`, the `nerdctl.lima` command is also available as `nerdctl`.
-  If you have installed Lima by `brew install lima`, you may make an alias (or a symlink) by yourself:
-  `alias nerdctl=nerdctl.lima`
+- See more [examples](./containers/).
 
 ## Advanced configuration
 
diff --git a/website/content/en/docs/examples/ai.md b/website/content/en/docs/examples/ai.md
index da88e82e..7d4a28a7 100644
--- a/website/content/en/docs/examples/ai.md
+++ b/website/content/en/docs/examples/ai.md
@@ -54,7 +54,7 @@ See also <https://github.com/Aider-AI/aider>.
 ```
 lima sudo snap install node --classic
 lima sudo npm install -g @anthropic-ai/claude-code
-lima codex
+lima claude
 ```
 
 Follow the guide shown in the first session for authentication.
diff --git a/website/content/en/docs/examples/containers/_index.md b/website/content/en/docs/examples/containers/_index.md
new file mode 100644
index 00000000..424ea298
--- /dev/null
+++ b/website/content/en/docs/examples/containers/_index.md
@@ -0,0 +1,10 @@
+---
+title: Containers
+weight: 3
+---
+
+Lima was designed to facilitate running containers inside a virtual machine, with automatic
+[filesystem sharing](../../config/mount.md) and  [port forwarding](../../config/port.md).
+
+The original motivation of Lima was to promote [containerd](./containerd) for macOS users, however,
+the current version of Lima supports other container engines too, and does not depend on macOS hosts.
\ No newline at end of file
diff --git a/website/content/en/docs/examples/containers/apptainer/_index.md b/website/content/en/docs/examples/containers/apptainer/_index.md
new file mode 100644
index 00000000..4b4d9142
--- /dev/null
+++ b/website/content/en/docs/examples/containers/apptainer/_index.md
@@ -0,0 +1,21 @@
+---
+title: Apptainer
+weight: 90
+---
+
+{{< tabpane text=true >}}
+{{% tab header="Rootless" %}}
+```bash
+limactl start template://apptainer
+limactl shell apptainer apptainer run -u -B $HOME:$HOME docker://alpine
+```
+{{% /tab %}}
+{{% tab header="Rootful" %}}
+```bash
+limactl start template://apptainer-rootful
+limactl shell apptainer-rootful apptainer run -u -B $HOME:$HOME docker://alpine
+```
+{{% /tab %}}
+{{< /tabpane >}}
+
+See also <https://apptainer.org/docs/user/latest/>.
\ No newline at end of file
diff --git a/website/content/en/docs/examples/containers/containerd/_index.md b/website/content/en/docs/examples/containers/containerd/_index.md
new file mode 100644
index 00000000..0517c199
--- /dev/null
+++ b/website/content/en/docs/examples/containers/containerd/_index.md
@@ -0,0 +1,41 @@
+---
+title: containerd (Default)
+weight: 1
+---
+
+Lima comes with the built-in integration for [containerd](https://containerd.io) and
+[nerdctl](https://github.com/containerd/nerdctl) (contaiNERD CTL):
+
+{{< tabpane text=true >}}
+{{% tab header="Rootless" %}}
+```bash
+lima nerdctl run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
+```
+
+or
+
+```bash
+nerdctl.lima run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
+```
+
+- If you have installed Lima by [`make install`](../../../installation/source.md), the `nerdctl.lima` command is also available as `nerdctl`.
+- If you have installed Lima by [`brew install lima`](../../../installation/_index.md), you may make an alias (or a symlink) by yourself:
+  `alias nerdctl=nerdctl.lima`
+{{% /tab %}}
+{{% tab header="Rootful" %}}
+```bash
+limactl start --containerd=system
+lima sudo nerdctl run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
+```
+{{% /tab %}}
+{{< /tabpane >}}
+
+The usage of the `nerdctl` command is similar to the `docker` command. See the [Command Reference](https://github.com/containerd/nerdctl/blob/main/docs/command-reference.md).
+
+## Disabling containerd
+
+To disable containerd, start an instance with `--containerd=none`:
+
+```bash
+limactl start --containerd=none
+```
diff --git a/website/content/en/docs/examples/containers/containerd/advanced/_index.md b/website/content/en/docs/examples/containers/containerd/advanced/_index.md
new file mode 100644
index 00000000..d697f19c
--- /dev/null
+++ b/website/content/en/docs/examples/containers/containerd/advanced/_index.md
@@ -0,0 +1,4 @@
+---
+title: Advanced
+weight: 1
+---
\ No newline at end of file
diff --git a/website/content/en/docs/examples/containers/containerd/advanced/bypass4netns.md b/website/content/en/docs/examples/containers/containerd/advanced/bypass4netns.md
new file mode 100644
index 00000000..d161c5a6
--- /dev/null
+++ b/website/content/en/docs/examples/containers/containerd/advanced/bypass4netns.md
@@ -0,0 +1,54 @@
+---
+title: Accelerating rootless networking with bypass4netns
+linkTitle: bypass4netns
+weight: 2
+---
+
+[bypass4netns](https://github.com/rootless-containers/bypass4netns) is an experimental accelerator for rootless networking.
+
+On macOS hosts, it is highly recommended to use the [vzNAT](../../../../config/network/vmnet.md#vznat) networking in conjunction
+to reduce the overhead of Lima's user-mode networking:
+
+```bash
+limactl start --network vzNAT
+```
+
+To enable bypass4netns, the daemon process (`bypass4netnsd`) has to be installed in the VM as follows:
+<!-- TODO: install by default -->
+```bash
+lima containerd-rootless-setuptool.sh install-bypass4netnsd
+```
+
+Then run a container with an annotation `nerdctl/bypass4netns=true`:
+
+```bash
+# 192.168.64.1 is the IP address of the "bridge100" interface on the macOS host
+lima nerdctl run --annotation nerdctl/bypass4netns=true alpine \
+  sh -euc 'apk add iperf3 && iperf3 -c 192.168.64.1'
+```
+
+Benchmark result:
+
+| Mode                          | Throughput     |
+|-------------------------------|----------------|
+| Rootless without bypass4netns | 2.30 Gbits/sec |
+| Rootless with bypass4netns    | 86.0 Gbits/sec |
+| Rootful                       | 90.3 Gbits/sec |
+
+<details>
+<summary>Benchmarking environment</summary>
+<p>
+
+- Lima version: 2.0.0-alpha.2
+  - nerdctl 2.1.6
+  - containerd 2.1.4
+  - bypass4netns 0.4.2
+- Container: Alpine Linux 3.22.2
+  - iperf 3.19.1-r0 (apk)
+- Guest: Ubuntu 25.04
+- Host: macOS 26.0.1
+  - iperf 3.19.1 (Homebrew)
+- Hardware: MacBook Pro 2024 (M4 Max, 128 GiB)
+
+</p>
+</details>
diff --git a/website/content/en/docs/examples/containers/containerd/advanced/gomodjail.md b/website/content/en/docs/examples/containers/containerd/advanced/gomodjail.md
new file mode 100644
index 00000000..dad6c07c
--- /dev/null
+++ b/website/content/en/docs/examples/containers/containerd/advanced/gomodjail.md
@@ -0,0 +1,18 @@
+---
+title: Enhanced supply chain security with gomodjail
+linkTitle: gomodjail
+weight: 1
+---
+
+[gomodjail](https://github.com/AkihiroSuda/gomodjail) is an experimental library sandbox for Go modules.
+
+gomodjail imposes syscall restrictions on a specific set of Go modules, so as to mitigate their potential vulnerabilities and supply chain attack vectors.
+A restricted module is hindered to access files and execute commands.
+
+gomodjail can be enabled for nerdctl by using the `nerdctl.gomodjail` binary.
+
+```bash
+lima nerdctl.gomodjail ...
+```
+
+For the gomodjail policy applied to `nerdctl.gomodjail`, see <https://github.com/containerd/nerdctl/blob/main/go.mod>.
diff --git a/website/content/en/docs/examples/containers/containerd/advanced/stargz.md b/website/content/en/docs/examples/containers/containerd/advanced/stargz.md
new file mode 100644
index 00000000..81de7e61
--- /dev/null
+++ b/website/content/en/docs/examples/containers/containerd/advanced/stargz.md
@@ -0,0 +1,53 @@
+---
+title: Accelerating start-up time with eStargz
+linkTitle: eStargz
+weight: 3
+---
+
+[eStargz](https://github.com/containerd/stargz-snapshotter) is an OCI-compatible container image format
+that reduces start-up latency using lazy-pulling technique.
+
+The support for eStargz is available by default for `ubuntu-24.04` instances:
+
+```bash
+limactl start --name=default template://ubuntu-24.04
+```
+
+The latest Ubuntu will be supported too in [a future release](https://github.com/containerd/stargz-snapshotter/issues/2144).
+
+{{% alert title="Hint" color=success %}}
+ARM Mac users need to run `limactl start` with `--rosetta` to allow [running AMD64 binaries](../../../../config/multi-arch.md).
+This is not an architectural limitation of eStargz, however, Rosetta is needed because the example Python image below
+is currently [only available for AMD64](https://github.com/containerd/stargz-snapshotter/issues/2143).
+{{% /alert %}}
+
+Without eStargz:
+
+```console
+$ time lima nerdctl run --platform=amd64 ghcr.io/stargz-containers/python:3.13-org python3 -c 'print("hi")'
+[...]
+hi
+
+real	0m23.767s
+user	0m0.025s
+sys	0m0.020s
+```
+
+With eStargz:
+
+```console
+$ time lima nerdctl --snapshotter=stargz run --platform=amd64 ghcr.io/stargz-containers/python:3.13-esgz python3 -c 'print("hi")'
+[...]
+hi
+
+real	0m13.365s
+user	0m0.026s
+sys	0m0.021s
+```
+
+Examples of eStargz images can be found at
+<https://github.com/containerd/stargz-snapshotter/blob/main/docs/pre-converted-images.md>.
+
+See also:
+- https://github.com/containerd/stargz-snapshotter
+- https://github.com/containerd/nerdctl/blob/main/docs/stargz.md
\ No newline at end of file
diff --git a/website/content/en/docs/examples/containers/docker/_index.md b/website/content/en/docs/examples/containers/docker/_index.md
new file mode 100644
index 00000000..eea3ff03
--- /dev/null
+++ b/website/content/en/docs/examples/containers/docker/_index.md
@@ -0,0 +1,21 @@
+---
+title: Docker
+weight: 2
+---
+
+{{< tabpane text=true >}}
+{{% tab header="Rootless" %}}
+```bash
+limactl start template://docker
+export DOCKER_HOST=$(limactl list docker --format 'unix://{{.Dir}}/sock/docker.sock')
+docker run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
+```
+{{% /tab %}}
+{{% tab header="Rootful" %}}
+```bash
+limactl start template://docker-rootful
+export DOCKER_HOST=$(limactl list docker-rootful --format 'unix://{{.Dir}}/sock/docker.sock')
+docker run -d --name nginx -p 127.0.0.1:8080:80 nginx:alpine
+```
+{{% /tab %}}
+{{< /tabpane >}}
diff --git a/website/content/en/docs/examples/containers/kubernetes/_index.md b/website/content/en/docs/examples/containers/kubernetes/_index.md
new file mode 100644
index 00000000..2703b3de
--- /dev/null
+++ b/website/content/en/docs/examples/containers/kubernetes/_index.md
@@ -0,0 +1,65 @@
+---
+title: Kubernetes
+weight: 4
+---
+
+## Single-node
+
+{{< tabpane text=true >}}
+{{% tab header="kubeadm" %}}
+```bash
+limactl start template://k8s
+export KUBECONFIG=$(limactl list k8s --format 'unix://{{.Dir}}/copied-from-guest/kubeconfig.yaml')
+kubectl create deployment nginx --image nginx:alpine
+kubectl create service nodeport nginx --node-port=31080 --tcp=80:80
+```
+
+Modify [`templates/k8s.yaml`](https://github.com/lima-vm/lima/blob/master/templates/k8s.yaml) to change
+the kubeadm configuration.
+
+See also <https://kubernetes.io/docs/reference/setup-tools/kubeadm/>.
+{{% /tab %}}
+{{% tab header="k3s" %}}
+```bash
+limactl start template://k3s
+export KUBECONFIG=$(limactl list k3s --format 'unix://{{.Dir}}/copied-from-guest/kubeconfig.yaml')
+kubectl create deployment nginx --image nginx:alpine
+kubectl create service nodeport nginx --node-port=31080 --tcp=80:80
+```
+
+See also <https://docs.k3s.io>.
+{{% /tab %}}
+{{% tab header="k0s" %}}
+```bash
+limactl start template://k0s
+export KUBECONFIG=$(limactl list k0s --format 'unix://{{.Dir}}/copied-from-guest/kubeconfig.yaml')
+kubectl create deployment nginx --image nginx:alpine
+kubectl create service nodeport nginx --node-port=31080 --tcp=80:80
+```
+
+See also <https://docs.k0sproject.io/>.
+{{% /tab %}}
+{{% tab header="Usernetes" %}}
+```bash
+limactl start template://experimental/u7s
+export KUBECONFIG=$(limactl list u7s --format 'unix://{{.Dir}}/copied-from-guest/kubeconfig.yaml')
+kubectl create deployment nginx --image nginx:alpine
+# NodePorts are not available by default in the case of Usernetes
+kubectl port-forward deployments/nginx 8080:80
+```
+
+See also <https://github.com/rootless-containers/usernetes>.
+{{% /tab %}}
+{{< /tabpane >}}
+
+## Multi-node
+
+A multi-node cluster can be created by creating multiple VMs connected via the ([`lima:user-v2`](../../config/network/user-v2.md) network.
+
+```bash
+limactl create --name k8s-0 --network lima:user-v2
+limactl create --name k8s-1 --network lima:user-v2
+```
+
+The cluster has to be set up manually, as the built-in templates do not support multi-node mode yet.
+Support for multi-node template is tracked in <https://github.com/lima-vm/lima/issues/4100>.
\ No newline at end of file
diff --git a/website/content/en/docs/examples/containers/podman/_index.md b/website/content/en/docs/examples/containers/podman/_index.md
new file mode 100644
index 00000000..1e721166
--- /dev/null
+++ b/website/content/en/docs/examples/containers/podman/_index.md
@@ -0,0 +1,45 @@
+---
+title: Podman
+weight: 3
+---
+
+{{< tabpane text=true >}}
+{{% tab header="Rootless" %}}
+To use `podman` command in the VM:
+```bash
+limactl start template://podman
+limactl shell podman podman run -d --name nginx -p 127.0.0.1:8080:80 docker.io/library/nginx:alpine
+```
+
+To use `podman` command on the host:
+```bash
+export CONTAINER_HOST=$(limactl list podman --format 'unix://{{.Dir}}/sock/podman.sock')
+podman --remote run -d --name nginx -p 127.0.0.1:8080:80 docker.io/library/nginx:alpine
+```
+
+To use `docker` command on the host:
+```bash
+export DOCKER_HOST=$(limactl list podman --format 'unix://{{.Dir}}/sock/podman.sock')
+docker run -d --name nginx -p 127.0.0.1:8080:80 docker.io/library/nginx:alpine
+```
+{{% /tab %}}
+{{% tab header="Rootful" %}}
+To use `podman` command in the VM:
+```bash
+limactl start template://podman-rootful
+limactl shell podman-rootful sudo podman run -d --name nginx -p 127.0.0.1:8080:80 docker.io/library/nginx:alpine
+```
+
+To use `podman` command on the host:
+```bash
+export CONTAINER_HOST=$(limactl list podman-rootful --format 'unix://{{.Dir}}/sock/podman.sock')
+podman --remote run -d --name nginx -p 127.0.0.1:8080:80 docker.io/library/nginx:alpine
+```
+
+To use `docker` command on the host:
+```bash
+export DOCKER_HOST=$(limactl list podman-rootful --format 'unix://{{.Dir}}/sock/podman.sock')
+docker run -d --name nginx -p 127.0.0.1:8080:80 docker.io/library/nginx:alpine
+```
+{{% /tab %}}
+{{< /tabpane >}}
diff --git a/website/content/en/docs/examples/gha.md b/website/content/en/docs/examples/gha.md
index 46b94e93..4c0f9090 100644
--- a/website/content/en/docs/examples/gha.md
+++ b/website/content/en/docs/examples/gha.md
@@ -65,9 +65,6 @@ The `--plain` mode is useful when you want the VM instance to be as close as pos
       # --plain is set to disable file sharing, port forwarding, built-in containerd, etc.
       run: limactl start --plain --name=default --cpus=1 --memory=1 --network=lima:user-v2 template://fedora
 
-    - name: "Set up SSH"
-      uses: lima-vm/lima-actions/ssh@v1
-
     - name: "Initialize Fedora"
       # plain old rsync and ssh are used for the initialization of the guest,
       # so that people who are not familiar with Lima can understand the initialization steps.
diff --git a/website/content/en/docs/faq/_index.md b/website/content/en/docs/faq/_index.md
index 275cdefb..de526b31 100644
--- a/website/content/en/docs/faq/_index.md
+++ b/website/content/en/docs/faq/_index.md
@@ -50,12 +50,9 @@ weight: 6
 Password is disabled and locked by default.
 You have to use `limactl shell bash` (or `lima bash`) to open a shell.
 
-Alternatively, you may also directly ssh into the guest: `ssh -p <PORT> -i ~/.lima/_config/user -o NoHostAuthenticationForLocalhost=yes 127.0.0.1`.
-The port number can be inspected by running `limactl list`.
-e.g.,
-```bash
-limactl list --format '{{ .SSHLocalPort }}' default
-```
+{{% fixlinks %}}
+Try virtiofs. See [`Usage » SSH`]({{< ref "/docs/usage/ssh" >}})
+{{% /fixlinks %}}
 
 #### "Does Lima work on ARM Mac?"
 Yes
diff --git a/website/content/en/docs/usage/_index.md b/website/content/en/docs/usage/_index.md
index 7a77bb56..d6ec1511 100644
--- a/website/content/en/docs/usage/_index.md
+++ b/website/content/en/docs/usage/_index.md
@@ -50,28 +50,6 @@ lima uname -a
 ```
 The `lima` command also accepts the instance name as the environment variable `$LIMA_INSTANCE`.
 
-
-SSH can be used too:
-```console
-$ limactl ls --format='{{.SSHConfigFile}}' default
-/Users/example/.lima/default/ssh.config
-
-$ ssh -F /Users/example/.lima/default/ssh.config lima-default
-```
-
-#### Using SSH without the `-F` flag
-
-To connect directly without specifying the config file, add this to your `~/.ssh/config`:
-
-```
-Include ~/.lima/*/ssh.config
-```
-
-Then you can connect directly:
-```bash
-ssh lima-default
-```
-
 ### Shell completion
 - To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`.
 - To enable zsh completion, see `limactl completion zsh --help`
diff --git a/website/content/en/docs/usage/ssh.md b/website/content/en/docs/usage/ssh.md
new file mode 100644
index 00000000..6903fafc
--- /dev/null
+++ b/website/content/en/docs/usage/ssh.md
@@ -0,0 +1,45 @@
+---
+title: SSH
+weight: 3
+---
+
+Instead of the `limactl shell` command, SSH can be used too:
+
+```console
+$ limactl ls --format='{{.SSHConfigFile}}' default
+/Users/example/.lima/default/ssh.config
+
+$ ssh -F /Users/example/.lima/default/ssh.config lima-default
+```
+
+This is useful for interoperability with other software that expects the SSH connectivity.
+
+## Using SSH without additional options
+
+Add the following line to your `~/.ssh/config`:
+
+```
+Include ~/.lima/*/ssh.config
+```
+
+Then you can connect directly without specifying `-F`:
+```bash
+ssh lima-default
+```
+
+This configuration is notably useful for the Remote Development mode of [Visual Studio Code](../examples/vscode.md).
+
+## Using SSH without a config file
+
+If your SSH client does not support a config file, try specifying an equivalent of the following command:
+
+```bash
+ssh -p <PORT> -i ~/.lima/_config/user -o NoHostAuthenticationForLocalhost=yes 127.0.0.1
+```
+
+The port number can be inspected as follows:
+```bash
+limactl list --format '{{ .SSHLocalPort }}' default
+```
+
+See also `.lima/default/ssh.config`.
\ No newline at end of file
diff --git a/website/layouts/shortcodes/blocks/cncf.html b/website/layouts/shortcodes/blocks/cncf.html
index 60bd1f1f..e94f1f3e 100644
--- a/website/layouts/shortcodes/blocks/cncf.html
+++ b/website/layouts/shortcodes/blocks/cncf.html
@@ -1,5 +1,5 @@
 <section class="text-center">
-  <h4>{{ .Site.Title }} is a <a class="text-secondary" href="https://cncf.io">CNCF</a> sandbox project</h4>
+  <h4>{{ .Site.Title }} is a <a class="text-secondary" href="https://cncf.io">CNCF</a> incubating project</h4>
 
   <img class="cncf-logo"
        src="/images/cncf-color-bg.svg" alt="Cloud Native Computing Foundation logo" />



Test patch:
diff --git a/hack/bats/tests/01-list.bats b/hack/bats/tests/list.bats
similarity index 100%
rename from hack/bats/tests/01-list.bats
rename to hack/bats/tests/list.bats
diff --git a/hack/bats/tests/mcp.bats b/hack/bats/tests/mcp.bats
new file mode 100644
index 00000000..acc3aa70
--- /dev/null
+++ b/hack/bats/tests/mcp.bats
@@ -0,0 +1,331 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+load "../helpers/load"
+
+NAME=bats
+
+# TODO Move helper functions to shared location
+run_yq() {
+    run -0 --separate-stderr limactl yq "$@"
+}
+
+json_edit() {
+    limactl yq --input-format json --output-format json --indent 0 "$@"
+}
+
+# TODO The reusable Lima instance setup is copied from preserve-env.bats
+# TODO and should be factored out into helper functions.
+local_setup_file() {
+    if [[ -n "${LIMA_BATS_REUSE_INSTANCE:-}" ]]; then
+        run limactl list --format '{{.Status}}' "$NAME"
+        [[ $status == 0 ]] && [[ $output == "Running" ]] && return
+    fi
+    limactl unprotect "$NAME" || :
+    limactl delete --force "$NAME" || :
+    # Make sure that the host agent doesn't inherit file handles 3 or 4.
+    # Otherwise bats will not finish until the host agent exits.
+    limactl start --yes --name "$NAME" template://default 3>&- 4>&-
+}
+
+local_teardown_file() {
+    if [[ -z "${LIMA_BATS_REUSE_INSTANCE:-}" ]]; then
+        limactl delete --force "$NAME"
+    fi
+}
+
+local_setup() {
+    cd "$PATH_BATS_ROOT"
+    coproc MCP { limactl mcp serve "$NAME"; }
+
+    ID=0
+    mcp initialize '{"protocolVersion":"2025-06-18"}'
+
+    # Each mcp request should increment the ID
+    [[ $ID -eq 1 ]]
+
+    run_yq .serverInfo.name <<<"$output"
+    assert_output "lima"
+}
+
+local_teardown() {
+    kill "${MCP_PID:?}" 2>&1 >/dev/null || :
+}
+
+mcp() {
+    local method=$1
+    local params=${2:-}
+
+    local request
+    printf -v request '{"jsonrpc":"2.0","id":%d,"method":"%s"}' "$((++ID))" "$method"
+    if [[ -n $params ]]; then
+        request=$(json_edit ".params=${params}" <<<"$request")
+    fi
+
+    # send request to MCP server stdin
+    echo "$request" >&"${MCP[1]}"
+
+    # read response from MCP server stdout with 5s timeout
+    local json
+    read -t 5 -r json <&"${MCP[0]}"
+
+    # verify that the response matches the request; also validates the output is valid JSON
+    run_yq .id <<<"$json"
+    assert_output "$ID"
+
+    # there must be no error object in the response
+    run_yq .error <<<"$json"
+    assert_output "null"
+
+    # set $output to .result
+    run_yq .result <<<"$json"
+}
+
+tools_call() {
+    local name=$1
+    local args=${2:-}
+
+    local params
+    printf -v params '{"name":"%s"}' "$name"
+    if [[ -n $args ]]; then
+        params=$(json_edit ".arguments=${args}" <<<"$params")
+    fi
+    mcp tools/call "$params"
+}
+
+@test 'list tools' {
+    mcp tools/list
+    run_yq '.tools[].name' <<<"$output"
+    assert_line glob
+    assert_line list_directory
+    assert_line read_file
+    assert_line run_shell_command
+    assert_line search_file_content
+    assert_line write_file
+}
+
+@test 'verify that tools descriptions include input and output schema' {
+    mcp tools/list
+    run_yq '.tools[] | select(.name == "run_shell_command")' <<<"$output"
+    json=$output
+
+    run_yq '.inputSchema.required[]' <<<"$json"
+    assert_line command
+    assert_line directory
+    assert_output_lines_count 2
+
+    run_yq '.inputSchema.properties | keys[]' <<<"$json"
+    assert_line command
+    assert_line description
+    assert_line directory
+    assert_output_lines_count 3
+
+    run_yq '.outputSchema.required[]' <<<"$json"
+    assert_line stdout
+    assert_line stderr
+    assert_output_lines_count 2
+
+    run_yq '.outputSchema.properties | keys[]' <<<"$json"
+    assert_line error
+    assert_line exit_code
+    assert_line stdout
+    assert_line stderr
+    assert_output_lines_count 4
+}
+
+@test 'run shell command returns command output' {
+    run -0 limactl shell "$NAME" cat /etc/os-release
+    assert_output
+    expected=$output
+
+    tools_call run_shell_command '{"directory":"/etc","command":["cat","os-release"]}'
+    json=$output
+
+    run_yq '.structuredContent.exit_code' <<<"$json"
+    assert_output 0
+
+    run_yq '.structuredContent.stdout' <<<"$json"
+    assert_output "$expected"
+
+    run_yq '.structuredContent.stderr' <<<"$json"
+    refute_output
+
+    # The same data is also available as encoded JSON
+    run_yq '.content[0].type' <<<"$json"
+    assert_output "text"
+
+    run_yq '.content[0].text' <<<"$json"
+    text=$output
+
+    run_yq '.exit_code' <<<"$text"
+    assert_output 0
+
+    run_yq '.stdout' <<<"$text"
+    assert_output "$expected"
+
+    run_yq '.stderr' <<<"$text"
+    refute_output
+}
+
+@test 'run shell command returns stderr and exit code' {
+    tools_call run_shell_command '{"directory":"/","command":["bash","-c","echo NO>&2; exit 13"]}'
+    json=$output
+
+    run_yq '.structuredContent.exit_code' <<<"$json"
+    assert_output 13
+
+    run_yq '.structuredContent.error' <<<"$json"
+    assert_output "exit status 13"
+
+    run_yq '.structuredContent.stdout' <<<"$json"
+    refute_output
+
+    run_yq '.structuredContent.stderr' <<<"$json"
+    assert_output "NO"
+}
+
+@test 'run shell command fails if the directory does not exist' {
+    tools_call run_shell_command '{"directory":"/etcetera","command":["cat","os-release"]}'
+    json=$output
+
+    run_yq '.structuredContent.exit_code' <<<"$json"
+    assert_output 1
+
+    run_yq '.structuredContent.stderr' <<<"$json"
+    assert_output --partial "No such file or directory"
+}
+
+@test 'read_file reads a file' {
+    run -0 limactl shell "$NAME" cat /etc/os-release
+    assert_output
+    expected=$output
+
+    tools_call read_file '{"path":"/etc/os-release"}'
+    json=$output
+
+    run_yq '.content[0].text' <<<"$json"
+    run_yq '.content' <<<"$output"
+    assert_output "$expected"
+
+    run_yq '.structuredContent.content' <<<"$json"
+    assert_output "$expected"
+}
+
+@test 'read_file returns an error when path does not exist' {
+    tools_call read_file '{"path":"/etc/os-release-info"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output "file does not exist"
+}
+
+@test 'read_file returns an error when path is not absolute' {
+    tools_call read_file '{"path":"os-release"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output --partial "expected an absolute path"
+}
+
+@test 'write_file creates new file and overwrites existing file' {
+    limactl shell "$NAME" rm -f /tmp/mcp.test
+    tools_call write_file '{"path":"/tmp/mcp.test","content":"foo"}'
+
+    run_yq '.content[0].text' <<<"$output"
+    assert_output "{}"
+
+    run -0 limactl shell "$NAME" cat /tmp/mcp.test
+    assert_output "foo"
+
+    tools_call write_file '{"path":"/tmp/mcp.test","content":"bar"}'
+
+    run_yq '.content[0].text' <<<"$output"
+    assert_output "{}"
+
+    run -0 limactl shell "$NAME" cat /tmp/mcp.test
+    assert_output "bar"
+}
+
+@test 'write_file creates the directory if it does not yet exist' {
+    skip "see https://github.com/lima-vm/lima/issues/4174"
+
+    limactl shell "$NAME" rm -rf /tmp/tmp
+    tools_call write_file '{"path":"/tmp/tmp/tmp","content":"tmp"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "null"
+
+    run -0 limactl shell "$NAME" cat /tmp/tmp/tmp
+    assert_output "tmp"
+}
+
+@test 'write_file returns an error when the directory is not writable' {
+    limactl shell "$NAME" mkdir -p /tmp/tmp
+    limactl shell "$NAME" chmod 444 /tmp/tmp
+    tools_call write_file '{"path":"/tmp/tmp/tmp","content":"tmp"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output "permission denied"
+}
+
+@test 'write_file returns an error when path is not absolute' {
+    tools_call write_file '{"path":"tmp/mcp.test","content":"baz"}'
+    json=$output
+
+    run_yq '.isError' <<<"$json"
+    assert_output "true"
+
+    run_yq '.content[0].text' <<<"$json"
+    assert_output --partial "expected an absolute path"
+}
+
+@test 'glob finds files by wildcard' {
+    tools_call glob '{"pattern":"*/*p.bats"}'
+
+    run_yq '.structuredContent.matches[]' <<<"$output"
+    assert_line --regexp '/tests/mcp.bats$'
+}
+
+@test 'glob returns an empty list when the pattern does not match' {
+    skip "see https://github.com/lima-vm/lima/issues/4173"
+
+    tools_call glob '{"pattern":"nothing.to.see"}'
+
+    run_yq '.structuredContent.matches[]' <<<"$output"
+    assert_output_lines_count 0
+}
+
+@test 'search_file_content finds text inside files' {
+    tools_call search_file_content '{"pattern":"needle in a haystack"}'
+
+    run_yq '.structuredContent.git_grep_output' <<<"$output"
+    assert_line --regexp '^tests/mcp.bats:[0-9]+: +tools_call'
+}
+
+@test 'search_file_content can find unicode characters above U+FFFF' {
+    # The light bulb emoji 💡 (U+1F4A1)
+    tools_call search_file_content '{"pattern":"💡"}'
+
+    run_yq '.structuredContent.git_grep_output' <<<"$output"
+    assert_line --regexp '^tests/mcp.bats:[0-9]+: +# The light bulb'
+    assert_line --regexp '^tests/mcp.bats:[0-9]+: +tools_call'
+}
+
+@test 'search_file_content returns an empty string if it cannot find the pattern' {
+    tools_call search_file_content "$(printf '{"pattern":"\U0001f4a1 not found"}')"
+
+    run_yq '.structuredContent.git_grep_output' <<<"$output"
+    refute_output
+}
+
diff --git a/hack/bats/tests/preserve-env.bats b/hack/bats/tests/preserve-env.bats
index 86db1a14..0e6b5d03 100644
--- a/hack/bats/tests/preserve-env.bats
+++ b/hack/bats/tests/preserve-env.bats
@@ -93,13 +93,13 @@ local_setup() {
     assert_line BAR=bar
 }
 
-@test 'wildcard does only work at the end of the pattern' {
+@test 'wildcard works at the start of the pattern' {
     export LIMA_SHELLENV_BLOCK="*FOO"
     export FOO=foo
     export BARFOO=barfoo
     run -0 limactl shell --preserve-env "$NAME" printenv
-    assert_line FOO=foo
-    assert_line BARFOO=barfoo
+    refute_line --regexp '^BARFOO='
+    refute_line --regexp '^FOO='
 }
 
 @test 'block list can use a , separated list with whitespace ignored' {
@@ -114,37 +114,72 @@ local_setup() {
     assert_line BARBAZ=barbaz
 }
 
-@test 'allow list overrides block list but blocks everything else' {
-    export LIMA_SHELLENV_ALLOW=SSH_FOO
+@test 'allow list can use a , separated list with whitespace ignored' {
+    export LIMA_SHELLENV_ALLOW="SSH_FOO, , BAR*, LD_UID"
     export SSH_FOO=ssh_foo
     export SSH_BAR=ssh_bar
+    export SSH_BLOCK=ssh_block
     export BAR=bar
+    export BARBAZ=barbaz
+    export LD_UID=randomuid
     run -0 limactl shell --preserve-env "$NAME" printenv
+    
     assert_line SSH_FOO=ssh_foo
+    assert_line BAR=bar
+    assert_line BARBAZ=barbaz
+    assert_line LD_UID=randomuid
+    
     refute_line --regexp '^SSH_BAR='
-    refute_line --regexp '^BAR='
+    refute_line --regexp '^SSH_BLOCK='
 }
 
-@test 'allow list can use a , separated list with whitespace ignored' {
-    export LIMA_SHELLENV_ALLOW="FOO*, , BAR"
+@test 'wildcard patterns work in all positions' {
+    export LIMA_SHELLENV_BLOCK="*FOO*BAR*"
     export FOO=foo
     export FOOBAR=foobar
+    export FOOXYZBAR=fooxyzbar
+    export FOOBAZ=foobaz
+    export BAZBAR=bazbar
     export BAR=bar
-    export BARBAZ=barbaz
+    export XFOOYBARZDOTCOM=xfooybarzdotcom
+    export NORMAL_VAR=normal_var
+    export UNRELATED=unrelated
     run -0 limactl shell --preserve-env "$NAME" printenv
-    assert_line FOO=foo
-    assert_line FOOBAR=foobar
+    
+    refute_line --regexp '^FOOBAR='
+    refute_line --regexp '^FOOXYZBAR='    
+    refute_line --regexp '^XFOOYBARZDOTCOM='
+    
+    assert_line FOOBAZ=foobaz
+    assert_line NORMAL_VAR=normal_var
+    assert_line UNRELATED=unrelated
+    assert_line BAZBAR=bazbar
     assert_line BAR=bar
-    refute_line --regexp '^BARBAZ='
+    assert_line FOO=foo
 }
 
-@test 'setting both allow list and block list generates a warning' {
-    export LIMA_SHELLENV_ALLOW=FOO
-    export LIMA_SHELLENV_BLOCK=BAR
-    export FOO=foo
-    run -0 --separate-stderr limactl shell --preserve-env "$NAME" printenv FOO
-    assert_output foo
-    assert_stderr --regexp 'level=warning msg="Both LIMA_SHELLENV_BLOCK and LIMA_SHELLENV_ALLOW are set'
+@test 'allowlist overrides default blocklist with wildcards' {
+    export LIMA_SHELLENV_ALLOW="SSH_*,CUSTOM*"
+    export LIMA_SHELLENV_BLOCK="+*TOKEN"
+    export SSH_AUTH_SOCK=ssh_auth_sock
+    export SSH_CONNECTION=ssh_connection
+    export CUSTOM_VAR=custom_var
+    export MY_TOKEN=my_token
+    export UNRELATED=unrelated
+    run -0 limactl shell --preserve-env "$NAME" printenv
+    
+    assert_line SSH_AUTH_SOCK=ssh_auth_sock
+    assert_line SSH_CONNECTION=ssh_connection
+    assert_line CUSTOM_VAR=custom_var
+    refute_line --regexp '^MY_TOKEN='
+    assert_line UNRELATED=unrelated
+}
+
+@test 'invalid characters in patterns cause fatal errors' {
+    export LIMA_SHELLENV_BLOCK="FOO-BAR"
+    run ! limactl shell --preserve-env "$NAME" printenv
+    assert_output --partial "Invalid LIMA_SHELLENV_BLOCK pattern"
+    assert_output --partial "contains invalid character"
 }
 
 @test 'limactl info includes the default block list' {
diff --git a/hack/bats/tests/url-github.bats b/hack/bats/tests/url-github.bats
new file mode 100644
index 00000000..6853ac8a
--- /dev/null
+++ b/hack/bats/tests/url-github.bats
@@ -0,0 +1,168 @@
+# SPDX-FileCopyrightText: Copyright The Lima Authors
+# SPDX-License-Identifier: Apache-2.0
+
+load "../helpers/load"
+
+# The jandubois/jandubois GitHub repo has been especially constructed to test
+# various features of the github URL scheme:
+#
+#   * repo defaults to org when not specified
+#   * filename defaults to .lima.yaml when only a path is specified
+#   * .yaml default extension
+#   * .lima.yaml files may be treated as symlinks
+#   * default branch lookup when not specified
+#   * github:ORG// repos can redirect to another github:ORG URL in the same ORG
+#
+# The jandubois/jandubois repo files are:
+#
+#   ├── .lima.yaml         -> templates/demo.yaml
+#   ├── back
+#   │   └── .lima.yaml     -> github:jandubois//loop/
+#   ├── docs
+#   │   └── .lima.yaml     -> ../templates/demo.yaml
+#   ├── invalid
+#   │   ├── org
+#   │   │   └── .lima.yaml -> github:lima-vm
+#   │   └── tag
+#   │       └── .lima.yaml -> github:jandubois//@v0.0.0
+#   ├── loop
+#   │   └── .lima.yaml     -> github:jandubois//back/
+#   ├── redirect
+#   │   └── .lima.yaml     -> github:jandubois/lima/templates/default
+#   ├── templates
+#   │   └── demo.yaml      "base: template:default"
+#   └── yaml
+#       └── .lima.yaml     "{}"
+#
+# Both the `main` branch and the `v0.0.0` tag have this layout.
+
+# All these URLs should redirect to the same template URL (either on "main" or at "v0.0.0"):
+# "https://raw.githubusercontent.com/jandubois/jandubois/${tag}/templates/demo.yaml"
+URLS=(
+	github:jandubois/jandubois/templates/demo.yaml@main
+	github:jandubois/jandubois/templates/demo.yaml
+	github:jandubois/jandubois/templates/demo
+	github:jandubois/jandubois/.lima.yaml
+	github:jandubois/jandubois/@v0.0.0
+	github:jandubois/jandubois
+	github:jandubois//templates/demo.yaml@main
+	github:jandubois//templates/demo.yaml
+	github:jandubois//templates/demo
+	github:jandubois//.lima.yaml
+	github:jandubois//@v0.0.0
+	github:jandubois//
+	github:jandubois/
+	github:jandubois@v0.0.0
+	github:jandubois
+	github:jandubois/jandubois/docs/.lima.yaml@main
+	github:jandubois/jandubois/docs/.lima.yaml
+	github:jandubois/jandubois/docs/.lima
+	github:jandubois/jandubois/docs/
+	github:jandubois//docs/.lima.yaml@v0.0.0
+	github:jandubois//docs/.lima.yaml
+	github:jandubois//docs/.lima
+	github:jandubois//docs/@v0.0.0
+	github:jandubois//docs/
+)
+
+url() {
+	run_e "$1" limactl --debug template url "$2"
+}
+
+test_jandubois_url() {
+	local url=$1
+	local tag="main"
+	if [[ $url == *v0.0.0* ]]; then
+		tag="v0.0.0"
+	fi
+
+	url -0 "$url"
+	assert_output "https://raw.githubusercontent.com/jandubois/jandubois/${tag}/templates/demo.yaml"
+}
+
+# Dynamically register a @test for each URL in the list
+for url in "${URLS[@]}"; do
+	bats_test_function --description "$url" -- test_jandubois_url "$url"
+done
+
+@test '.lima.yaml is retained when it exits and is not a symlink' {
+	url -0 'github:jandubois//yaml/'
+	assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/yaml/.lima.yaml'
+}
+
+@test 'non-existing .lima.yaml returns an error' {
+	url -1 'github:jandubois//missing/'
+	assert_fatal 'file "https://raw.githubusercontent.com/jandubois/jandubois/main/missing/.lima.yaml" not found or inaccessible: status 404'
+}
+
+@test 'hidden files without an extension get a .yaml extension' {
+	url -0 'github:jandubois//test/.hidden'
+	assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.hidden.yaml'
+}
+
+@test 'files that have an extension do not get a .yaml extension' {
+	url -0 'github:jandubois//test/.script.sh'
+	assert_output 'https://raw.githubusercontent.com/jandubois/jandubois/main/test/.script.sh'
+}
+
+@test 'github: URLs are EXPERIMENTAL' {
+	url -0 'github:jandubois'
+	assert_warning "The github: scheme is still EXPERIMENTAL"
+}
+
+# Invalid URLs
+@test 'empty github: url returns an error' {
+	url -1 'github:'
+	assert_fatal 'github: URL must contain at least an ORG, got ""'
+}
+
+@test 'missing org returns an error' {
+	url -1 'github:/jandubois'
+	assert_fatal 'github: URL must contain at least an ORG, got ""'
+}
+
+# github: redirects in github:ORG// repos
+@test 'org redirects can point to different repo and may switch the branch name' {
+	url -0 'github:jandubois//redirect/'
+	# Note that the default branch in jandubois/jandubois is main, but in jandubois/lima it is master
+	assert_debug 'Locator "github:jandubois//redirect/" replaced with "github:jandubois/lima/templates/default"'
+	assert_debug 'Locator "github:jandubois/lima/templates/default" replaced with "https://raw.githubusercontent.com/jandubois/lima/master/templates/default.yaml"'
+	assert_output 'https://raw.githubusercontent.com/jandubois/lima/master/templates/default.yaml'
+}
+
+@test 'org redirects propagate an explicit branch/tag to the other repo' {
+	url -0 'github:jandubois//redirect/@v1.2.1'
+	assert_debug 'Locator "github:jandubois//redirect/@v1.2.1" replaced with "github:jandubois/lima/templates/default@v1.2.1"'
+	assert_debug 'Locator "github:jandubois/lima/templates/default@v1.2.1" replaced with "https://raw.githubusercontent.com/jandubois/lima/v1.2.1/templates/default.yaml"'
+	assert_output 'https://raw.githubusercontent.com/jandubois/lima/v1.2.1/templates/default.yaml'
+}
+
+@test 'org redirects cannot point to another org' {
+	url -1 'github:jandubois//invalid/org/'
+	assert_fatal 'redirect "github:lima-vm" is not a "github:jandubois" URL…'
+}
+
+@test 'org redirects with branch cannot point to another org' {
+	url -1 'github:jandubois//invalid/org/@main'
+	assert_fatal 'redirect "github:lima-vm" is not a "github:jandubois" URL…'
+}
+
+@test 'org redirects cannot include a branch or tag' {
+	url -1 'github:jandubois//invalid/tag/'
+	assert_fatal 'redirect "github:jandubois//@v0.0.0" must not include a branch/tag/sha…'
+}
+
+@test 'org redirects with tag cannot include a branch or tag' {
+	url -1 'github:jandubois//invalid/tag/@v0.0.0'
+	assert_fatal 'redirect "github:jandubois//@v0.0.0" must not include a branch/tag/sha…'
+}
+
+@test 'org redirects must not create circular redirects' {
+	url -1 'github:jandubois//loop/'
+	assert_fatal 'custom locator "github:jandubois//loop/" has a redirect loop'
+}
+
+@test 'org redirects with branch must not create circular redirects' {
+	url -1 'github:jandubois//back/@main'
+	assert_fatal 'custom locator "github:jandubois//back/@main" has a redirect loop'
+}
diff --git a/hack/bats/tests/00-yq.bats b/hack/bats/tests/yq.bats
similarity index 100%
rename from hack/bats/tests/00-yq.bats
rename to hack/bats/tests/yq.bats
diff --git a/pkg/envutil/envutil_test.go b/pkg/envutil/envutil_test.go
index 3530288f..6904580f 100644
--- a/pkg/envutil/envutil_test.go
+++ b/pkg/envutil/envutil_test.go
@@ -88,11 +88,9 @@ func TestGetBlockAndAllowLists(t *testing.T) {
 		t.Setenv("LIMA_SHELLENV_BLOCK", "")
 		t.Setenv("LIMA_SHELLENV_ALLOW", "")
 
-		blockList, isBlockListSet := getBlockList()
-		allowList, isAllowListSet := getAllowList()
+		blockList := getBlockList()
+		allowList := getAllowList()
 
-		assert.Assert(t, !isBlockListSet)
-		assert.Assert(t, !isAllowListSet)
 		assert.Assert(t, isUsingDefaultBlockList())
 		assert.DeepEqual(t, blockList, defaultBlockList)
 		assert.Equal(t, len(allowList), 0)
@@ -101,8 +99,7 @@ func TestGetBlockAndAllowLists(t *testing.T) {
 	t.Run("custom blocklist", func(t *testing.T) {
 		t.Setenv("LIMA_SHELLENV_BLOCK", "PATH,HOME")
 
-		blockList, isSet := getBlockList()
-		assert.Assert(t, isSet)
+		blockList := getBlockList()
 		assert.Assert(t, !isUsingDefaultBlockList())
 		expected := []string{"PATH", "HOME"}
 		assert.DeepEqual(t, blockList, expected)
@@ -111,8 +108,7 @@ func TestGetBlockAndAllowLists(t *testing.T) {
 	t.Run("additive blocklist", func(t *testing.T) {
 		t.Setenv("LIMA_SHELLENV_BLOCK", "+CUSTOM_VAR")
 
-		blockList, isSet := getBlockList()
-		assert.Assert(t, isSet)
+		blockList := getBlockList()
 		assert.Assert(t, isUsingDefaultBlockList())
 		expected := slices.Concat(GetDefaultBlockList(), []string{"CUSTOM_VAR"})
 		assert.DeepEqual(t, blockList, expected)
@@ -121,8 +117,7 @@ func TestGetBlockAndAllowLists(t *testing.T) {
 	t.Run("allowlist", func(t *testing.T) {
 		t.Setenv("LIMA_SHELLENV_ALLOW", "FOO,BAR")
 
-		allowList, isSet := getAllowList()
-		assert.Assert(t, isSet)
+		allowList := getAllowList()
 		expected := []string{"FOO", "BAR"}
 		assert.DeepEqual(t, allowList, expected)
 	})
@@ -173,9 +168,11 @@ func TestFilterEnvironment(t *testing.T) {
 
 	t.Run("allowlist", func(t *testing.T) {
 		result := filterEnvironmentWithLists(testEnv, []string{"FOO", "USER"}, nil)
-
 		expected := []string{"FOO=bar", "USER=testuser"}
-		assert.Equal(t, len(result), len(expected))
+
+		// since FOO and USER are included in testEnv, the filtered result should not differ
+		// from what is in the testEnv
+		assert.Equal(t, len(result), len(testEnv))
 		assert.Assert(t, containsAll(result, expected))
 	})
 
@@ -213,3 +210,42 @@ func TestGetDefaultBlockList(t *testing.T) {
 		assert.Assert(t, found, "Expected builtin blocklist to contain %q", item)
 	}
 }
+
+func TestValidatePattern(t *testing.T) {
+	tests := []struct {
+		name    string
+		pattern string
+		valid   bool
+	}{
+		{"simple alphanumeric uppercase", "FOO", true},
+		{"simple alphanumeric lowercase", "foo", true},
+		{"mixed case", "FooBar", true},
+		{"with underscore", "FOO_BAR", true},
+		{"with numbers", "VAR123", true},
+		{"with trailing asterisk", "FOO*", true},
+		{"with multiple asterisks", "FOO*BAR*", true},
+		{"asterisk at beginning", "*FOO", true},
+		{"asterisk in middle", "FOO*BAR", true},
+		{"only asterisk", "*", true},
+		{"with dash", "FOO-BAR", false},
+		{"with space", "FOO BAR", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validatePattern(tt.pattern)
+			if tt.valid {
+				assert.NilError(t, err, "Expected pattern %q to be valid", tt.pattern)
+			} else {
+				assert.Assert(t, err != nil, "Expected pattern %q to be invalid", tt.pattern)
+			}
+		})
+	}
+}
+
+func TestValidatePatternErrorMessage(t *testing.T) {
+	err := validatePattern("FOO-BAR")
+	assert.Assert(t, err != nil, "Expected pattern with dash to be invalid")
+	expectedMsg := `pattern "FOO-BAR" contains invalid character "-" at position 3`
+	assert.Equal(t, err.Error(), expectedMsg)
+}
diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go
index 768650a5..55227992 100644
--- a/pkg/limayaml/defaults_test.go
+++ b/pkg/limayaml/defaults_test.go
@@ -150,7 +150,7 @@ func TestFillDefault(t *testing.T) {
 		},
 		MountType: ptr.Of(limatype.NINEP),
 		Provision: []limatype.Provision{
-			{Script: "#!/bin/true # {{.Param.ONE}}"},
+			{Script: ptr.Of("#!/bin/true # {{.Param.ONE}}")},
 		},
 		Probes: []limatype.Probe{
 			{Script: "#!/bin/false # {{.Param.ONE}}"},
@@ -234,7 +234,7 @@ func TestFillDefault(t *testing.T) {
 
 	expect.Provision = slices.Clone(y.Provision)
 	expect.Provision[0].Mode = limatype.ProvisionModeSystem
-	expect.Provision[0].Script = "#!/bin/true # Eins"
+	expect.Provision[0].Script = ptr.Of("#!/bin/true # Eins")
 
 	expect.Probes = slices.Clone(y.Probes)
 	expect.Probes[0].Mode = limatype.ProbeModeReadiness
@@ -364,7 +364,7 @@ func TestFillDefault(t *testing.T) {
 		},
 		Provision: []limatype.Provision{
 			{
-				Script: "#!/bin/true",
+				Script: ptr.Of("#!/bin/true"),
 				Mode:   limatype.ProvisionModeUser,
 			},
 		},
@@ -572,7 +572,7 @@ func TestFillDefault(t *testing.T) {
 		MountInotify: ptr.Of(true),
 		Provision: []limatype.Provision{
 			{
-				Script: "#!/bin/true",
+				Script: ptr.Of("#!/bin/true"),
 				Mode:   limatype.ProvisionModeSystem,
 			},
 		},
diff --git a/pkg/limayaml/limayaml_test.go b/pkg/limayaml/limayaml_test.go
index 97fd8bae..64e70b6f 100644
--- a/pkg/limayaml/limayaml_test.go
+++ b/pkg/limayaml/limayaml_test.go
@@ -51,6 +51,7 @@ func TestDefaultYAML(t *testing.T) {
 	y.Images = nil                // remove default images
 	y.Mounts = nil                // remove default mounts
 	y.Base = nil                  // remove default base templates
+	y.VMOpts = nil                // remove default vmopts mapping
 	y.MinimumLimaVersion = nil    // remove minimum Lima version
 	y.MountTypesUnsupported = nil // remove default workaround for kernel 6.9-6.11
 	t.Log(dumpJSON(t, y))
diff --git a/pkg/limayaml/marshal_test.go b/pkg/limayaml/marshal_test.go
index 4ad8f3ab..f70d6960 100644
--- a/pkg/limayaml/marshal_test.go
+++ b/pkg/limayaml/marshal_test.go
@@ -4,14 +4,23 @@
 package limayaml
 
 import (
+	"strings"
 	"testing"
+	"text/template"
 
+	"github.com/goccy/go-yaml"
 	"gotest.tools/v3/assert"
 
 	"github.com/lima-vm/lima/v2/pkg/limatype"
 	"github.com/lima-vm/lima/v2/pkg/ptr"
 )
 
+func dumpYAML(t *testing.T, d any) string {
+	b, err := yaml.Marshal(d)
+	assert.NilError(t, err)
+	return string(b)
+}
+
 func TestMarshalEmpty(t *testing.T) {
 	_, err := Marshal(&limatype.LimaYAML{}, false)
 	assert.NilError(t, err)
@@ -39,3 +48,137 @@ mounts:
 ...
 `)
 }
+
+type Opts struct {
+	Foo int
+	Bar string
+}
+
+var (
+	opts = Opts{Foo: 1, Bar: "two"}
+	text = `{"foo":1,"bar":"two"}`
+	code any
+)
+
+func TestConvert(t *testing.T) {
+	err := yaml.Unmarshal([]byte(text), &code)
+	assert.NilError(t, err)
+	o := opts
+	var a any
+	err = Convert(o, &a, "")
+	assert.NilError(t, err)
+	assert.DeepEqual(t, a, code)
+	err = Convert(a, &o, "")
+	assert.NilError(t, err)
+	assert.Equal(t, o, opts)
+}
+
+func TestVMOpts(t *testing.T) {
+	text := `
+vmType: null
+`
+	var y limatype.LimaYAML
+	err := Unmarshal([]byte(text), &y, "lima.yaml")
+	assert.NilError(t, err)
+	var o limatype.VMOpts
+	err = Convert(y.VMOpts, &o, "vmOpts")
+	assert.NilError(t, err)
+	t.Log(dumpYAML(t, o))
+}
+
+func TestQEMUOpts(t *testing.T) {
+	text := `
+vmType: "qemu"
+vmOpts:
+  qemu:
+    minimumVersion: null
+    cpuType:
+`
+	var y limatype.LimaYAML
+	err := Unmarshal([]byte(text), &y, "lima.yaml")
+	assert.NilError(t, err)
+	var o limatype.QEMUOpts
+	err = Convert(y.VMOpts[limatype.QEMU], &o, "vmOpts.qemu")
+	assert.NilError(t, err)
+	t.Log(dumpYAML(t, o))
+}
+
+func TestVZOpts(t *testing.T) {
+	text := `
+vmType: "vz"
+vmOpts:
+  vz:
+    rosetta:
+      enabled: null
+      binfmt: null
+`
+	var y limatype.LimaYAML
+	err := Unmarshal([]byte(text), &y, "lima.yaml")
+	assert.NilError(t, err)
+	var o limatype.VZOpts
+	err = Convert(y.VMOpts[limatype.VZ], &o, "vmOpts.vz")
+	assert.NilError(t, err)
+	t.Log(dumpYAML(t, o))
+}
+
+func TestVMOptsNull(t *testing.T) {
+	text := `
+vmOpts: null
+`
+	var y limatype.LimaYAML
+	err := Unmarshal([]byte(text), &y, "lima.yaml")
+	assert.NilError(t, err)
+	var o limatype.VMOpts
+	err = Convert(y.VMOpts, &o, "vmOpts")
+	assert.NilError(t, err)
+	var oq limatype.QEMUOpts
+	err = Convert(y.VMOpts[limatype.QEMU], &oq, "vmOpts.qemu")
+	assert.NilError(t, err)
+	var ov limatype.VZOpts
+	err = Convert(y.VMOpts[limatype.VZ], &ov, "vmOpts.vz")
+	assert.NilError(t, err)
+}
+
+type FormatData struct {
+	limatype.Instance `yaml:",inline"`
+}
+
+func TestVZOptsRosettaMessage(t *testing.T) {
+	text := `
+vmType: "vz"
+vmOpts:
+  vz:
+    rosetta:
+      enabled: true
+      binfmt: false
+
+message: |
+  {{- if .Instance.Config.VMOpts.vz.rosetta.enabled}}
+  Rosetta is enabled in this VM, so you can run x86_64 containers on Apple Silicon.
+  {{- end}}
+`
+	want := `vmType: vz
+vmOpts:
+  vz:
+    rosetta:
+      binfmt: false
+      enabled: true
+message: |
+  
+  Rosetta is enabled in this VM, so you can run x86_64 containers on Apple Silicon.
+`
+	var y limatype.LimaYAML
+	err := Unmarshal([]byte(text), &y, "lima.yaml")
+	assert.NilError(t, err)
+	tmpl, err := template.New("format").Parse(y.Message)
+	assert.NilError(t, err)
+	inst := limatype.Instance{Config: &y}
+	var message strings.Builder
+	data := FormatData{Instance: inst}
+	err = tmpl.Execute(&message, data)
+	assert.NilError(t, err)
+	y.Message = message.String()
+	b, err := Marshal(&y, false)
+	assert.NilError(t, err)
+	assert.Equal(t, string(b), want)
+}
diff --git a/pkg/sshutil/sshutil_test.go b/pkg/sshutil/sshutil_test.go
index da879784..b7fe84b3 100644
--- a/pkg/sshutil/sshutil_test.go
+++ b/pkg/sshutil/sshutil_test.go
@@ -53,3 +53,87 @@ func Test_detectValidPublicKey(t *testing.T) {
 	assert.Check(t, !detectValidPublicKey("arbitrary content"))
 	assert.Check(t, !detectValidPublicKey(""))
 }
+
+func Test_DisableControlMasterOptsFromSSHArgs(t *testing.T) {
+	tests := []struct {
+		name    string
+		sshArgs []string
+		want    []string
+	}{
+		{
+			name: "no ControlMaster options",
+			sshArgs: []string{
+				"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+		{
+			name: "ControlMaster=yes",
+			sshArgs: []string{
+				"-o", "ControlMaster=yes", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+		{
+			name: "ControlMaster=auto",
+			sshArgs: []string{
+				"-o", "ControlMaster=auto", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+		{
+			name: "ControlMaster=auto with ControlPath",
+			sshArgs: []string{
+				"-o", "ControlMaster=auto", "-o", "ControlPath=/tmp/ssh-%r@%h:%p", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+		{
+			name: "ControlPath only",
+			sshArgs: []string{
+				"-o", "ControlPath=/tmp/ssh-%r@%h:%p", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+		{
+			name: "ControlMaster=no",
+			sshArgs: []string{
+				"-o", "ControlMaster=no", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+		{
+			name: "ControlMaster=auto with other options",
+			sshArgs: []string{
+				"-o", "ControlMaster=auto", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
+			},
+			want: []string{
+				"-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ControlPersist=no",
+				"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.DeepEqual(t, DisableControlMasterOptsFromSSHArgs(tt.sshArgs), tt.want)
+		})
+	}
+}