Repository: lima-vm/lima
Pull request #3072 (2025-01-01 06:41:28)

Assemble templates using the new `base` setting
This PR creates initial support for multi-file templates, mostly based on the suggestions I gave in https://github.com/lima-vm/lima/discussions/2520#discussioncomment-10193570.

## Instance create vs. start stages

The basic idea behind "template embedding" is that when an instance is created, the `lima.yaml` that is stored in the instance directory will embed settings from additional
base template(s) and external scripts into a single composite document.

Once the instance is created, there are no longer external references and the instance can be started while offline, even if it was created from online template fragments.

## Merging default settings from base templates

Merging of templates is controlled by the new `base` property, which takes the name of
a base template, e.g.

```yaml
base: template://docker
```

It is expected that in most instances only a single base template is needed, but it is possible to provide a list:

```yaml
base:
- variables.yaml
- http://example.com/template.yaml
```

The base template can in turn have their own base templates. For example `my.yaml` instance may be based on `template://docker`, which in turn could be based on `template://ubuntu`.

Merging is performed by copying every property from the base template that does not yet exist in the main template. For list properties the lists are appended. More details and exceptions are documented below.

## Embedding of external provisioning scripts

Both provisioning scripts and probe scripts can now be located with the new `file` property specifying a template locator (details below). The file will be loaded and inlined under the `script` property. Therefore `file` and `script` are mutually exclusive.

```yaml
provision:
- mode: user
  file: my-script.sh
probes:
- file: template://docker-probe.sh
```

## Relative template locators

The values of the `base` and `file` properties are "template locators". They are the usual local filenames or `file://`, `https://`, or `template://`URLs.

In addition they can also be relative locators: names that don't have a URL scheme and don't start with a `/`. These names are resolved relative to the template in which they occur (similar to relative `href` properties in HTML).

```yaml
base: base.yaml
provision:
- file: script.sh
```

If this template is loaded as `templates/main.yaml`, then the referenced template files will be fetched from `templates/base.yaml` and `templates/script.sh`.

If the template is loaded as `template://main`, then the files will be `template://base.yaml` and `template://script.sh`.

This means a template using relative locators will always fetch dependencies from the same (relative) location regardless of how it was loaded itself.

To make this work `template://base.yaml` needs to be equivalent to `template://base`. And it must be possible to use other file extensions, so `template://script.sh` loads the script and doesn't look for `script.sh.yaml`.

## limactl template command

This PR adds new options to the `limactl template copy` subcommand.

* `--verbatim` copies a file _exactly_ as is.

* `--embed` will merge in all external base templates and shell scripts.

* `--embed-all` will also merge in templates and files referenced by `template://` URLs.

* `--fill` will apply `override.yaml`, `default.yaml`, and builtin defaults.

Without additional options the `copy` command will copy the file, but turn any relative locators into absolute locators. So the copy of the template will continue to reference the same base templates and scripts. Use the `--verbatim` option if you intend to copy those files locally as well.

## Examples

### Embedding a script

### Merging in `template://default`

## Detailed merging algorithm

Each template will recusively embed all dependencies in its base templates before merging them in.

Then each property, that does not exist in the main template will be copied over from the base template.

Properties that are lists are appended.

For `provision` and `probes` the order is reversed: the base entries are inserted **before** the main template entries, so that latter are executed last (see #2932 for discussion).

### Special rules

The `dns` entries are not merged. The entries from a base template are only copied when the main template list is empty.

`mountTypesSupported` will have any duplicate entries removed.

The `base` property is removed if the base template has been embedded.

For `provision` and `probes` the `file` property is removed, and the actual content of the script is stored in the usual `script` property.

For `minimumLimaVersion` and `vmOpts.qemy.minimumVersion` the values are compared. and the base version is only copied when it is greater than the main version.

### Combining list entries

Three list properties (`additionalDisks`, `mounts`, and `networks`) use combining logic based on a unique key (disk name, mount point, and interface name, respectively).

If a latter entry matches the key of an earlier entry, then any missing fields in the earlier entry are filled in from the latter entry, which is then deleted. This matches the logic already used for merging `override.yaml` and `default.yaml`.

There is a new wildcard feature now, where `*` will match any existing key, so can be used to provide defaults for all entries. This is mostly in preparation to use this mechanism for the builtin defaults.

For the combining list mechanism to work, **all** base templates must be merged (so all lists must contain the complete set of entries). Otherwise the key matching mechanism wouldn't work globally, but only for the parent template at each level.


jandubois: This PR still needs some integration tests, but I wanted to create it before 2024 was over. 😄 
jandubois: @AkihiroSuda I believe I've addressed all your code review feedback so far, as well as all the implications from our discussion about digest support (the schema has been updated, but the digest computation/checking is left for a future PR).

I've rebased this PR since you originally reviewed it, but any further changes are all in separate commits. I've not yet squashed them, in case it makes it easier for you to continue the review; but let me know if you prefer that I squash them now!
jandubois: > I will briefly to turn this into Draft mode again because I think there is another condition in `useAbsLocators` that is not fully implemented, and I need to figure out how to add a test for it.

I think I have all the combinations covered now (famous last words alert), so marking it ready for review again.

And again, let me know if you prefer me to squash all the commits already!
nirs: Does this introduce any breaking change or it should be completely compatible with existing templates?
jandubois: > Does this introduce any breaking change or it should be completely compatible with existing templates?

It should be completely backwards compatible.

> Can we merge this and release v1.1.0-alpha.0 ?

I have another commit that should go into the alpha, but I want to create a separate PR for it instead of piling on even more changes on this one.

It is based on a discussion I had with @nirs on Slack about supporting external data files, so you can e.g. write a YAML config file using automation, and don't have to wrap it into a shell script that you put into a provisioning script. The change allows you to do this:

```
provision:
- mode: data
  path: /etc/conf.d/example
  content: |
    VAR=VALUE
  overwrite: false
- mode: data
  path: "{{.Home}}/kubeadm.yaml"
  file: kubeadm.yaml
  owner: "{{.User}}"
  permissions: 0700
```

The `file` property works the same way it works for provision scripts and probes.

It is pretty similar to the `write_files` feature in cloud-init. I can write some more about it when I open the PR.
jandubois: I had to rebase the PR to resolve the merge conflict with recent changes to `pkg/limayaml/marshal.go`. There are no other changes.
AkihiroSuda: Let's merge, after another rebase to add the licensing boilerplate

jandubois: > Let's merge, after another rebase to add the licensing boilerplate

Rebased and copyright headers added...

Patch:
diff --git a/cmd/limactl/start.go b/cmd/limactl/start.go
index 62f1ee77..d6ea4251 100644
--- a/cmd/limactl/start.go
+++ b/cmd/limactl/start.go
@@ -205,7 +205,9 @@ func loadOrCreateInstance(cmd *cobra.Command, args []string, createOnly bool) (*
 			return nil, err
 		}
 	}
-
+	if err := tmpl.Embed(cmd.Context(), true, true); err != nil {
+		return nil, err
+	}
 	yqExprs, err := editflags.YQExpressions(flags, true)
 	if err != nil {
 		return nil, err
diff --git a/cmd/limactl/template.go b/cmd/limactl/template.go
index bb4fb0ad..996aed59 100644
--- a/cmd/limactl/template.go
+++ b/cmd/limactl/template.go
@@ -4,6 +4,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -26,6 +27,9 @@ func newTemplateCommand() *cobra.Command {
 		// The template command is still hidden because the subcommands and options are still under development
 		// and subject to change at any time.
 		Hidden: true,
+		PreRun: func(*cobra.Command, []string) {
+			logrus.Warn("`limactl template` is experimental")
+		},
 	}
 	templateCommand.AddCommand(
 		newTemplateCopyCommand(),
@@ -46,23 +50,53 @@ var templateCopyExample = `  Template locators are local files, file://, https:/
   # Copy default template to STDOUT
   limactl template copy template://default -
 
-  # Copy template from web location to local file
-  limactl template copy https://example.com/lima.yaml mighty-machine.yaml
+  # Copy template from web location to local file and embed all external references
+  # (this does not embed template:// references)
+  limactl template copy --embed https://example.com/lima.yaml mighty-machine.yaml
 `
 
 func newTemplateCopyCommand() *cobra.Command {
 	templateCopyCommand := &cobra.Command{
-		Use:     "copy TEMPLATE DEST",
+		Use:     "copy [OPTIONS] TEMPLATE DEST",
 		Short:   "Copy template",
 		Long:    "Copy a template via locator to a local file",
 		Example: templateCopyExample,
 		Args:    WrapArgsError(cobra.ExactArgs(2)),
 		RunE:    templateCopyAction,
 	}
+	templateCopyCommand.Flags().Bool("embed", false, "embed external dependencies into template")
+	templateCopyCommand.Flags().Bool("embed-all", false, "embed all dependencies into template")
+	templateCopyCommand.Flags().Bool("fill", false, "fill defaults")
+	templateCopyCommand.Flags().Bool("verbatim", false, "don't make locators absolute")
 	return templateCopyCommand
 }
 
 func templateCopyAction(cmd *cobra.Command, args []string) error {
+	embed, err := cmd.Flags().GetBool("embed")
+	if err != nil {
+		return err
+	}
+	embedAll, err := cmd.Flags().GetBool("embed-all")
+	if err != nil {
+		return err
+	}
+	fill, err := cmd.Flags().GetBool("fill")
+	if err != nil {
+		return err
+	}
+	verbatim, err := cmd.Flags().GetBool("verbatim")
+	if err != nil {
+		return err
+	}
+	if fill {
+		embedAll = true
+	}
+	if embedAll {
+		embed = true
+	}
+	if embed && verbatim {
+		return errors.New("--verbatim cannot be used with any of --embed, --embed-all, or --fill")
+	}
 	tmpl, err := limatmpl.Read(cmd.Context(), "", args[0])
 	if err != nil {
 		return err
@@ -70,6 +104,35 @@ func templateCopyAction(cmd *cobra.Command, args []string) error {
 	if len(tmpl.Bytes) == 0 {
 		return fmt.Errorf("don't know how to interpret %q as a template locator", args[0])
 	}
+	if !verbatim {
+		if embed {
+			// Embed default base.yaml only when fill is true.
+			if err := tmpl.Embed(cmd.Context(), embedAll, fill); err != nil {
+				return err
+			}
+		} else {
+			if err := tmpl.UseAbsLocators(); err != nil {
+				return err
+			}
+		}
+	}
+	if fill {
+		limaDir, err := dirnames.LimaDir()
+		if err != nil {
+			return err
+		}
+		// Load() will merge the template with override.yaml and default.yaml via FillDefaults().
+		// FillDefaults() needs the potential instance directory to validate host templates using {{.Dir}}.
+		filePath := filepath.Join(limaDir, tmpl.Name+".yaml")
+		tmpl.Config, err = limayaml.Load(tmpl.Bytes, filePath)
+		if err != nil {
+			return err
+		}
+		tmpl.Bytes, err = limayaml.Marshal(tmpl.Config, false)
+		if err != nil {
+			return err
+		}
+	}
 	writer := cmd.OutOrStdout()
 	target := args[1]
 	if target != "-" {
@@ -116,10 +179,14 @@ func templateValidateAction(cmd *cobra.Command, args []string) error {
 		if tmpl.Name == "" {
 			return fmt.Errorf("can't determine instance name from template locator %q", arg)
 		}
+		// Embed default base.yaml only when fill is true.
+		if err := tmpl.Embed(cmd.Context(), true, fill); err != nil {
+			return err
+		}
 		// Load() will merge the template with override.yaml and default.yaml via FillDefaults().
 		// FillDefaults() needs the potential instance directory to validate host templates using {{.Dir}}.
-		instDir := filepath.Join(limaDir, tmpl.Name)
-		y, err := limayaml.Load(tmpl.Bytes, instDir)
+		filePath := filepath.Join(limaDir, tmpl.Name+".yaml")
+		y, err := limayaml.Load(tmpl.Bytes, filePath)
 		if err != nil {
 			return err
 		}
diff --git a/hack/cache-common-inc.sh b/hack/cache-common-inc.sh
index 70d61d41..bb1cb6b2 100755
--- a/hack/cache-common-inc.sh
+++ b/hack/cache-common-inc.sh
@@ -18,17 +18,18 @@ function error_exit() {
 # ```
 function download_template_if_needed() {
 	local template="$1"
-	case "${template}" in
-	https://*)
-		tmp_yaml=$(mktemp -d)/template.yaml
-		curl -sSLf "${template}" >"${tmp_yaml}" || return
-		echo "${tmp_yaml}"
-		;;
-	*)
-		test -f "${template}" || return
-		echo "${template}"
-		;;
-	esac
+	tmp_yaml="$(mktemp -d)/template.yaml"
+	# The upgrade test doesn't have limactl installed first. The old version wouldn't support `limactl tmpl` anyways.
+	if command -v limactl >/dev/null; then
+		limactl tmpl copy --embed-all "${template}" "${tmp_yaml}" || return
+	else
+		if [[ $template == https://* ]]; then
+			curl -sSLf "${template}" >"${tmp_yaml}" || return
+		else
+			cp "${template}" "${tmp_yaml}"
+		fi
+	fi
+	echo "${tmp_yaml}"
 }
 
 # e.g.
diff --git a/pkg/limatmpl/abs.go b/pkg/limatmpl/abs.go
new file mode 100644
index 00000000..2598b377
--- /dev/null
+++ b/pkg/limatmpl/abs.go
@@ -0,0 +1,131 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+	"path"
+	"path/filepath"
+	"runtime"
+	"strings"
+)
+
+// UseAbsLocators will replace all relative template locators with absolute ones, so this template
+// can be stored anywhere and still reference the same base templates and files.
+func (tmpl *Template) UseAbsLocators() error {
+	err := tmpl.useAbsLocators()
+	return tmpl.ClearOnError(err)
+}
+
+func (tmpl *Template) useAbsLocators() error {
+	if err := tmpl.Unmarshal(); err != nil {
+		return err
+	}
+	basePath, err := basePath(tmpl.Locator)
+	if err != nil {
+		return err
+	}
+	for i, baseLocator := range tmpl.Config.Base {
+		absLocator, err := absPath(baseLocator.URL, basePath)
+		if err != nil {
+			return err
+		}
+		if i == 0 {
+			// base can be either a single string (URL), or a single locator object, or a list whose first element can be either a string or an object
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.base | select(type == \"!!str\")) |= %q\n", absLocator))
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.base | select(type == \"!!map\") | .url) |= %q\n", absLocator))
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.base | select(type == \"!!seq\" and (.[0] | type) == \"!!str\") | .[0]) |= %q\n", absLocator))
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.base | select(type == \"!!seq\" and (.[0] | type) == \"!!map\") | .[0].url) |= %q\n", absLocator))
+		} else {
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.base[%d] | select(type == \"!!str\")) |= %q\n", i, absLocator))
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.base[%d] | select(type == \"!!map\") | .url) |= %q\n", i, absLocator))
+		}
+	}
+	for i, p := range tmpl.Config.Probes {
+		if p.File != nil {
+			absLocator, err := absPath(p.File.URL, basePath)
+			if err != nil {
+				return err
+			}
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.probes[%d].file | select(type == \"!!str\")) = %q\n", i, absLocator))
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.probes[%d].file | select(type == \"!!map\") | .url) = %q\n", i, absLocator))
+		}
+	}
+	for i, p := range tmpl.Config.Provision {
+		if p.File != nil {
+			absLocator, err := absPath(p.File.URL, basePath)
+			if err != nil {
+				return err
+			}
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.provision[%d].file | select(type == \"!!str\")) = %q\n", i, absLocator))
+			tmpl.expr.WriteString(fmt.Sprintf("| ($a.provision[%d].file | select(type == \"!!map\") | .url) = %q\n", i, absLocator))
+		}
+	}
+	return tmpl.evalExpr()
+}
+
+// withVolume adds the volume name of the current working directory to a path without volume name.
+// On Windows filepath.Abs() only returns a "rooted" name, but does not add the volume name.
+// withVolume also normalizes all path separators to the platform native one.
+func withVolume(path string) (string, error) {
+	if runtime.GOOS == "windows" && filepath.VolumeName(path) == "" {
+		root, err := filepath.Abs("/")
+		if err != nil {
+			return "", err
+		}
+		path = filepath.VolumeName(root) + path
+	}
+	return filepath.Clean(path), nil
+}
+
+// basePath returns the locator in absolute format, but without the filename part.
+func basePath(locator string) (string, error) {
+	u, err := url.Parse(locator)
+	// Single-letter schemes will be drive names on Windows, e.g. "c:/foo"
+	if err == nil && len(u.Scheme) > 1 {
+		// path.Dir("") returns ".", which must be removed for url.JoinPath() to do the right thing later
+		return u.Scheme + "://" + strings.TrimSuffix(path.Dir(path.Join(u.Host, u.Path)), "."), nil
+	}
+	base, err := filepath.Abs(filepath.Dir(locator))
+	if err != nil {
+		return "", err
+	}
+	return withVolume(base)
+}
+
+// absPath either returns the locator directly, or combines it with the basePath if the locator is a relative path.
+func absPath(locator, basePath string) (string, error) {
+	if locator == "" {
+		return "", errors.New("locator is empty")
+	}
+	u, err := url.Parse(locator)
+	if err == nil && len(u.Scheme) > 1 {
+		return locator, nil
+	}
+	// Check for rooted locator; filepath.IsAbs() returns false on Windows when the volume name is missing
+	volumeLen := len(filepath.VolumeName(locator))
+	if locator[volumeLen] != '/' && locator[volumeLen] != filepath.Separator {
+		switch {
+		case basePath == "":
+			return "", errors.New("basePath is empty")
+		case basePath == "-":
+			return "", errors.New("can't use relative paths when reading template from STDIN")
+		case strings.Contains(locator, "../"):
+			return "", fmt.Errorf("relative locator path %q must not contain '../' segments", locator)
+		case volumeLen != 0:
+			return "", fmt.Errorf("relative locator path %q must not include a volume name", locator)
+		}
+		u, err = url.Parse(basePath)
+		if err != nil {
+			return "", err
+		}
+		if len(u.Scheme) > 1 {
+			return u.JoinPath(locator).String(), nil
+		}
+		locator = filepath.Join(basePath, locator)
+	}
+	return withVolume(locator)
+}
diff --git a/pkg/limatmpl/embed.go b/pkg/limatmpl/embed.go
new file mode 100644
index 00000000..b73d0330
--- /dev/null
+++ b/pkg/limatmpl/embed.go
@@ -0,0 +1,595 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"slices"
+	"sync"
+
+	"github.com/coreos/go-semver/semver"
+	"github.com/lima-vm/lima/pkg/limayaml"
+	"github.com/lima-vm/lima/pkg/store/dirnames"
+	"github.com/lima-vm/lima/pkg/store/filenames"
+	"github.com/lima-vm/lima/pkg/version/versionutil"
+	"github.com/lima-vm/lima/pkg/yqutil"
+	"github.com/sirupsen/logrus"
+)
+
+var warnBaseIsExperimental = sync.OnceFunc(func() {
+	logrus.Warn("`base` is experimental")
+})
+
+var warnFileIsExperimental = sync.OnceFunc(func() {
+	logrus.Warn("`provision[*].file` and `probes[*].file` are experimental")
+})
+
+// Embed will recursively resolve all "base" dependencies and update the
+// template with the merged result. It also inlines all external provisioning
+// and probe scripts.
+func (tmpl *Template) Embed(ctx context.Context, embedAll, defaultBase bool) error {
+	if err := tmpl.UseAbsLocators(); err != nil {
+		return err
+	}
+	seen := make(map[string]bool)
+	err := tmpl.embedAllBases(ctx, embedAll, defaultBase, seen)
+	// additionalDisks, mounts, and networks may combine entries based on a shared key
+	// This must be done after **all** base templates have been merged, so that wildcard keys can match
+	// against all earlier list entries, and not just against the direct parent template.
+	if err == nil {
+		err = tmpl.combineListEntries()
+	}
+	return tmpl.ClearOnError(err)
+}
+
+func (tmpl *Template) embedAllBases(ctx context.Context, embedAll, defaultBase bool, seen map[string]bool) error {
+	logrus.Debugf("Embedding templates into %q", tmpl.Locator)
+	if defaultBase {
+		configDir, err := dirnames.LimaConfigDir()
+		if err != nil {
+			return err
+		}
+		defaultBaseFilename := filepath.Join(configDir, filenames.Base)
+		if _, err := os.Stat(defaultBaseFilename); err == nil {
+			// turn string into single element list
+			// empty concatenation works around bug https://github.com/mikefarah/yq/issues/2269
+			tmpl.expr.WriteString("| ($a.base | select(type == \"!!str\")) |= [\"\" + .]\n")
+			tmpl.expr.WriteString("| ($a.base | select(type == \"!!map\")) |= [[] + .]\n")
+			// prepend base template at the beginning of the list
+			tmpl.expr.WriteString(fmt.Sprintf("| $a.base = [%q, $a.base[]]\n", defaultBaseFilename))
+			if err := tmpl.evalExpr(); err != nil {
+				return err
+			}
+		}
+	}
+	for {
+		if err := tmpl.Unmarshal(); err != nil {
+			return err
+		}
+		if len(tmpl.Config.Base) == 0 {
+			break
+		}
+		baseLocator := tmpl.Config.Base[0]
+		if baseLocator.Digest != nil {
+			return fmt.Errorf("base %q in %q has specified a digest; digest support is not yet implemented", baseLocator.URL, tmpl.Locator)
+		}
+		isTemplate, _ := SeemsTemplateURL(baseLocator.URL)
+		if isTemplate && !embedAll {
+			// Once we skip a template:// URL we can no longer embed any other base template
+			for i := 1; i < len(tmpl.Config.Base); i++ {
+				isTemplate, _ = SeemsTemplateURL(tmpl.Config.Base[i].URL)
+				if !isTemplate {
+					return fmt.Errorf("cannot embed template %q after not embedding %q", tmpl.Config.Base[i].URL, baseLocator.URL)
+				}
+			}
+			break
+			// TODO should we track embedding of template:// URLs so we can warn if we embed a non-template:// URL afterwards?
+		}
+
+		if seen[baseLocator.URL] {
+			return fmt.Errorf("base template loop detected: template %q already included", baseLocator.URL)
+		}
+		seen[baseLocator.URL] = true
+
+		// remove base[0] from template before merging
+		if err := tmpl.embedBase(ctx, baseLocator, embedAll, seen); err != nil {
+			return err
+		}
+	}
+	if err := tmpl.embedAllScripts(ctx, embedAll); err != nil {
+		return err
+	}
+	if len(tmpl.Bytes) > yBytesLimit {
+		return fmt.Errorf("template %q embedding exceeded the size limit (%d bytes)", tmpl.Locator, yBytesLimit)
+	}
+	return nil
+}
+
+func (tmpl *Template) embedBase(ctx context.Context, baseLocator limayaml.LocatorWithDigest, embedAll bool, seen map[string]bool) error {
+	warnBaseIsExperimental()
+	logrus.Debugf("Embedding base %q in template %q", baseLocator.URL, tmpl.Locator)
+	if err := tmpl.Unmarshal(); err != nil {
+		return err
+	}
+	base, err := Read(ctx, "", baseLocator.URL)
+	if err != nil {
+		return err
+	}
+	if err := base.UseAbsLocators(); err != nil {
+		return err
+	}
+	if err := base.embedAllBases(ctx, embedAll, false, seen); err != nil {
+		return err
+	}
+	if err := tmpl.merge(base); err != nil {
+		return err
+	}
+	if len(tmpl.Bytes) > yBytesLimit {
+		return fmt.Errorf("template %q embedding exceeded the size limit (%d bytes)", tmpl.Locator, yBytesLimit)
+	}
+	return nil
+}
+
+// evalExprImpl evaluates tmpl.expr against one or more documents.
+// Called by evalExpr() and embedAllScripts() for single documents and merge() for 2 documents.
+func (tmpl *Template) evalExprImpl(prefix string, b []byte) error {
+	var err error
+	expr := prefix + tmpl.expr.String() + "| $a"
+	tmpl.Bytes, err = yqutil.EvaluateExpression(expr, b)
+	// Make sure the YAML ends with just a single newline
+	tmpl.Bytes = append(bytes.TrimRight(tmpl.Bytes, "\n"), '\n')
+	tmpl.Config = nil
+	tmpl.expr.Reset()
+	return tmpl.ClearOnError(err)
+}
+
+// evalExpr evaluates tmpl.expr against the tmpl.Bytes document.
+func (tmpl *Template) evalExpr() error {
+	var err error
+	if tmpl.expr.Len() > 0 {
+		// There is just a single document; $a and $b are the same
+		singleDocument := "select(document_index == 0) as $a | $a as $b\n"
+		err = tmpl.evalExprImpl(singleDocument, tmpl.Bytes)
+	}
+	return err
+}
+
+// merge merges the base template into tmpl.
+func (tmpl *Template) merge(base *Template) error {
+	if err := tmpl.mergeBase(base); err != nil {
+		return tmpl.ClearOnError(err)
+	}
+	documents := fmt.Sprintf("%s\n---\n%s", string(tmpl.Bytes), string(base.Bytes))
+	return tmpl.evalExprImpl(mergeDocuments, []byte(documents))
+}
+
+// mergeBase generates a yq script to merge the template with a base.
+// Most of the merging is done generically by the mergeDocuments script below.
+// Only thing left is to compare minimum version numbers and keep the highest version.
+func (tmpl *Template) mergeBase(base *Template) error {
+	if err := tmpl.Unmarshal(); err != nil {
+		return err
+	}
+	if err := base.Unmarshal(); err != nil {
+		return err
+	}
+	if tmpl.Config.MinimumLimaVersion != nil && base.Config.MinimumLimaVersion != nil {
+		if versionutil.GreaterThan(*base.Config.MinimumLimaVersion, *tmpl.Config.MinimumLimaVersion) {
+			const minimumLimaVersion = "minimumLimaVersion"
+			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)
+		if tmplVersion.LessThan(baseVersion) {
+			const minimumQEMUVersion = "vmOpts.qemu.minimumVersion"
+			tmpl.copyField(minimumQEMUVersion, minimumQEMUVersion)
+		}
+	}
+	return nil
+}
+
+// mergeDocuments copies over settings from the base that don't yet exist
+// in the template, and to append lists from the base to template lists.
+// Both head and line comments are copied over as well.
+//
+// It also handles these special cases:
+// * dns lists are not merged and only copied when the template doesn't have any dns entries at all.
+// * probes and provision scripts are appended in reverse order.
+// * mountTypesUnsupported have duplicate values removed.
+// * base is removed from the template.
+const mergeDocuments = `
+  select(document_index == 0) as $a
+| select(document_index == 1) as $b
+
+# $c will be mutilated to implement our own "merge only new fields" logic.
+| $b as $c
+
+# Delete the base that is being merged right now
+| $a | select(.base | tag == "!!seq") | del(.base[0])
+| $a | select(.base | (tag == "!!seq" and length == 0)) | del(.base)
+| $a | select(.base | tag == "!!str") | del(.base)
+
+# If $a.base is a list, then $b.base must be a list as well
+# (note $b, not $c, because we merge lists from $b)
+| $b | select((.base | tag == "!!str") and ($a.base | tag == "!!seq")) | .base = [ "" + .base ]
+
+# Delete base DNS entries if the template list is not empty.
+| $a | select(.dns) | del($b.dns, $c.dns)
+
+# Mark all new list fields with a custom tag. This is needed to avoid appending
+# newly copied lists to themselves again when we merge lists.
+| $c | .. | select(tag == "!!seq") tag = "!!tag"
+
+# Delete all nodes in $c that are in $a and not a map. This is necessary because
+# the yq "*n" operator (merge only new fields) does not copy all comments across.
+| $c | delpaths([$a | .. | select(tag != "!!map") | path])
+
+# Merging with null returns null; use an empty map if $c has only comments
+| $a * ($c // {}) as $a
+
+# Find all elements that are existing lists. This will not match newly
+# copied lists because they have a custom !!tag instead of !!seq.
+# Append the elements from the same path in $b.
+# Exception: base templates, provision scripts and probes are prepended instead.
+| $a | (.. | select(tag == "!!seq" and (path[0] | test("^(base|provision|probes)$") | not))) |=
+   (. + (path[] as $p ireduce ($b; .[$p])))
+| $a | (.. | select(tag == "!!seq" and (path[0] | test("^(base|provision|probes)$")))) |=
+   ((path[] as $p ireduce ($b; .[$p])) + .)
+
+# Copy head and line comments for existing lists that do not already have comments.
+# New lists and existing maps already have their comments updated by the $a * $c merge.
+| $a | (.. | select(tag == "!!seq" and (key | head_comment == "")) | key) head_comment |=
+   (((path[] as $p ireduce ($b; .[$p])) | key | head_comment) // "")
+| $a | (.. | select(tag == "!!seq" and (key | line_comment == "")) | key) line_comment |=
+   (((path[] as $p ireduce ($b; .[$p])) | key | line_comment) // "")
+
+# Make sure mountTypesUnsupported elements are unique.
+| $a | (select(.mountTypesUnsupported) | .mountTypesUnsupported) |= unique
+
+# Remove the custom tags again so they do not clutter up the YAML output.
+| $a | .. tag = ""
+`
+
+// listFields returns dst and src fields like "list[idx].field".
+func listFields(list string, dstIdx, srcIdx int, field string) (dst, src string) {
+	dst = fmt.Sprintf("%s[%d]", list, dstIdx)
+	src = fmt.Sprintf("%s[%d]", list, srcIdx)
+	if field != "" {
+		dst += "." + field
+		src += "." + field
+	}
+	return dst, src
+}
+
+// copyField copies value and comments from $b.src to $a.dst.
+func (tmpl *Template) copyField(dst, src string) {
+	tmpl.expr.WriteString(fmt.Sprintf("| ($a.%s) = $b.%s\n", dst, src))
+	// The head_comment is on the key and not the value, so needs to be copied explicitly.
+	// Surprisingly the line_comment seems to be copied with the value already even though it is also on the key.
+	tmpl.expr.WriteString(fmt.Sprintf("| ($a.%s | key) head_comment = ($b.%s | key | head_comment)\n", dst, src))
+}
+
+// copyListEntryField copies $b.list[srcIdx].field to $a.list[dstIdx].field (including comments).
+// Note: field must not be "" and must not be a list field itself either.
+func (tmpl *Template) copyListEntryField(list string, dstIdx, srcIdx int, field string) {
+	tmpl.copyField(listFields(list, dstIdx, srcIdx, field))
+}
+
+type commentType string
+
+const (
+	headComment commentType = "head"
+	lineComment commentType = "line"
+)
+
+// copyComment copies a non-empty head or line comment from $b.src to $a.dst, but only if $a.dst already exists.
+func (tmpl *Template) copyComment(dst, src string, commentType commentType, isMapElement bool) {
+	onKey := ""
+	if isMapElement {
+		onKey = " | key" // For map elements the comments are on the keys and not the values.
+	}
+	// The expression is careful not to create a null $a.dst entry if $b.src has no comments and $a.dst didn't already exist.
+	// e.g.: `| $a | (select(.foo) | .foo | key | select(head_comment == "" and ($b.bar | key | head_comment != ""))) head_comment |= ($b.bar | key | head_comment)`
+	tmpl.expr.WriteString(fmt.Sprintf("| $a | (select(.%s) | .%s%s | select(%s_comment == \"\" and ($b.%s%s | %s_comment != \"\"))) %s_comment |= ($b.%s%s | %s_comment)\n",
+		dst, dst, onKey, commentType, src, onKey, commentType, commentType, src, onKey, commentType))
+}
+
+// copyComments copies all non-empty comments from $b.src to $a.dst.
+func (tmpl *Template) copyComments(dst, src string, isMapElement bool) {
+	for _, commentType := range []commentType{headComment, lineComment} {
+		tmpl.copyComment(dst, src, commentType, isMapElement)
+	}
+}
+
+// copyListEntryComments copies all non-empty comments from $b.list[srcIdx].field to $a.list[dstIdx].field.
+func (tmpl *Template) copyListEntryComments(list string, dstIdx, srcIdx int, field string) {
+	dst, src := listFields(list, dstIdx, srcIdx, field)
+	isMapElement := field != ""
+	tmpl.copyComments(dst, src, isMapElement)
+}
+
+func (tmpl *Template) deleteListEntry(list string, idx int) {
+	tmpl.expr.WriteString(fmt.Sprintf("| del($a.%s[%d], $b.%s[%d])\n", list, idx, list, idx))
+}
+
+// upgradeListEntryStringToMapField turns list[idx] from a string to a {field: list[idx]} map.
+func (tmpl *Template) upgradeListEntryStringToMapField(list string, idx int, field string) {
+	// TODO the head_comment on the string becomes duplicated as a foot_comment on the new field; could be a yq bug?
+	tmpl.expr.WriteString(fmt.Sprintf("| ($a.%s[%d] | select(type == \"!!str\")) |= {\"%s\": .}\n", list, idx, field))
+}
+
+// combineListEntries combines entries based on a shared unique key.
+// If two entries share the same key, then any missing fields in the earlier entry are
+// filled in from the latter one. The latter one is then deleted.
+//
+// Notes:
+// * The field order is not maintained when entries with a matching key are merged.
+// * The unique keys (and mount locations) are assumed to not be subject to Go templating.
+// * A wildcard key '*' matches all prior list entries.
+func (tmpl *Template) combineListEntries() error {
+	if err := tmpl.Unmarshal(); err != nil {
+		return err
+	}
+
+	tmpl.combineAdditionalDisks()
+	tmpl.combineMounts()
+	tmpl.combineNetworks()
+
+	return tmpl.evalExpr()
+}
+
+// TODO: Maybe instead of hard-coding all the yaml names of LimaYAML struct fields we should
+// TODO: retrieve them using reflection from the Go type tags to avoid possible typos.
+
+// combineAdditionalDisks combines additionalDisks entries. The shared key is the disk name.
+func (tmpl *Template) combineAdditionalDisks() {
+	const additionalDisks = "additionalDisks"
+
+	diskIdx := make(map[string]int, len(tmpl.Config.AdditionalDisks))
+	for src := 0; src < len(tmpl.Config.AdditionalDisks); {
+		disk := tmpl.Config.AdditionalDisks[src]
+		var from, to int
+		if disk.Name == "*" {
+			// copy to **all** previous entries
+			from = 0
+			to = src - 1
+		} else {
+			if i, ok := diskIdx[disk.Name]; ok {
+				// copy to previous disk with the same diskIdx
+				from = i
+				to = i
+			} else {
+				// record disk index and continue with the next entry
+				if disk.Name != "" {
+					diskIdx[disk.Name] = src
+				}
+				src++
+				continue
+			}
+		}
+		for dst := from; dst <= to; dst++ {
+			// upgrade additionalDisks[dst] from "disk" name string to {"name": "disk"} map so we can add fields
+			upgradeDiskToMap := sync.OnceFunc(func() {
+				tmpl.upgradeListEntryStringToMapField(additionalDisks, dst, "name")
+			})
+
+			dest := &tmpl.Config.AdditionalDisks[dst]
+			if dest.Format == nil && disk.Format != nil {
+				upgradeDiskToMap()
+				tmpl.copyListEntryField(additionalDisks, dst, src, "format")
+				dest.Format = disk.Format
+			}
+			// TODO: Does it make sense to merge "fsType" and "fsArgs" independently of each other?
+			if dest.FSType == nil && disk.FSType != nil {
+				upgradeDiskToMap()
+				tmpl.copyListEntryField(additionalDisks, dst, src, "fsType")
+				dest.FSType = disk.FSType
+			}
+			// "fsArgs" are inherited all-or-nothing; they are not appended
+			if len(dest.FSArgs) == 0 && len(disk.FSArgs) != 0 {
+				upgradeDiskToMap()
+				tmpl.copyListEntryField(additionalDisks, dst, src, "fsArgs")
+				dest.FSArgs = disk.FSArgs
+			}
+			// TODO: Is there a good reason not to copy comments from wildcard entries?
+			if disk.Name != "*" {
+				tmpl.copyListEntryComments(additionalDisks, dst, src, "")
+			}
+		}
+		tmpl.Config.AdditionalDisks = slices.Delete(tmpl.Config.AdditionalDisks, src, src+1)
+		tmpl.deleteListEntry(additionalDisks, src)
+	}
+}
+
+// combineMounts combines mounts entries. The shared key is the mount point.
+func (tmpl *Template) combineMounts() {
+	const mounts = "mounts"
+
+	mountPointIdx := make(map[string]int, len(tmpl.Config.Mounts))
+	for src := 0; src < len(tmpl.Config.Mounts); {
+		mount := tmpl.Config.Mounts[src]
+		// mountPoint (an optional field) defaults to location (a required field)
+		mountPoint := mount.Location
+		if mount.MountPoint != nil {
+			mountPoint = *mount.MountPoint
+		}
+		var from, to int
+		if mountPoint == "*" {
+			from = 0
+			to = src - 1
+		} else {
+			if i, ok := mountPointIdx[mountPoint]; ok {
+				from = i
+				to = i
+			} else {
+				if mountPoint != "" {
+					mountPointIdx[mountPoint] = src
+				}
+				src++
+				continue
+			}
+		}
+		for dst := from; dst <= to; dst++ {
+			dest := &tmpl.Config.Mounts[dst]
+			// MountPoint
+			if dest.MountPoint == nil && mount.MountPoint != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "mountPoint")
+				dest.MountPoint = mount.MountPoint
+			}
+			// Writable
+			if dest.Writable == nil && mount.Writable != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "writable")
+				dest.Writable = mount.Writable
+			}
+			// SSHFS
+			if dest.SSHFS.Cache == nil && mount.SSHFS.Cache != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "sshfs.cache")
+				dest.SSHFS.Cache = mount.SSHFS.Cache
+			}
+			if dest.SSHFS.FollowSymlinks == nil && mount.SSHFS.FollowSymlinks != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "sshfs.followSymlinks")
+				dest.SSHFS.FollowSymlinks = mount.SSHFS.FollowSymlinks
+			}
+			if dest.SSHFS.SFTPDriver == nil && mount.SSHFS.SFTPDriver != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "sshfs.sftpDriver")
+				dest.SSHFS.SFTPDriver = mount.SSHFS.SFTPDriver
+			}
+			// NineP
+			if dest.NineP.SecurityModel == nil && mount.NineP.SecurityModel != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "9p.securityModel")
+				dest.NineP.SecurityModel = mount.NineP.SecurityModel
+			}
+			if dest.NineP.ProtocolVersion == nil && mount.NineP.ProtocolVersion != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "9p.protocolVersion")
+				dest.NineP.ProtocolVersion = mount.NineP.ProtocolVersion
+			}
+			if dest.NineP.Msize == nil && mount.NineP.Msize != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "9p.msize")
+				dest.NineP.Msize = mount.NineP.Msize
+			}
+			if dest.NineP.Cache == nil && mount.NineP.Cache != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "9p.cache")
+				dest.NineP.Cache = mount.NineP.Cache
+			}
+			// Virtiofs
+			if dest.Virtiofs.QueueSize == nil && mount.Virtiofs.QueueSize != nil {
+				tmpl.copyListEntryField(mounts, dst, src, "virtiofs.queueSize")
+				dest.Virtiofs.QueueSize = mount.Virtiofs.QueueSize
+			}
+			if mountPoint != "*" {
+				tmpl.copyListEntryComments(mounts, dst, src, "")
+				tmpl.copyListEntryComments(mounts, dst, src, "sshfs")
+				tmpl.copyListEntryComments(mounts, dst, src, "9p")
+				tmpl.copyListEntryComments(mounts, dst, src, "virtiofs")
+			}
+		}
+		tmpl.Config.Mounts = slices.Delete(tmpl.Config.Mounts, src, src+1)
+		tmpl.deleteListEntry(mounts, src)
+	}
+}
+
+// combineNetworks combines networks entries. The shared key is the interface name.
+func (tmpl *Template) combineNetworks() {
+	const networks = "networks"
+
+	interfaceIdx := make(map[string]int, len(tmpl.Config.Networks))
+	for src := 0; src < len(tmpl.Config.Networks); {
+		nw := tmpl.Config.Networks[src]
+		var from, to int
+		if nw.Interface == "*" {
+			from = 0
+			to = src - 1
+		} else {
+			if i, ok := interfaceIdx[nw.Interface]; ok {
+				from = i
+				to = i
+			} else {
+				if nw.Interface != "" {
+					interfaceIdx[nw.Interface] = src
+				}
+				src++
+				continue
+			}
+		}
+		for dst := from; dst <= to; dst++ {
+			dest := &tmpl.Config.Networks[dst]
+			// Lima and Socket are mutually exclusive. Only copy base values if both are still unset.
+			if dest.Lima == "" && dest.Socket == "" {
+				if nw.Lima != "" {
+					tmpl.copyListEntryField(networks, dst, src, "lima")
+					dest.Lima = nw.Lima
+				}
+				if nw.Socket != "" {
+					tmpl.copyListEntryField(networks, dst, src, "socket")
+					dest.Socket = nw.Socket
+				}
+			}
+			if dest.MACAddress == "" && nw.MACAddress != "" {
+				tmpl.copyListEntryField(networks, dst, src, "macAddress")
+				dest.MACAddress = nw.MACAddress
+			}
+			if dest.VZNAT == nil && nw.VZNAT != nil {
+				tmpl.copyListEntryField(networks, dst, src, "vzNAT")
+				dest.VZNAT = nw.VZNAT
+			}
+			if dest.Metric == nil && nw.Metric != nil {
+				tmpl.copyListEntryField(networks, dst, src, "metric")
+				dest.Metric = nw.Metric
+			}
+			if nw.Interface != "*" {
+				tmpl.copyListEntryComments(networks, dst, src, "")
+			}
+		}
+		tmpl.Config.Networks = slices.Delete(tmpl.Config.Networks, src, src+1)
+		tmpl.deleteListEntry(networks, src)
+	}
+}
+
+// updateScript replaces a "file" property with the actual script and then renames the field to "script".
+func (tmpl *Template) updateScript(field string, idx int, script string) {
+	entry := fmt.Sprintf("$a.%s[%d].file", field, idx)
+	// Assign script to the "file" field and then rename it to "script".
+	tmpl.expr.WriteString(fmt.Sprintf("| (%s) = %q | (%s | key) = \"script\"\n", entry, script, entry))
+}
+
+// embedAllScripts replaces all "provision" and "probes" file references with the actual script.
+func (tmpl *Template) embedAllScripts(ctx context.Context, embedAll bool) error {
+	if err := tmpl.Unmarshal(); err != nil {
+		return err
+	}
+	for i, p := range tmpl.Config.Probes {
+		// Don't overwrite existing script. This should throw an error during validation.
+		if p.File != nil && p.Script == "" {
+			warnFileIsExperimental()
+			isTemplate, _ := SeemsTemplateURL(p.File.URL)
+			if embedAll || !isTemplate {
+				scriptTmpl, err := Read(ctx, "", p.File.URL)
+				if err != nil {
+					return err
+				}
+				tmpl.updateScript("probes", i, string(scriptTmpl.Bytes))
+			}
+		}
+	}
+	for i, p := range tmpl.Config.Provision {
+		if p.File != nil && p.Script == "" {
+			warnFileIsExperimental()
+			isTemplate, _ := SeemsTemplateURL(p.File.URL)
+			if embedAll || !isTemplate {
+				scriptTmpl, err := Read(ctx, "", p.File.URL)
+				if err != nil {
+					return err
+				}
+				tmpl.updateScript("provision", i, string(scriptTmpl.Bytes))
+			}
+		}
+	}
+	return tmpl.evalExpr()
+}
diff --git a/pkg/limatmpl/locator.go b/pkg/limatmpl/locator.go
index 1a6abc03..343ee5d7 100644
--- a/pkg/limatmpl/locator.go
+++ b/pkg/limatmpl/locator.go
@@ -13,6 +13,7 @@ import (
 	"path"
 	"path/filepath"
 	"strings"
+	"unicode"
 
 	"github.com/containerd/containerd/identifiers"
 	"github.com/lima-vm/lima/pkg/ioutilx"
@@ -20,17 +21,10 @@ import (
 	"github.com/sirupsen/logrus"
 )
 
-type Template struct {
-	Name    string
-	Locator string
-	Bytes   []byte
-}
-
 const yBytesLimit = 4 * 1024 * 1024 // 4MiB
 
 func Read(ctx context.Context, name, locator string) (*Template, error) {
 	var err error
-
 	tmpl := &Template{
 		Name:    name,
 		Locator: locator,
@@ -43,8 +37,11 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 		templateName := filepath.Join(templateURL.Host, templateURL.Path)
 		logrus.Debugf("interpreting argument %q as a template name %q", locator, templateName)
 		if tmpl.Name == "" {
-			// e.g., templateName = "deprecated/centos-7" , tmpl.Name = "centos-7"
-			tmpl.Name = filepath.Base(templateName)
+			// e.g., templateName = "deprecated/centos-7.yaml" , tmpl.Name = "centos-7"
+			tmpl.Name, err = InstNameFromYAMLPath(templateName)
+			if err != nil {
+				return nil, err
+			}
 		}
 		tmpl.Bytes, err = templatestore.Read(templateName)
 		if err != nil {
@@ -78,8 +75,12 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 				return nil, err
 			}
 		}
-		logrus.Debugf("interpreting argument %q as a file url for instance %q", locator, tmpl.Name)
-		r, err := os.Open(strings.TrimPrefix(locator, "file://"))
+		logrus.Debugf("interpreting argument %q as a file URL for instance %q", locator, tmpl.Name)
+		filePath := strings.TrimPrefix(locator, "file://")
+		if !filepath.IsAbs(filePath) {
+			return nil, fmt.Errorf("file URL %q is not an absolute path", locator)
+		}
+		r, err := os.Open(filePath)
 		if err != nil {
 			return nil, err
 		}
@@ -88,7 +89,7 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 		if err != nil {
 			return nil, err
 		}
-	case SeemsYAMLPath(locator):
+	case SeemsFilePath(locator):
 		if tmpl.Name == "" {
 			tmpl.Name, err = InstNameFromYAMLPath(locator)
 			if err != nil {
@@ -96,6 +97,10 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 			}
 		}
 		logrus.Debugf("interpreting argument %q as a file path for instance %q", locator, tmpl.Name)
+		if locator, err = filepath.Abs(locator); err != nil {
+			return nil, err
+		}
+		tmpl.Locator = locator
 		r, err := os.Open(locator)
 		if err != nil {
 			return nil, err
@@ -111,6 +116,8 @@ func Read(ctx context.Context, name, locator string) (*Template, error) {
 			return nil, fmt.Errorf("unexpected error reading stdin: %w", err)
 		}
 	}
+	// The only reason not to call tmpl.UseAbsLocators() here is that `limactl tmpl copy --verbatim …`
+	// should create an unmodified copy of the template.
 	return tmpl, nil
 }
 
@@ -149,6 +156,20 @@ func SeemsYAMLPath(arg string) bool {
 	return strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml")
 }
 
+// SeemsFilePath returns true if arg either contains a path separator or has a file extension that
+// does not start with a digit. `my.yaml` is a file path, `ubuntu-20.10` is not.
+func SeemsFilePath(arg string) bool {
+	// Single-letter schemes will be drive names on Windows, e.g. "c:/foo.yaml"
+	if u, err := url.Parse(arg); err == nil && len(u.Scheme) > 1 {
+		return false
+	}
+	if strings.ContainsRune(arg, '/') || strings.ContainsRune(arg, filepath.Separator) {
+		return true
+	}
+	ext := filepath.Ext(arg)
+	return len(ext) > 1 && !unicode.IsDigit(rune(ext[1]))
+}
+
 func InstNameFromURL(urlStr string) (string, error) {
 	u, err := url.Parse(urlStr)
 	if err != nil {
diff --git a/pkg/limatmpl/template.go b/pkg/limatmpl/template.go
new file mode 100644
index 00000000..b6398a50
--- /dev/null
+++ b/pkg/limatmpl/template.go
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl
+
+import (
+	"strings"
+
+	"github.com/lima-vm/lima/pkg/limayaml"
+)
+
+type Template struct {
+	Locator string // template locator (absolute path or URL)
+	Bytes   []byte // file contents
+
+	// The following fields are only used when the template represents a YAML config file.
+	Name   string // instance name, may be inferred from locator
+	Config *limayaml.LimaYAML
+
+	expr strings.Builder // yq expression to update template
+}
+
+func (tmpl *Template) ClearOnError(err error) error {
+	if err != nil {
+		tmpl.Bytes = nil
+		tmpl.Config = nil
+		tmpl.expr.Reset()
+	}
+	return err
+}
+
+// Unmarshal makes sure the tmpl.Config field is set. Any operation that modified
+// tmpl.Bytes is expected to set tmpl.Config back to nil.
+func (tmpl *Template) Unmarshal() error {
+	if tmpl.Config == nil {
+		tmpl.Config = &limayaml.LimaYAML{}
+		if err := limayaml.Unmarshal(tmpl.Bytes, tmpl.Config, tmpl.Locator); err != nil {
+			tmpl.Config = nil
+			return err
+		}
+	}
+	return nil
+}
diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go
index f9990f45..9386a987 100644
--- a/pkg/limayaml/limayaml.go
+++ b/pkg/limayaml/limayaml.go
@@ -10,6 +10,7 @@ import (
 )
 
 type LimaYAML struct {
+	Base                  BaseTemplates `yaml:"base,omitempty" json:"base,omitempty" jsonschema:"nullable"`
 	MinimumLimaVersion    *string       `yaml:"minimumLimaVersion,omitempty" json:"minimumLimaVersion,omitempty" jsonschema:"nullable"`
 	VMType                *VMType       `yaml:"vmType,omitempty" json:"vmType,omitempty" jsonschema:"nullable"`
 	VMOpts                VMOpts        `yaml:"vmOpts,omitempty" json:"vmOpts,omitempty"`
@@ -53,6 +54,13 @@ type LimaYAML struct {
 	User                 User           `yaml:"user,omitempty" json:"user,omitempty"`
 }
 
+type BaseTemplates []LocatorWithDigest
+
+type LocatorWithDigest struct {
+	URL    string  `yaml:"url" json:"url"`
+	Digest *string `yaml:"digest,omitempty" json:"digest,omitempty" jsonschema:"nullable"` // TODO currently unused
+}
+
 type (
 	OS        = string
 	Arch      = string
@@ -216,10 +224,11 @@ 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" json:"script"`
-	Playbook                        string        `yaml:"playbook,omitempty" json:"playbook,omitempty"`
+	Mode                            ProvisionMode      `yaml:"mode,omitempty" json:"mode,omitempty" jsonschema:"default=system"`
+	SkipDefaultDependencyResolution *bool              `yaml:"skipDefaultDependencyResolution,omitempty" json:"skipDefaultDependencyResolution,omitempty"`
+	Script                          string             `yaml:"script" json:"script"`
+	File                            *LocatorWithDigest `yaml:"file,omitempty" json:"file,omitempty" jsonschema:"nullable"`
+	Playbook                        string             `yaml:"playbook,omitempty" json:"playbook,omitempty"`
 }
 
 type Containerd struct {
@@ -235,10 +244,11 @@ const (
 )
 
 type Probe struct {
-	Mode        ProbeMode `yaml:"mode,omitempty" json:"mode,omitempty" jsonschema:"default=readiness"`
-	Description string    `yaml:"description,omitempty" json:"description,omitempty"`
-	Script      string    `yaml:"script,omitempty" json:"script,omitempty"`
-	Hint        string    `yaml:"hint,omitempty" json:"hint,omitempty"`
+	Mode        ProbeMode          `yaml:"mode,omitempty" json:"mode,omitempty" jsonschema:"default=readiness"`
+	Description string             `yaml:"description,omitempty" json:"description,omitempty"`
+	Script      string             `yaml:"script,omitempty" json:"script,omitempty"`
+	File        *LocatorWithDigest `yaml:"file,omitempty" json:"file,omitempty" jsonschema:"nullable"`
+	Hint        string             `yaml:"hint,omitempty" json:"hint,omitempty"`
 }
 
 type Proto = string
diff --git a/pkg/limayaml/marshal.go b/pkg/limayaml/marshal.go
index 051769c9..0380fa9b 100644
--- a/pkg/limayaml/marshal.go
+++ b/pkg/limayaml/marshal.go
@@ -38,17 +38,49 @@ func unmarshalDisk(dst *Disk, b []byte) error {
 	return yaml.Unmarshal(b, dst)
 }
 
+// unmarshalBaseTemplates unmarshalls `base` which is either a string or a list of Locators.
+func unmarshalBaseTemplates(dst *BaseTemplates, b []byte) error {
+	var s string
+	if err := yaml.Unmarshal(b, &s); err == nil {
+		*dst = BaseTemplates{LocatorWithDigest{URL: s}}
+		return nil
+	}
+	var locator LocatorWithDigest
+	if err := yaml.Unmarshal(b, &locator); err == nil {
+		*dst = BaseTemplates{locator}
+		return nil
+	}
+	return yaml.UnmarshalWithOptions(b, dst, yaml.CustomUnmarshaler[LocatorWithDigest](unmarshalLocatorWithDigest))
+}
+
+// unmarshalLocator unmarshalls a locator which is either a string or a Locator struct.
+func unmarshalLocatorWithDigest(dst *LocatorWithDigest, b []byte) error {
+	var s string
+	if err := yaml.Unmarshal(b, &s); err == nil {
+		*dst = LocatorWithDigest{URL: s}
+		return nil
+	}
+	return yaml.Unmarshal(b, dst)
+}
+
 func Unmarshal(data []byte, y *LimaYAML, comment string) error {
-	if err := yaml.UnmarshalWithOptions(data, y, yaml.CustomUnmarshaler[Disk](unmarshalDisk)); err != nil {
+	opts := []yaml.DecodeOption{
+		yaml.CustomUnmarshaler[BaseTemplates](unmarshalBaseTemplates),
+		yaml.CustomUnmarshaler[Disk](unmarshalDisk),
+		yaml.CustomUnmarshaler[LocatorWithDigest](unmarshalLocatorWithDigest),
+	}
+	if err := yaml.UnmarshalWithOptions(data, y, opts...); err != nil {
 		return fmt.Errorf("failed to unmarshal YAML (%s): %w", comment, err)
 	}
-	// the go-yaml library doesn't catch all markup errors, unfortunately
+	// The go-yaml library doesn't catch all markup errors, unfortunately
 	// make sure to get a "second opinion", using the same library as "yq"
 	if err := yqutil.ValidateContent(data); err != nil {
 		return fmt.Errorf("failed to unmarshal YAML (%s): %w", comment, err)
 	}
+	// Finally log a warning if the YAML file violates the "strict" rules
+	opts = append(opts, yaml.Strict())
 	var ignore LimaYAML
-	if err := yaml.UnmarshalWithOptions(data, &ignore, yaml.Strict(), yaml.CustomUnmarshaler[Disk](unmarshalDisk)); err != nil {
+	if err := yaml.UnmarshalWithOptions(data, &ignore, opts...); err != nil {
 		logrus.WithField("comment", comment).WithError(err).Warn("Non-strict YAML detected; please check for typos")
 	}
 	return nil
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index 111719bd..4bf3c837 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -50,6 +50,9 @@ func validateFileObject(f File, fieldName string) error {
 }
 
 func Validate(y *LimaYAML, warn bool) error {
+	if len(y.Base) > 0 {
+		return errors.New("field `base` must be empty for YAML validation")
+	}
 	if y.MinimumLimaVersion != nil {
 		if _, err := versionutil.Parse(*y.MinimumLimaVersion); err != nil {
 			return fmt.Errorf("field `minimumLimaVersion` must be a semvar value, got %q: %w", *y.MinimumLimaVersion, err)
@@ -206,6 +209,14 @@ func Validate(y *LimaYAML, warn bool) error {
 	// y.Firmware.LegacyBIOS is ignored for aarch64, but not a fatal error.
 
 	for i, p := range y.Provision {
+		if p.File != nil {
+			if p.File.URL != "" {
+				return fmt.Errorf("field `provision[%d].file.url` must be empty during validation (script should already be embedded)", i)
+			}
+			if p.File.Digest != nil {
+				return fmt.Errorf("field `provision[%d].file.digest` support is not yet implemented", i)
+			}
+		}
 		switch p.Mode {
 		case ProvisionModeSystem, ProvisionModeUser, ProvisionModeBoot:
 			if p.SkipDefaultDependencyResolution != nil {
@@ -246,6 +257,14 @@ func Validate(y *LimaYAML, warn bool) error {
 		}
 	}
 	for i, p := range y.Probes {
+		if p.File != nil {
+			if p.File.URL != "" {
+				return fmt.Errorf("field `probe[%d].file.url` must be empty during validation (script should already be embedded)", i)
+			}
+			if p.File.Digest != nil {
+				return fmt.Errorf("field `probe[%d].file.digest` support is not yet implemented", i)
+			}
+		}
 		if !strings.HasPrefix(p.Script, "#!") {
 			return fmt.Errorf("field `probe[%d].script` must start with a '#!' line", i)
 		}
diff --git a/pkg/store/filenames/filenames.go b/pkg/store/filenames/filenames.go
index 659a6af7..09f6c93b 100644
--- a/pkg/store/filenames/filenames.go
+++ b/pkg/store/filenames/filenames.go
@@ -24,6 +24,7 @@ const (
 	NetworksConfig = "networks.yaml"
 	Default        = "default.yaml"
 	Override       = "override.yaml"
+	Base           = "base.yaml"
 )
 
 // Filenames that may appear under an instance directory
diff --git a/templates/default.yaml b/templates/default.yaml
index 471c9661..2275e4ec 100644
--- a/templates/default.yaml
+++ b/templates/default.yaml
@@ -211,6 +211,12 @@ containerd:
 # Provisioning scripts need to be idempotent because they might be called
 # multiple times, e.g. when the host VM is being restarted.
 # The scripts can use the following template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}}.
+#
+# EXPERIMENTAL Alternatively the script can be provided using the "file" property. This file is read when the instance
+# is created and then stored under the "script" property. When "file" is specified "script" must be empty.
+# The "file" property can either be a string (URL), or an object with a "url" and "digest" properties.
+# The "digest" property is currently unused.
+# Relative script files will be resolved relative to the location of the template file.
 # 🟢 Builtin default: []
 # provision:
 # # `system` is executed with root privileges
@@ -222,12 +228,9 @@ containerd:
 #     apt-get install -y vim
 # # `user` is executed without root privileges
 # - mode: user
-#   script: |
-#     #!/bin/bash
-#     set -eux -o pipefail
-#     cat <<EOF > ~/.vimrc
-#     set number
-#     EOF
+#   file:
+#     url: user-provisioning.sh
+#     digest: deadbeef
 # # `boot` is executed directly by /bin/sh as part of cloud-init-local.service's early boot process,
 # # which is why there is no hash-bang specified in the example
 # # See cloud-init docs for more info https://docs.cloud-init.io/en/latest/reference/examples.html#run-commands-on-first-boot
@@ -254,6 +257,11 @@ containerd:
 # Probe scripts to check readiness.
 # The scripts run in user mode. They must start with a '#!' line.
 # The scripts can use the following template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}}.
+# EXPERIMENTAL Alternatively the script can be provided using the "file" property. This file is read when the instance
+# is created and then stored under the "script" property. When "file" is specified "script" must be empty.
+# The "file" property can either be a string (URL), or an object with a "url" and "digest" properties.
+# The "digest" property is currently unused.
+# Relative script files will be resolved relative to the location of the template file.
 # 🟢 Builtin default: []
 # probes:
 # # Only `readiness` probes are supported right now.
@@ -279,6 +287,17 @@ containerd:
 # 🟢 Builtin default: not set
 minimumLimaVersion: null
 
+# EXPERIMENTAL
+# Default settings can be imported from base templates. These will be merged in when the instance
+# is created, and the combined template is stored in the instance directory.
+# This setting ca be either a single string (URL), or a list of locators.
+# A locator is again either a string (URL), or an object with "url" and "digest" properties, e.g.
+# base: [{url: ./base.yaml, digest: decafbad}, …]
+# The "digest" property is currently unused.
+# Any relative base template name will be resolved relative to the location of the main template.
+# 🟢 Builtin default: no base template
+base: null
+
 # User to be used inside the VM
 user:
   # User name. An explicitly specified username is not validated by Lima.
@@ -561,6 +580,12 @@ nestedVirtualization: null
 # on each restart. It can be used to globally override settings, e.g. make
 # the mount of the home directory writable.
 
+# EXPERIMENTAL
+# A third mechanism is $LIMA_HOME/_config/base.yaml. It is similar to
+# `default.yaml` but will be merged during the `base` template embedding
+# when the instance is created, and not on every start. It becomes part
+# of the lima.yaml file.
+
 # On each instance start the config settings are determined: If a value is
 # not set in `lima.yaml`, then the `default.yaml` is used. If that file
 # doesn't exist, or the value is not defined in the file, then the builtin
diff --git a/website/content/en/docs/releases/experimental.md b/website/content/en/docs/releases/experimental.md
index 2310e41c..d443fce8 100644
--- a/website/content/en/docs/releases/experimental.md
+++ b/website/content/en/docs/releases/experimental.md
@@ -13,17 +13,22 @@ The following features are experimental and subject to change:
 - `audio.device`
 - `arch: armv7l`
 - `mountInotify: true`
+- `base:` and variations like `base[]:` and `base[].url`
+- `provision[].file:` and alternative `provision[].file.url:`  
+- `probes[].file:` and alternative `probes[].file.url:`
 
 The following commands are experimental and subject to change:
 
 - `limactl snapshot *`
 - `limactl tunnel`
+- `limactl template *`
 
 ## Graduated
 
 The following features were experimental in the past:
 
 ### Until v1.0
+
 - `mountType: 9p`
 - `vmType: vz` and relevant configurations (`mountType: virtiofs`, `rosetta`, `[]networks.vzNAT`)
 - `mode: user-v2` in `networks.yml` and relevant configuration in `lima.yaml`


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b8bc41ab..38bd49e6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -253,7 +253,8 @@ jobs:
     steps:
     - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
       with:
-        fetch-depth: 1
+        # To avoid "failed to load YAML file \"templates/experimental/riscv64.yaml\": can't parse builtin Lima version \"3f3a6f6\": 3f3a6f6 is not in dotted-tri format"
+        fetch-depth: 0
     - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34  # v5.3.0
       with:
         go-version: 1.24.x
diff --git a/hack/test-port-forwarding.pl b/hack/test-port-forwarding.pl
index cc1fc96c..b812060c 100755
--- a/hack/test-port-forwarding.pl
+++ b/hack/test-port-forwarding.pl
@@ -212,11 +212,11 @@ sub JoinHostPort {
 # "ipv4" and "ipv6" will be replaced by the actual host ipv4 and ipv6 addresses.
 __DATA__
 portForwards:
-  # We can't test that port 22 will be blocked because the guestagent has
-  # been ignoring it since startup, so the log message is in the part of
-  # the log we skipped.
-  # skip: 127.0.0.1 22 → 127.0.0.1 2222
-  # ignore: 127.0.0.1 sshLocalPort
+# We can't test that port 22 will be blocked because the guestagent has
+# been ignoring it since startup, so the log message is in the part of
+# the log we skipped.
+# skip: 127.0.0.1 22 → 127.0.0.1 2222
+# ignore: 127.0.0.1 sshLocalPort
 
 - guestIP: 127.0.0.2
   guestPortRange: [3000, 3009]
diff --git a/hack/test-templates.sh b/hack/test-templates.sh
index f0ec7263..7fba8f9e 100755
--- a/hack/test-templates.sh
+++ b/hack/test-templates.sh
@@ -17,7 +17,8 @@ if [ "$#" -ne 1 ]; then
 	exit 1
 fi
 
-FILE="$1"
+# Resolve any ../ fragments in the filename because they are invalid in relative template locators
+FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
 NAME="$(basename -s .yaml "$FILE")"
 OS_HOST="$(uname -o)"
 
@@ -135,6 +136,9 @@ if [[ -n ${CHECKS["port-forwards"]} ]]; then
 	limactl validate "$FILE_HOST"
 fi
 
+INFO "Make sure template embedding copies \"$FILE_HOST\" exactly"
+diff -u <(echo -n "base: $FILE_HOST" | limactl tmpl copy --embed - -) "$FILE_HOST"
+
 function diagnose() {
 	NAME="$1"
 	set -x +e
@@ -207,7 +211,7 @@ fi
 
 if [[ -n ${CHECKS["set-user"]} ]]; then
 	INFO 'Testing that user settings can be provided by lima.yaml'
-	limactl shell "$NAME" grep "^john:x:4711:4711:John Doe:/home/john-john" /etc/passwd
+	limactl shell "$NAME" grep "^john:x:4711:4711:John Doe:/home/john-john:/usr/bin/bash" /etc/passwd
 fi
 
 if [[ -n ${CHECKS["proxy-settings"]} ]]; then
diff --git a/hack/test-templates/test-misc.yaml b/hack/test-templates/test-misc.yaml
index 2137c5f3..fe1af508 100644
--- a/hack/test-templates/test-misc.yaml
+++ b/hack/test-templates/test-misc.yaml
@@ -3,21 +3,7 @@
 # - snapshots
 # - (More to come)
 #
-# This template requires Lima v1.0.0-alpha.0 or later.
-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/22.04/release-20220902/ubuntu-22.04-server-cloudimg-amd64.img"
-  arch: "x86_64"
-  digest: "sha256:c777670007cc5f132417b9e0bc01367ccfc2a989951ffa225bb1952917c3aa81"
-- location: "https://cloud-images.ubuntu.com/releases/22.04/release-20220902/ubuntu-22.04-server-cloudimg-arm64.img"
-  arch: "aarch64"
-  digest: "sha256:9620f479bd5a6cbf1e805654d41b27f4fc56ef20f916c8331558241734de81ae"
-# Fallback to the latest release image.
-# Hint: run `limactl prune` to invalidate the cache
-- 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"
+base: template://ubuntu-22.04
 
 # 9p is not compatible with `limactl snapshot`
 mountTypesUnsupported: ["9p"]
@@ -26,8 +12,6 @@ mounts:
   writable: true
 - location: "/tmp/lima test dir with spaces"
   writable: true
-- location: "/tmp/lima"
-  writable: true
 
 param:
   BOOT: boot
@@ -64,3 +48,5 @@ user:
   comment: John Doe
   home: "/home/{{.User}}-{{.User}}"
   uid: 4711
+  # Ubuntu has identical /bin/bash and /usr/bin/bash
+  shell: /usr/bin/bash
diff --git a/pkg/limatmpl/abs_test.go b/pkg/limatmpl/abs_test.go
new file mode 100644
index 00000000..b4f192f6
--- /dev/null
+++ b/pkg/limatmpl/abs_test.go
@@ -0,0 +1,299 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl
+
+import (
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+type useAbsLocatorsTestCase struct {
+	description string
+	locator     string
+	template    string
+	expected    string
+}
+
+var useAbsLocatorsTestCases = []useAbsLocatorsTestCase{
+	{
+		"Template without base or script file",
+		"template://foo",
+		`arch: aarch64`,
+		`arch: aarch64`,
+	},
+	{
+		"Single string base template",
+		"template://foo",
+		`base: bar.yaml`,
+		`base: template://bar.yaml`,
+	},
+	{
+		"Flow style array of one base template",
+		"template://foo",
+		`base: {url: bar.yaml, digest: deadbeef}`,
+		// not sure why the quotes around the URL were added; maybe because we don't copy the style from the source
+		`base: {url: 'template://bar.yaml', digest: deadbeef}`,
+	},
+	{
+		"Flow style array of sequence of two base URLs",
+		"template://foo",
+		`base: [bar.yaml, baz.yaml]`,
+		`base: ['template://bar.yaml', 'template://baz.yaml']`,
+	},
+	{
+		"Flow style array of sequence of two base locator objects",
+		"template://foo",
+		`base: [{url: bar.yaml, digest: deadbeef}, {url: baz.yaml, digest: decafbad}]`,
+		`base: [{url: 'template://bar.yaml', digest: deadbeef}, {url: 'template://baz.yaml', digest: decafbad}]`,
+	},
+	{
+		"Block style array of one base template",
+		"template://foo",
+		`
+base:
+- bar.yaml
+`,
+		`
+base:
+- template://bar.yaml`,
+	},
+	{
+		"Block style of four base templates",
+		"template://foo",
+		`
+base:
+- bar.yaml
+- template://my
+- https://example.com/my.yaml
+- baz.yaml
+`,
+		`
+base:
+- template://bar.yaml
+- template://my
+- https://example.com/my.yaml
+- template://baz.yaml
+`,
+	},
+	{
+		"Provisioning and probe scripts",
+		"template://experimental/foo",
+		`
+provision:
+- mode: user
+  file: userscript.sh
+- mode: system
+  file:
+    url: systemscript.sh
+    digest: abc123
+probes:
+- file: probe.sh
+- file:
+    url: probe.sh
+    digest: digest
+`,
+		`
+provision:
+- mode: user
+  file: template://experimental/userscript.sh
+- mode: system
+  file:
+    url: template://experimental/systemscript.sh
+    digest: abc123
+probes:
+- file: template://experimental/probe.sh
+- file:
+    url: template://experimental/probe.sh
+    digest: digest
+`,
+	},
+}
+
+func TestUseAbsLocators(t *testing.T) {
+	for _, tc := range useAbsLocatorsTestCases {
+		t.Run(tc.description, func(t *testing.T) { RunUseAbsLocatorTest(t, tc) })
+	}
+}
+
+func RunUseAbsLocatorTest(t *testing.T, tc useAbsLocatorsTestCase) {
+	tmpl := &Template{
+		Bytes:   []byte(strings.TrimSpace(tc.template)),
+		Locator: tc.locator,
+	}
+	err := tmpl.UseAbsLocators()
+	assert.NilError(t, err, tc.description)
+
+	actual := strings.TrimSpace(string(tmpl.Bytes))
+	expected := strings.TrimSpace(tc.expected)
+	assert.Equal(t, actual, expected, tc.description)
+}
+
+func TestBasePath(t *testing.T) {
+	// On Windows the root will be something like "C:\"
+	root, err := filepath.Abs("/")
+	assert.NilError(t, err)
+	volume := filepath.VolumeName(root)
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("/foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, root)
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("/foo/bar")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, filepath.Clean(volume+"/foo"))
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("template://foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "template://")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("template://foo/bar")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "template://foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("http://host/foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "http://host")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("http://host/foo/bar")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "http://host/foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("file:///foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "file:///")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := basePath("file:///foo/bar")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "file:///foo")
+	})
+}
+
+func TestAbsPath(t *testing.T) {
+	root, err := filepath.Abs("/")
+	assert.NilError(t, err)
+	volume := filepath.VolumeName(root)
+
+	t.Run("If the locator is already an absolute path, it is returned unchanged", func(t *testing.T) {
+		actual, err := absPath(volume+"/foo", volume+"/root")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, filepath.Clean(volume+"/foo"))
+	})
+
+	t.Run("If the locator is a rooted path without volume name, then the volume will be added", func(t *testing.T) {
+		actual, err := absPath("/foo", volume+"/root")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, filepath.Clean(volume+"/foo"))
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("template://foo", volume+"/root")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "template://foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("http://host/foo", volume+"/root")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "http://host/foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("file:///foo", volume+"/root")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "file:///foo")
+	})
+
+	t.Run("Can't have relative path when reading from STDIN", func(t *testing.T) {
+		_, err = absPath("foo", "-")
+		assert.ErrorContains(t, err, "STDIN")
+	})
+
+	t.Run("Relative paths must be underneath the basePath", func(t *testing.T) {
+		_, err = absPath("../foo", volume+"/root")
+		assert.ErrorContains(t, err, "'../'")
+	})
+
+	t.Run("locator must not be empty", func(t *testing.T) {
+		_, err = absPath("", "foo")
+		assert.ErrorContains(t, err, "locator is empty")
+	})
+
+	t.Run("basePath must not be empty", func(t *testing.T) {
+		_, err = absPath("foo", "")
+		assert.ErrorContains(t, err, "basePath is empty")
+	})
+
+	t.Run("", func(t *testing.T) {
+		_, err = absPath("./foo", "")
+		assert.ErrorContains(t, err, "empty")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("./foo", volume+"/root")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, filepath.Clean(volume+"/root/foo"))
+	})
+
+	if runtime.GOOS == "windows" {
+		t.Run("Relative locators must not include volume names", func(t *testing.T) {
+			_, err := absPath(volume+"foo", volume+"/root")
+			assert.ErrorContains(t, err, "volume")
+		})
+	}
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("foo", "template://")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "template://foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("bar", "template://foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "template://foo/bar")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("foo", "http://host")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "http://host/foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("bar", "http://host/foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "http://host/foo/bar")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("foo", "file:///")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "file:///foo")
+	})
+
+	t.Run("", func(t *testing.T) {
+		actual, err := absPath("bar", "file:///foo")
+		assert.NilError(t, err)
+		assert.Equal(t, actual, "file:///foo/bar")
+	})
+}
diff --git a/pkg/limatmpl/embed_test.go b/pkg/limatmpl/embed_test.go
new file mode 100644
index 00000000..f9057872
--- /dev/null
+++ b/pkg/limatmpl/embed_test.go
@@ -0,0 +1,459 @@
+// SPDX-FileCopyrightText: Copyright The Lima Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package limatmpl_test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/lima-vm/lima/pkg/limatmpl"
+	"github.com/lima-vm/lima/pkg/limayaml"
+	"github.com/sirupsen/logrus"
+	"gotest.tools/v3/assert"
+	"gotest.tools/v3/assert/cmp"
+)
+
+type embedTestCase struct {
+	description string
+	template    string
+	base        string
+	expected    string
+}
+
+// Notes:
+// * When the template starts with "#" then the comparison will be textual instead of structural.
+//   This is required to verify comment handling.
+// * If the description starts with "TODO" then the test is expected to fail (until it is fixed).
+// * If the description starts with "ERROR" then the test is expected to fail with an error containing the expected string.
+// * base is split on "---\n" and stored as base0.yaml, base1.yaml, ... in the same dir as the template.
+// * If any base template starts with "#!" then the extension will be .sh instead of .yaml.
+// * The template is automatically prefixed with "base: base0.yaml" unless base0 is a script.
+// * All line comments will be separated by 2 spaces from the value on output.
+// * Merge order of additionalDisks, mounts, and networks depends on the logic in the
+//   combineListEntries() functions and will not follow the order of the base template(s).
+
+var embedTestCases = []embedTestCase{
+	{
+		"Empty template",
+		"",
+		"vmType: qemu",
+		"vmType: qemu",
+	},
+	{
+		"Base doesn't override existing values",
+		"vmType: vz",
+		"{arch: aarch64, vmType: qemu}",
+		"{arch: aarch64, vmType: vz}",
+	},
+	{
+		"Comments are copied over as well",
+		`#
+# VM Type is QEMU
+vmType: qemu # QEMU
+`,
+		`
+# Arch is x86_64
+arch: x86_64 # X86
+`,
+		`
+# VM Type is QEMU
+vmType: qemu  # QEMU
+# Arch is x86_64
+arch: x86_64  # X86
+`,
+	},
+	{
+		"mountTypesUnsupported are concatenated and duplicates removed",
+		"mountTypesUnsupported: [9p,reverse-sshfs]",
+		"mountTypesUnsupported: [9p,virtiofs]",
+		"mountTypesUnsupported: [9p,reverse-sshfs,virtiofs]",
+	},
+	{
+		"minimumLimaVersion (including comments) is updated when the base version is higher",
+		`#
+# Works with Lima 0.8.0 and later
+minimumLimaVersion: 0.8.0 # needs 0.8.0
+`,
+		`
+# Requires at least 1.0.2
+minimumLimaVersion: 1.0.2    # or later
+`,
+		`
+# Requires at least 1.0.2
+minimumLimaVersion: 1.0.2  # or later
+`,
+	},
+	{
+		"vmOpts.qmu.minimumVersion is updated when the base version is higher",
+		"vmOpts: {qemu: {minimumVersion: 8.2.1}}",
+		"vmOpts: {qemu: {minimumVersion: 9.1.0}}",
+		"vmOpts: {qemu: {minimumVersion: 9.1.0}}",
+	},
+	{
+		"dns list is not appended, but the highest priority one is picked",
+		"dns: [1.1.1.1]",
+		"dns: [8.8.8.8, 1.2.3.4]",
+		"dns: [1.1.1.1]",
+	},
+	{
+		"Update comments on existing maps and lists that don't have comments yet",
+		`#
+additionalDisks:
+- name: disk1 # One
+`,
+		`
+# Mount additional disks
+additionalDisks: # comment
+# This is disk2
+- name: disk2 # Two
+`,
+		`
+# Mount additional disks
+additionalDisks:  # comment
+- name: disk1  # One
+# This is disk2
+- name: disk2  # Two
+`,
+	},
+	{
+		"probes and provision scripts are prepended instead of appended",
+		"probes: [{script: 1}]\nprovision: [{script: One}]",
+		"probes: [{script: 2}]\nprovision: [{script: Two}]",
+		"probes: [{script: 2},{script: 1}]\nprovision: [{script: Two},{script: One}]",
+	},
+	{
+		"additionalDisks append, but merge fields on shared name",
+		"additionalDisks: [{name: disk1}]",
+		"additionalDisks: [{name: disk2},{name: disk1, format: true}]",
+		"additionalDisks: [{name: disk1, format: true},{name: disk2}]",
+	},
+	{
+		// This test fails because there are 2 spurious newlines in the merged output
+		"TODO mounts append, but merge fields on shared mountPoint",
+		`#
+# My mounts
+mounts:
+- location: loc1  # mountPoint loc1
+- location: loc1
+  mountPoint: loc2
+`,
+		`
+mounts:
+# will update mountPoint loc2
+- location: loc1
+  mountPoint: loc2
+  writable: true
+  # SSHFS
+  sshfs:  # ssh
+    followSymlinks: true
+# will create new mountPoint loc3
+- location: loc1
+  mountPoint: loc3
+  writable: true
+`,
+		`
+# My mounts
+mounts:
+- location: loc1  # mountPoint loc1
+# will update mountPoint loc2
+- location: loc1
+  mountPoint: loc2
+  writable: true
+  # SSHFS
+  sshfs:  # ssh
+    followSymlinks: true
+# will create new mountPoint loc3
+- location: loc1
+  mountPoint: loc3
+  writable: true
+`,
+	},
+	{
+		// This entry can be deleted when the previous one no longer fails
+		"mounts append, but merge fields on shared mountPoint (no comments version)",
+		`mounts: [{location: loc1}, {location: loc1, mountPoint: loc2}]`,
+		`mounts: [{location: loc1, mountPoint: loc2, writable: true, sshfs: {followSymlinks: true}}, {location: loc1, mountPoint: loc3, writable: true}]`,
+		`mounts: [{location: loc1}, {location: loc1, mountPoint: loc2, writable: true, sshfs: {followSymlinks: true}}, {location: loc1, mountPoint: loc3, writable: true}]`,
+	},
+	{
+		"template:// URLs are not embedded when embedAll is false",
+		// also tests file.url format
+		``,
+		`
+base: template://default
+provision:
+- file:
+    url: template://provision.sh
+probes:
+- file:
+    url: template://probe.sh
+`,
+		`
+base: template://default
+provision:
+- file: template://provision.sh
+probes:
+- file: template://probe.sh
+`,
+	},
+	{
+		"ERROR Each template must only be embedded once",
+		`#
+arch: aarch64
+`,
+		`
+base: base0.yaml
+# failure would mean this test loops forever, not that it fails the test
+vmType: qemu
+`,
+		`base template loop detected`,
+	},
+	{
+		"ERROR All bases following template:// bases must be template:// URLs too when embedAll is false",
+		``,
+		`base: [template://default, base1.yaml]`,
+		"after not embedding",
+	},
+	{
+		"ERROR All bases following template:// bases must be template:// URLs too when embedAll is false",
+		``,
+		`
+base: [base1.yaml, base2.yaml]
+---
+base: template://default
+---
+base: baseX.yaml`,
+		"after not embedding",
+	},
+	{
+		"Bases are embedded depth-first",
+		`#`,
+		`
+base: [base1.yaml, {url: base2.yaml}] # also test file.url format
+additionalDisks: [disk0]
+---
+base: base3.yaml
+additionalDisks: [disk1]
+---
+additionalDisks: [disk2]
+---
+additionalDisks: [disk3]
+`,
+		`
+additionalDisks: [disk0, disk1, disk3, disk2]
+`,
+	},
+	{
+		"additionalDisks with name '*' are merged with all previous entries",
+		`
+additionalDisks:
+- name: disk1
+- name: disk2
+- name: disk3
+  format: false
+`,
+		`
+additionalDisks:
+- name: disk4
+- name: "*"
+  format: true # will apply to disk1, disk2, and disk4
+- name: disk5
+`,
+		`
+additionalDisks:
+- name: disk1
+  format: true
+- name: disk2
+  format: true
+- name: disk3
+  format: false
+- name: disk4
+  format: true
+- name: disk5
+`,
+	},
+	{
+		// This test fails because the yq commands don't handle comments properly; may need to be fixed in yq
+		"TODO additionalDisks will be upgraded from string to map",
+		`#
+additionalDisks:
+# my head comment
+- mine # my line comment
+`,
+		`
+# head comment
+additionalDisks: # line comment
+- name: "*"
+  format: true # formatting is good for you
+`,
+		`
+# head comment
+additionalDisks:  # line comment
+# my head comment
+- name: mine  # my line comment
+  format: true  # formatting is good for you
+`,
+	},
+	{
+		// This entry can be deleted when the previous one no longer fails
+		"additionalDisks will be upgraded from string to map (no comments version)",
+		`additionalDisks: [mine]`,
+		`additionalDisks: [{name: "*", format: true}]`,
+		`additionalDisks: [{name: mine, format: true}]`,
+	},
+	{
+		"networks without interface name are not merged",
+		`
+networks:
+- interface: lima1
+`,
+		`
+networks:
+- interface: lima2
+# The metric will not be merged with anything
+- metric: 250
+- interface: lima1
+  metric: 100     # will be set on the first entry
+- interface: '*'  # wildcard
+  metric: 123     # will be set on the first entry
+`,
+		`
+networks:
+- interface: lima1
+  metric: 100  # will be set on the first entry
+- interface: lima2
+  metric: 123  # will be set on the first entry
+# The metric will not be merged with anything
+- metric: 250
+`,
+	},
+	{
+		"Scripts are embedded with comments moved",
+		`#
+# Hi There!
+provision:
+# This script will be merged from an external file
+- file: base1.sh # This comment will move to the "script" key
+`,
+		`
+# base0.yaml is ignored
+---
+#!/usr/bin/env bash
+echo "This is base1.sh"
+`,
+		`
+# Hi There!
+provision:
+# This script will be merged from an external file
+- script: |-  # This comment will move to the "script" key
+    #!/usr/bin/env bash
+    echo "This is base1.sh"
+# base0.yaml is ignored
+`,
+	},
+	{
+		"Script files are embedded even when no base property exists",
+		"provision: [{file: base0.sh}]",
+		"#! my script",
+		`provision: [{script: "#! my script"}]`,
+	},
+	{
+		"ERROR base digest is not yet implemented (1)",
+		"",
+		"base: {url: base.yaml, digest: deafbad}",
+		"not yet implemented",
+	},
+	{
+		"ERROR base digest is not yet implemented (2)",
+		"",
+		"base: [{url: base.yaml, digest: deafbad}]",
+		"not yet implemented",
+	},
+}
+
+func TestEmbed(t *testing.T) {
+	focus := os.Getenv("TEST_FOCUS")
+	for _, tc := range embedTestCases {
+		if focus != "" {
+			if !strings.Contains(tc.description, focus) {
+				continue
+			}
+			logrus.SetLevel(logrus.DebugLevel)
+		}
+		t.Run(tc.description, func(t *testing.T) { RunEmbedTest(t, tc) })
+	}
+	logrus.SetLevel(logrus.InfoLevel)
+}
+
+func RunEmbedTest(t *testing.T, tc embedTestCase) {
+	todo := strings.HasPrefix(tc.description, "TODO")
+	expectError := strings.HasPrefix(tc.description, "ERROR")
+	stringCompare := strings.HasPrefix(tc.template, "#")
+
+	// Normalize testcase data
+	tc.template = strings.TrimSpace(strings.TrimPrefix(tc.template, "#"))
+	tc.base = strings.TrimSpace(tc.base)
+	tc.expected = strings.TrimSpace(tc.expected)
+
+	// Change to temp directory so all template and script names don't include a slash.
+	cwd, err := os.Getwd()
+	assert.NilError(t, err, "Getting current working directory")
+	err = os.Chdir(t.TempDir())
+	assert.NilError(t, err, "Changing directory to t.TempDir()")
+	defer func() {
+		_ = os.Chdir(cwd)
+	}()
+
+	for i, base := range strings.Split(tc.base, "---\n") {
+		extension := ".yaml"
+		if strings.HasPrefix(base, "#!") {
+			extension = ".sh"
+		}
+		baseFilename := fmt.Sprintf("base%d%s", i, extension)
+		err := os.WriteFile(baseFilename, []byte(base), 0o600)
+		assert.NilError(t, err, tc.description)
+	}
+	tmpl := &limatmpl.Template{
+		Bytes:   []byte(fmt.Sprintf("base: base0.yaml\n%s", tc.template)),
+		Locator: "tmpl.yaml",
+	}
+	// Don't include `base` property if base0 is a script
+	if strings.HasPrefix(tc.base, "#!") {
+		tmpl.Bytes = []byte(tc.template)
+	}
+	err = tmpl.Embed(context.TODO(), false, false)
+	if expectError {
+		assert.ErrorContains(t, err, tc.expected, tc.description)
+		return
+	}
+	assert.NilError(t, err, tc.description)
+
+	if stringCompare {
+		actual := strings.TrimSpace(string(tmpl.Bytes))
+		if todo {
+			assert.Assert(t, actual != tc.expected, tc.description)
+		} else {
+			assert.Equal(t, actual, tc.expected, tc.description)
+		}
+		return
+	}
+
+	err = tmpl.Unmarshal()
+	assert.NilError(t, err, tc.description)
+
+	var expected limayaml.LimaYAML
+	err = limayaml.Unmarshal([]byte(tc.expected), &expected, "expected")
+	assert.NilError(t, err, tc.description)
+
+	if todo {
+		// using reflect.DeepEqual because cmp.DeepEqual can't easily be negated
+		assert.Assert(t, !reflect.DeepEqual(tmpl.Config, &expected), tc.description)
+	} else {
+		assert.Assert(t, cmp.DeepEqual(tmpl.Config, &expected), tc.description)
+	}
+}
diff --git a/pkg/limayaml/validate_test.go b/pkg/limayaml/validate_test.go
index 5623166f..a7b94f53 100644
--- a/pkg/limayaml/validate_test.go
+++ b/pkg/limayaml/validate_test.go
@@ -49,6 +49,13 @@ func TestValidateProbes(t *testing.T) {
 
 	err = Validate(y, false)
 	assert.Error(t, err, "field `probe[0].script` must start with a '#!' line")
+
+	invalidProbe = `probes: [{file: {digest: decafbad}}]`
+	y, err = Load([]byte(invalidProbe+"\n"+images), "lima.yaml")
+	assert.NilError(t, err)
+
+	err = Validate(y, false)
+	assert.Error(t, err, "field `probe[0].file.digest` support is not yet implemented")
 }
 
 func TestValidateAdditionalDisks(t *testing.T) {
diff --git a/pkg/templatestore/templatestore.go b/pkg/templatestore/templatestore.go
index ec486fe2..104d26b7 100644
--- a/pkg/templatestore/templatestore.go
+++ b/pkg/templatestore/templatestore.go
@@ -8,6 +8,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"unicode"
 
 	securejoin "github.com/cyphar/filepath-securejoin"
 	"github.com/lima-vm/lima/pkg/usrlocalsharelima"
@@ -23,11 +24,17 @@ func Read(name string) ([]byte, error) {
 	if err != nil {
 		return nil, err
 	}
-	yamlPath, err := securejoin.SecureJoin(filepath.Join(dir, "templates"), name+".yaml")
+	ext := filepath.Ext(name)
+	// Append .yaml extension if name doesn't have an extension, or if it starts with a digit.
+	// So "docker.sh" would remain unchanged but "ubuntu-24.04" becomes "ubuntu-24.04.yaml".
+	if len(ext) < 2 || unicode.IsDigit(rune(ext[1])) {
+		name += ".yaml"
+	}
+	filePath, err := securejoin.SecureJoin(filepath.Join(dir, "templates"), name)
 	if err != nil {
 		return nil, err
 	}
-	return os.ReadFile(yamlPath)
+	return os.ReadFile(filePath)
 }
 
 const Default = "default"