Repository: ddev/ddev
Pull request #6379 (2024-07-05 22:10:48)

feat: Allow traefik merged static configuration override with `static_config.*.yaml`
## The Issue

This is a followup to 
* https://github.com/ddev/ddev/pull/6317

We discovered there that we didn't have an appropriate way to add or override static configuration for traefik. We could only add to the base config, couldn't merge or override any elements. Now we can merge and override. For example, several plugins can be added in separate files, logLevel can be changed, etc.

## How This PR Solves The Issue

`~/.ddev/traefik/static_config.*.yaml` can now override and is generated into `~/.ddev/traefik/.static_config.yaml`

This will make it possible to do things like changing log, overriding plugins, etc.

## Manual Testing Instructions

Docs review:
* https://ddev--6379.org.readthedocs.build/en/6379/users/extend/traefik-router/#traefik-configuration
* https://ddev--6379.org.readthedocs.build/en/6379/users/topics/hosting/

Try the examples for static_config.*.yaml in the [traefik router docs](https://ddev--6379.org.readthedocs.build/en/6379/users/extend/traefik-router)

## Automated Testing Overview

* TestTraefikStaticConfig
* TestMergeYaml

## Release/Deployment Notes

<!-- Does this affect anything else or have ramifications for other code? Does anything have to be done on deployment? -->


github-actions[bot]: <!-- bot: nightly-link -->
Download the artifacts for this pull request:

* [all-ddev-executables.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572716.zip)
* [ddev-macos-amd64.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572748.zip)
* [ddev-macos-arm64.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572776.zip)
* [ddev-linux-arm64.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572819.zip)
* [ddev-linux-amd64.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572844.zip)
* [ddev-windows-amd64-installer.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572881.zip)
* [ddev-windows-arm64.zip](https://nightly.link/ddev/ddev/actions/artifacts/1672572910.zip)

See [Testing a PR](https://ddev.readthedocs.io/en/latest/developers/building-contributing/#testing-a-pr).

[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/ddev/ddev/pull/6379)

[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/?ref=20240705_merge_traefik_static&repo=80927419)
rfay: Thanks for the review! 
rfay: @nickchomey - the loss of comments is not a problem with mergo (or at least doesn't start there). It's a problem with go-yaml. I've never figured out how to get past it, might be worth a return to it.

The easiest way to see it is to create a comment in your config.yaml and `ddev config --auto`, comment will be lost.

See
* https://github.com/go-yaml/yaml/issues/132#issuecomment-498791474
* https://stackoverflow.com/questions/56401238/how-to-unmarshall-yaml-v3-with-full-access-to-node
* Many problems in https://github.com/go-yaml/yaml/issues?q=is%3Aopen+is%3Aissue+in%3Atitle+comment+
* https://github.com/ddev/ddev/issues/1529
* https://github.com/ddev/ddev/issues/1074
rfay: Wow, thanks for catching that sendAnonymousUsage @stasadev . I guess it must have been that way for years. 

Rebased, fixed that, added variation of @nickchomey suggestion. When most tests pass I'll pull and @nickchomey can rebase against HEAD.

Patch:
diff --git a/docs/content/users/extend/traefik-router.md b/docs/content/users/extend/traefik-router.md
index dc97bdbd0..9e11168d0 100644
--- a/docs/content/users/extend/traefik-router.md
+++ b/docs/content/users/extend/traefik-router.md
@@ -1,6 +1,6 @@
 # Router Customization and Debugging (Traefik)
 
-Traefik is the default router in DDEV v1.22+. The older `nginx-proxy` router is deprecated and will be removed in DDEV v1.24.
+Traefik is the default router in DDEV v1.22+. The older `nginx-proxy` router is deprecated, no longer receives automated test coverage, and will be removed in DDEV v1.24.
 
 DDEV’s router plays an important role in its [container architecture](../usage/architecture.md#container-architecture), receiving HTTP and HTTPS traffic for requests like `*.ddev.site` and delivering them to the relevant project’s web container.
 
@@ -8,7 +8,7 @@ DDEV uses Traefik by default unless you configure the (deprecated) `nginx-proxy`
 
 ## Traefik Configuration
 
-Before continuing, it's important to note that **very few users ever experiment with custom Traefik configuration**. This is an advanced topic, and the vast majority of users never need to know anything about it, as DDEV generates all of the necessary configuration. An understanding of Traefik configuration is required.
+Before continuing, it's important to note that **very few users ever experiment with custom Traefik configuration**. This is an advanced topic, and the vast majority of users never need to know anything about it, as DDEV generates all the necessary configuration. An understanding of Traefik configuration is required.
 
 You can fully customize the router’s [Traefik configuration](https://doc.traefik.io/traefik/getting-started/configuration-overview/). (DDEV uses the Traefik `v2` rule syntax by default, `defaultRuleSyntax: v2`. However, any custom router configuration can use `ruleSyntax: v3` to override this.)
 
@@ -21,10 +21,32 @@ All Traefik configuration uses the *file* provider, not the *Docker* provider. E
 
 Static configuration is automatically generated in the `~/.ddev/traefik` directory. "Static" configuration means Traefik configuration which is only read when the router is started.
 
-* `.static_config.yaml` is the base configuration, generated by DDEV, and not to be edited. It is read on router startup, and does not change after that.
-* Additional static configuration may be added by adding a `static_config.*.yaml` file, which will be appended to the generated `.static_config.yaml`.
+* `.static_config.yaml` (a hidden file) is the configuration that gets used. It is not to be edited; it is generated from DDEV's base configuration while merging any files named `static_config.*.yaml`. It is read on router startup, and does not change until the router starts again (normally after `ddev poweroff`).
+* Additional static configuration may be added by adding `static_config.*.yaml` files, which will be merged into the generated `.static_config.yaml`. For example, a `static_config.plugin.yaml` might contain external Traefik plugins, or a `static_config.dnschallenge.yaml` might provide configuration for additional `certificatesResolvers`. Merging is done with an **override** strategy, meaning that the final file in alphanumeric sort to touch a particular element of the YAML structure wins.
+    Some examples of `static_config.*.yaml` files are:
+    * `static_config.cloudflare.yaml`:
+
+      ```yaml
+      certificatesResolvers:
+        acme-dnsChallenge:
+          acme:
+            email: admin@example.com
+            dnsChallenge:
+              provider: cloudflare
+      ```
+
+    * `static_config.fail2ban.yaml`
+
+        ```yaml
+        experimental:
+          plugins:
+            fail2ban:
+              moduleName: "github.com/tomMoulard/fail2ban"
+              version: "v0.8.1"
+      ```
+
 * `certs/default_cert.*` files are the default DDEV-generated certificates, normally created by `mkcert`.
-* `config/default_config.yaml` contains global dynamic configuration, including pointers to the default certificates. It is possible to add other Traefik configuration in the `config` directory, which will apply to all projects. For example, a `config/router_middlewares.yaml` file might provide middleware implementations that would apply to all projects.
+* `config/default_config.yaml` contains global *dynamic* configuration, including pointers to the default certificates. It is possible to add other Traefik configuration in the `config` directory, which will apply to all projects. For example, a `config/router_middlewares.yaml` file might provide middleware implementations that would apply to all projects.
 
 ### Project Traefik Configuration
 
@@ -51,7 +73,7 @@ When things seem to be going wrong, run [`ddev poweroff`](../usage/commands.md#p
 
 ## Router `docker-compose` Customization
 
-The default configuration for the router is found in `~/.ddev/.router-compose.yaml`. It is quite unusual to override this configuration, but it can be overridden in the same way project configuration can be overridden (project `.ddev/docker-compose.*.yaml`). These ultimately get merged into `~/.ddev/.router-compose-full.yaml`
+The default Docker Compose configuration for the router container is found in `~/.ddev/.router-compose.yaml`. It is quite unusual to override this configuration, but it can be overridden in the same way project configuration can be overridden (project `.ddev/docker-compose.*.yaml`). These ultimately get merged into `~/.ddev/.router-compose-full.yaml`
 
 You can create a `~/.ddev/router-compose.*.yaml`. For example, as `~/.ddev/router-compose.cloudflare.yaml` might contain environment variables like this:
 
diff --git a/docs/content/users/topics/hosting.md b/docs/content/users/topics/hosting.md
index 9f238538f..606fe25d0 100644
--- a/docs/content/users/topics/hosting.md
+++ b/docs/content/users/topics/hosting.md
@@ -25,6 +25,14 @@ Here’s how to try it for yourself:
 8. Create your DDEV project as you normally would, but `ddev config --project-name=<yourproject> --project-tld=<your-top-level-domain>`. If your website responds to multiple hostnames (e.g., with and without `www`), you’ll need to add `additional_hostnames`.
 9. Redirect HTTP to HTTPS. Edit the `.ddev/traefik/config/<projectname>.yaml` to remove the `#ddev-generated` and uncomment the `middlewares:` and `- "redirectHttps"` lines in the HTTP router section.
 10. Run [`ddev start`](../usage/commands.md#start) and visit your site. With some CMSes, you may also need to clear your cache.
+11. If you see trouble with Let's Encrypt `ACME` failures, you can temporarily switch to the `ACME` staging server, and avoid getting rate-limited while you are experimenting. The certificates it serves will not be valid, but you'll see that they're coming from Let's Encrypt anyway. Add a `~/.ddev/traefik/static_config.staging.yaml` with the contents:
+
+    ```yaml
+    certificatesResolvers:
+    acme-tlsChallenge:
+        acme:
+            caServer: "https://acme-staging-v02.api.letsencrypt.org/directory"
+    ```
 
 You may have to restart DDEV with `ddev poweroff && ddev start --all` if Let’s Encrypt has failed for some reason or the DNS name is not yet resolving. (Use `docker logs -f ddev-router` to see Let’s Encrypt activity.)
 
diff --git a/pkg/ddevapp/router.go b/pkg/ddevapp/router.go
index 4ae8ae0a1..ee0d8626f 100644
--- a/pkg/ddevapp/router.go
+++ b/pkg/ddevapp/router.go
@@ -80,7 +80,7 @@ func StartDdevRouter() error {
 		return err
 	}
 	if globalconfig.DdevGlobalConfig.IsTraefikRouter() {
-		err = pushGlobalTraefikConfig()
+		err = PushGlobalTraefikConfig()
 		if err != nil {
 			return fmt.Errorf("failed to push global Traefik config: %v", err)
 		}
diff --git a/pkg/ddevapp/traefik.go b/pkg/ddevapp/traefik.go
index 50077572f..cf582f179 100644
--- a/pkg/ddevapp/traefik.go
+++ b/pkg/ddevapp/traefik.go
@@ -2,7 +2,6 @@ package ddevapp
 
 import (
 	"fmt"
-	"io"
 	"os"
 	"path"
 	"path/filepath"
@@ -102,8 +101,8 @@ func processHTTPExpose(serviceName string, httpExpose string, isHTTPS bool, exte
 	return routingTable, nil
 }
 
-// pushGlobalTraefikConfig pushes the config into ddev-global-cache
-func pushGlobalTraefikConfig() error {
+// PushGlobalTraefikConfig pushes the config into ddev-global-cache
+func PushGlobalTraefikConfig() error {
 	globalTraefikDir := filepath.Join(globalconfig.GetGlobalDdevDir(), "traefik")
 	uid, _, _ := util.GetContainerUIDGid()
 	err := os.MkdirAll(globalTraefikDir, 0755)
@@ -229,43 +228,38 @@ func pushGlobalTraefikConfig() error {
 		}
 	}
 
-	staticConfigPath := filepath.Join(globalTraefikDir, ".static_config.yaml")
+	staticConfigFinalPath := filepath.Join(globalTraefikDir, ".static_config.yaml")
 
-	staticConfigFile, err := os.Create(staticConfigPath)
+	staticConfigTemp, err := os.CreateTemp("", "static_config-")
 	if err != nil {
-		util.Failed("Failed to create Traefik config file: %v", err)
+		return err
 	}
-	defer staticConfigFile.Close()
+
 	t, err := template.New("traefik_static_config_template.yaml").Funcs(getTemplateFuncMap()).ParseFS(bundledAssets, "traefik_static_config_template.yaml")
 	if err != nil {
 		return fmt.Errorf("could not create template from traefik_static_config_template.yaml: %v", err)
 	}
 
-	err = t.Execute(staticConfigFile, templateData)
+	err = t.Execute(staticConfigTemp, templateData)
 	if err != nil {
 		return fmt.Errorf("could not parse traefik_static_config_template.yaml with templatedate='%v':: %v", templateData, err)
 	}
-
-	// Append contents of globalTraefikDir's `static_config.*.yaml` to the config file
+	tmpFileName := staticConfigTemp.Name()
+	err = staticConfigTemp.Close()
+	if err != nil {
+		return err
+	}
 	extraStaticConfigFiles, err := fileutil.GlobFilenames(globalTraefikDir, "static_config.*.yaml")
 	if err != nil {
-		return fmt.Errorf("could not GlobFilenames(%s, static_config.*.yaml): %v", globalTraefikDir, err)
+		return err
 	}
-
-	for _, extraFile := range extraStaticConfigFiles {
-		_, err = staticConfigFile.Write([]byte(fmt.Sprintf("\n# Appended content from file '%s'\n", extraFile)))
-		if err != nil {
-			return fmt.Errorf("could not append content from %s: %v", extraFile, err)
-		}
-		s, err := fileutil.ReadFileIntoString(extraFile)
-		if err != nil {
-			return err
-		}
-		_, err = io.Copy(staticConfigFile, strings.NewReader(s))
-		if err != nil {
-			return err
-		}
-
+	resultYaml, err := util.MergeYamlFiles(tmpFileName, extraStaticConfigFiles...)
+	if err != nil {
+		return err
+	}
+	err = os.WriteFile(staticConfigFinalPath, []byte(resultYaml), 0755)
+	if err != nil {
+		return err
 	}
 
 	err = dockerutil.CopyIntoVolume(globalTraefikDir, "ddev-global-cache", "traefik", uid, "", false)
diff --git a/pkg/ddevapp/traefik_static_config_template.yaml b/pkg/ddevapp/traefik_static_config_template.yaml
index e2e3c49cb..da37a0249 100644
--- a/pkg/ddevapp/traefik_static_config_template.yaml
+++ b/pkg/ddevapp/traefik_static_config_template.yaml
@@ -32,7 +32,7 @@ providers:
     directory: /mnt/ddev-global-cache/traefik/config
     watch: true
 global:
-  sendanonymoususage: false
+  sendAnonymousUsage: false
 
 ping:
   entryPoint: "traefik"
diff --git a/pkg/util/yamltools.go b/pkg/util/yamltools.go
index 97a82d2cd..6887e0711 100644
--- a/pkg/util/yamltools.go
+++ b/pkg/util/yamltools.go
@@ -2,8 +2,10 @@ package util
 
 import (
 	"fmt"
-	"gopkg.in/yaml.v3"
 	"os"
+
+	"github.com/imdario/mergo"
+	"gopkg.in/yaml.v3"
 )
 
 // YamlFileToMap() reads the named file into a map[string]interface{}
@@ -50,3 +52,29 @@ func YamlToDict(topm interface{}) (map[string]interface{}, error) {
 	}
 	return res, nil
 }
+
+// MergeYamlFiles merges yaml files extraFiles into the baseFile, returning the
+// result as a string
+// Merging is *override* based, so later files can override contents of others
+func MergeYamlFiles(baseFile string, extraFile ...string) (string, error) {
+	resultMap, err := YamlFileToMap(baseFile)
+	if err != nil {
+		return "", err
+	}
+	for _, fileName := range extraFile {
+		m, err := YamlFileToMap(fileName)
+		if err != nil {
+			return "", err
+		}
+		err = mergo.Merge(&resultMap, m, mergo.WithOverride)
+		if err != nil {
+			return "", err
+		}
+	}
+	result, err := yaml.Marshal(resultMap)
+	if err != nil {
+		return "", err
+	}
+
+	return string(result), nil
+}


Test patch:
diff --git a/cmd/ddev/cmd/scripts/test_ddev.sh b/cmd/ddev/cmd/scripts/test_ddev.sh
index ba63645fc..57cdfb763 100755
--- a/cmd/ddev/cmd/scripts/test_ddev.sh
+++ b/cmd/ddev/cmd/scripts/test_ddev.sh
@@ -119,7 +119,7 @@ which -a docker
 echo
 
 printf "Docker provider: ${docker_platform}\n"
-if [ "${OSTYPE%-*}" = "linux" ] && [ "$docker_platform" = "docker-desktop" ]; then
+if [ "${WSL_DISTRO_NAME}" = "" ] && [ "${OSTYPE%-*}" = "linux" ] && [ "$docker_platform" = "docker-desktop" ]; then
   printf "ERROR: Using Docker Desktop on Linux is not supported.\n"
 fi
 
diff --git a/containers/ddev-traefik-router/test/testdata/.static_config.yaml b/containers/ddev-traefik-router/test/testdata/.static_config.yaml
index fd3cfc4b1..043e5332b 100644
--- a/containers/ddev-traefik-router/test/testdata/.static_config.yaml
+++ b/containers/ddev-traefik-router/test/testdata/.static_config.yaml
@@ -15,7 +15,7 @@ providers:
     directory: /mnt/ddev-global-cache/traefik/config
     watch: true
 global:
-  sendanonymoususage: false
+  sendAnonymousUsage: false
 
 ping:
   entryPoint: "traefik"
diff --git a/pkg/ddevapp/testdata/TestTraefikStaticConfig/extraPlugin/expectation.yaml b/pkg/ddevapp/testdata/TestTraefikStaticConfig/extraPlugin/expectation.yaml
new file mode 100644
index 000000000..9bc65d3bf
--- /dev/null
+++ b/pkg/ddevapp/testdata/TestTraefikStaticConfig/extraPlugin/expectation.yaml
@@ -0,0 +1,38 @@
+accesslog: {}
+api:
+  dashboard: true
+  insecure: true
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+core:
+  defaultRuleSyntax: v2
+entryPoints:
+  http-80:
+    address: :80
+  http-443:
+    address: :443
+  http-8025:
+    address: :8025
+  http-8026:
+    address: :8026
+  traefik:
+    address: :10999
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: github.com/tomMoulard/fail2ban
+      version: v0.8.1
+global:
+  sendAnonymousUsage: false
+log:
+  level: DEBUG
+ping:
+  entryPoint: traefik
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
diff --git a/pkg/ddevapp/testdata/TestTraefikStaticConfig/extraPlugin/static_config.extra_plugin.yaml b/pkg/ddevapp/testdata/TestTraefikStaticConfig/extraPlugin/static_config.extra_plugin.yaml
new file mode 100644
index 000000000..858f95712
--- /dev/null
+++ b/pkg/ddevapp/testdata/TestTraefikStaticConfig/extraPlugin/static_config.extra_plugin.yaml
@@ -0,0 +1,9 @@
+# fail2ban plugin example from
+# https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban
+# Additional configuration is required to add this as a middleware in router
+
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: "github.com/tomMoulard/fail2ban"
+      version: "v0.8.1"
diff --git a/pkg/ddevapp/testdata/TestTraefikStaticConfig/logChange/expectation.yaml b/pkg/ddevapp/testdata/TestTraefikStaticConfig/logChange/expectation.yaml
new file mode 100644
index 000000000..8e912f5e6
--- /dev/null
+++ b/pkg/ddevapp/testdata/TestTraefikStaticConfig/logChange/expectation.yaml
@@ -0,0 +1,33 @@
+accesslog: {}
+api:
+  dashboard: true
+  insecure: true
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+core:
+  defaultRuleSyntax: v2
+entryPoints:
+  http-80:
+    address: :80
+  http-443:
+    address: :443
+  http-8025:
+    address: :8025
+  http-8026:
+    address: :8026
+  traefik:
+    address: :10999
+global:
+  sendAnonymousUsage: false
+log:
+  level: INFO
+ping:
+  entryPoint: traefik
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
diff --git a/pkg/ddevapp/testdata/TestTraefikStaticConfig/logChange/static_config.log_change.yaml b/pkg/ddevapp/testdata/TestTraefikStaticConfig/logChange/static_config.log_change.yaml
new file mode 100644
index 000000000..478a84933
--- /dev/null
+++ b/pkg/ddevapp/testdata/TestTraefikStaticConfig/logChange/static_config.log_change.yaml
@@ -0,0 +1,2 @@
+log:
+  level: INFO
diff --git a/pkg/ddevapp/traefik_test.go b/pkg/ddevapp/traefik_test.go
index a1ecf33fe..c000774b4 100644
--- a/pkg/ddevapp/traefik_test.go
+++ b/pkg/ddevapp/traefik_test.go
@@ -1,17 +1,20 @@
 package ddevapp_test
 
 import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
 	"github.com/ddev/ddev/pkg/ddevapp"
 	"github.com/ddev/ddev/pkg/fileutil"
 	"github.com/ddev/ddev/pkg/globalconfig"
 	"github.com/ddev/ddev/pkg/globalconfig/types"
 	"github.com/ddev/ddev/pkg/testcommon"
+	copy2 "github.com/otiai10/copy"
 	asrt "github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
+	"gopkg.in/yaml.v3"
 )
 
 // TestTraefikSimple tests basic Traefik router usage
@@ -139,3 +142,72 @@ func TestTraefikVirtualHost(t *testing.T) {
 		_, _ = testcommon.EnsureLocalHTTPContent(t, "https://extra.ddev.site", "Welcome to nginx")
 	}
 }
+
+// TestTraefikStaticConfig tests static config usage and merging
+func TestTraefikStaticConfig(t *testing.T) {
+	origDir, _ := os.Getwd()
+	globalTraefikDir := filepath.Join(globalconfig.GetGlobalDdevDir(), "traefik")
+	staticConfigFinalPath := filepath.Join(globalTraefikDir, ".static_config.yaml")
+
+	site := TestSites[0] // 0 == wordpress
+	app, err := ddevapp.NewApp(site.Dir, true)
+	require.NoError(t, err)
+
+	testData := filepath.Join(origDir, "testdata", t.Name())
+
+	err = app.Start()
+	require.NoError(t, err)
+
+	t.Cleanup(func() {
+		_ = app.Stop(true, false)
+		ddevapp.PowerOff()
+	})
+
+	testCases := []struct {
+		content string
+		dir     string
+	}{
+		{"logChange", "logChange"},
+		{"extraPlugin", "extraPlugin"},
+	}
+	for _, tc := range testCases {
+		t.Run("", func(t *testing.T) {
+			testSourceDir := filepath.Join(testData, tc.dir)
+			traefikGlobalConfigDir := filepath.Join(globalconfig.GetGlobalDdevDir(), "traefik")
+
+			err = copy2.Copy(testSourceDir, traefikGlobalConfigDir)
+			require.NoError(t, err)
+
+			// Remove any static_config.*.yaml we have added
+			t.Cleanup(func() {
+				files, _ := filepath.Glob(filepath.Join(testSourceDir, "static_config.*.yaml"))
+				for _, fileToRemove := range files {
+					f := filepath.Base(fileToRemove)
+					err = os.Remove(filepath.Join(traefikGlobalConfigDir, f))
+					require.NoError(t, err)
+				}
+				err = os.Remove(filepath.Join(traefikGlobalConfigDir, "expectation.yaml"))
+				require.NoError(t, err)
+				err = ddevapp.PushGlobalTraefikConfig()
+				require.NoError(t, err)
+			})
+
+			// Unmarshal the loaded result expectation so it will look the same as merged (without comments, etc)
+			var tmpMap map[string]interface{}
+			expectedResultString, err := fileutil.ReadFileIntoString(filepath.Join(testSourceDir, "expectation.yaml"))
+			require.NoError(t, err)
+			err = yaml.Unmarshal([]byte(expectedResultString), &tmpMap)
+			require.NoError(t, err)
+			unmarshalledExpectationString, err := yaml.Marshal(tmpMap)
+			require.NoError(t, err)
+
+			// Generate and push config
+			err = ddevapp.PushGlobalTraefikConfig()
+			require.NoError(t, err)
+			// Now read result config and compare
+			renderedStaticConfig, err := fileutil.ReadFileIntoString(staticConfigFinalPath)
+			require.NoError(t, err)
+			require.Equal(t, string(unmarshalledExpectationString), renderedStaticConfig)
+		})
+	}
+}
diff --git a/pkg/util/testdata/TestMergeYamlFiles/Overrides/base.yaml b/pkg/util/testdata/TestMergeYamlFiles/Overrides/base.yaml
new file mode 100644
index 000000000..8945258d0
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/Overrides/base.yaml
@@ -0,0 +1,54 @@
+#ddev-generated
+# Traefik "static configuration" file for DDEV
+# DO NOT EDIT this file, your edits will be replaced.
+# Although Traefik calls this "static configuration" it is changed
+# regularly to update `entryPoints`
+
+log:
+  level: DEBUG
+accesslog: { }
+
+api:
+  dashboard: true
+  insecure: true
+
+core:
+  defaultRuleSyntax: v2
+
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      # To avoid Let's Encrypt rate-limiting,
+      # we can uncomment `caServer` here to use the staging caServer and avoid messages like
+      # rateLimited :: Error creating new order :: too many failed authorizations recently:
+      # see https://letsencrypt.org/docs/failed-validation-limit/
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
+global:
+  sendAnonymousUsage: false
+
+ping:
+  entryPoint: "traefik"
+
+entryPoints:
+  traefik:
+    address: ":10999"
+
+  http-443:
+    address: ":443"
+
+  http-80:
+    address: ":80"
+
+  http-8025:
+    address: ":8025"
+
+  http-8026:
+    address: ":8026"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/Overrides/expectation.yaml b/pkg/util/testdata/TestMergeYamlFiles/Overrides/expectation.yaml
new file mode 100644
index 000000000..563157697
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/Overrides/expectation.yaml
@@ -0,0 +1,45 @@
+accesslog: {}
+api:
+  dashboard: true
+  insecure: true
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+  acmeresolver:
+    acme:
+      dnsChallenge:
+        provider: cloudflare
+core:
+# defaultRuleSyntax overwritten to v3 in extra_overwrites_1
+  defaultRuleSyntax: v3
+entryPoints:
+  http-80:
+    address: :80
+  http-443:
+    address: :443
+  http-8025:
+    address: :8025
+  http-8026:
+    address: :8026
+  traefik:
+    address: :10999
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: github.com/tomMoulard/fail2ban
+      version: v0.8.1
+global:
+  sendAnonymousUsage: false
+# Here the log has been overridden to INFO in extra_overwrites_1
+# and then to WARN in extra_overwrites_2
+log:
+  level: WARN
+ping:
+  entryPoint: traefik
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
diff --git a/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_certificates_resolvers.yaml b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_certificates_resolvers.yaml
new file mode 100644
index 000000000..dee34ff7d
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_certificates_resolvers.yaml
@@ -0,0 +1,7 @@
+# Bring in extra acmeresolver (requires merge)
+certificatesResolvers:
+  acmeresolver:
+    acme:
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      dnsChallenge:
+        provider: cloudflare
diff --git a/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_overrides_1.yaml b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_overrides_1.yaml
new file mode 100644
index 000000000..2d5fbc59e
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_overrides_1.yaml
@@ -0,0 +1,5 @@
+log:
+  level: INFO
+
+core:
+  defaultRuleSyntax: v3
diff --git a/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_overrides_2.yaml b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_overrides_2.yaml
new file mode 100644
index 000000000..70dcd3f1b
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_overrides_2.yaml
@@ -0,0 +1,2 @@
+log:
+  level: WARN
diff --git a/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_plugins.yaml b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_plugins.yaml
new file mode 100644
index 000000000..858f95712
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/Overrides/extra_plugins.yaml
@@ -0,0 +1,9 @@
+# fail2ban plugin example from
+# https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban
+# Additional configuration is required to add this as a middleware in router
+
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: "github.com/tomMoulard/fail2ban"
+      version: "v0.8.1"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseFileOnly/base.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseFileOnly/base.yaml
new file mode 100644
index 000000000..8945258d0
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseFileOnly/base.yaml
@@ -0,0 +1,54 @@
+#ddev-generated
+# Traefik "static configuration" file for DDEV
+# DO NOT EDIT this file, your edits will be replaced.
+# Although Traefik calls this "static configuration" it is changed
+# regularly to update `entryPoints`
+
+log:
+  level: DEBUG
+accesslog: { }
+
+api:
+  dashboard: true
+  insecure: true
+
+core:
+  defaultRuleSyntax: v2
+
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      # To avoid Let's Encrypt rate-limiting,
+      # we can uncomment `caServer` here to use the staging caServer and avoid messages like
+      # rateLimited :: Error creating new order :: too many failed authorizations recently:
+      # see https://letsencrypt.org/docs/failed-validation-limit/
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
+global:
+  sendAnonymousUsage: false
+
+ping:
+  entryPoint: "traefik"
+
+entryPoints:
+  traefik:
+    address: ":10999"
+
+  http-443:
+    address: ":443"
+
+  http-80:
+    address: ":80"
+
+  http-8025:
+    address: ":8025"
+
+  http-8026:
+    address: ":8026"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseFileOnly/expectation.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseFileOnly/expectation.yaml
new file mode 100644
index 000000000..8945258d0
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseFileOnly/expectation.yaml
@@ -0,0 +1,54 @@
+#ddev-generated
+# Traefik "static configuration" file for DDEV
+# DO NOT EDIT this file, your edits will be replaced.
+# Although Traefik calls this "static configuration" it is changed
+# regularly to update `entryPoints`
+
+log:
+  level: DEBUG
+accesslog: { }
+
+api:
+  dashboard: true
+  insecure: true
+
+core:
+  defaultRuleSyntax: v2
+
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      # To avoid Let's Encrypt rate-limiting,
+      # we can uncomment `caServer` here to use the staging caServer and avoid messages like
+      # rateLimited :: Error creating new order :: too many failed authorizations recently:
+      # see https://letsencrypt.org/docs/failed-validation-limit/
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
+global:
+  sendAnonymousUsage: false
+
+ping:
+  entryPoint: "traefik"
+
+entryPoints:
+  traefik:
+    address: ":10999"
+
+  http-443:
+    address: ":443"
+
+  http-80:
+    address: ":80"
+
+  http-8025:
+    address: ":8025"
+
+  http-8026:
+    address: ":8026"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/base.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/base.yaml
new file mode 100644
index 000000000..8945258d0
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/base.yaml
@@ -0,0 +1,54 @@
+#ddev-generated
+# Traefik "static configuration" file for DDEV
+# DO NOT EDIT this file, your edits will be replaced.
+# Although Traefik calls this "static configuration" it is changed
+# regularly to update `entryPoints`
+
+log:
+  level: DEBUG
+accesslog: { }
+
+api:
+  dashboard: true
+  insecure: true
+
+core:
+  defaultRuleSyntax: v2
+
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      # To avoid Let's Encrypt rate-limiting,
+      # we can uncomment `caServer` here to use the staging caServer and avoid messages like
+      # rateLimited :: Error creating new order :: too many failed authorizations recently:
+      # see https://letsencrypt.org/docs/failed-validation-limit/
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
+global:
+  sendAnonymousUsage: false
+
+ping:
+  entryPoint: "traefik"
+
+entryPoints:
+  traefik:
+    address: ":10999"
+
+  http-443:
+    address: ":443"
+
+  http-80:
+    address: ":80"
+
+  http-8025:
+    address: ":8025"
+
+  http-8026:
+    address: ":8026"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/expectation.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/expectation.yaml
new file mode 100644
index 000000000..9ce1cb828
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/expectation.yaml
@@ -0,0 +1,42 @@
+accesslog: {}
+api:
+  dashboard: true
+  insecure: true
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+  acmeresolver:
+    acme:
+      dnsChallenge:
+        provider: cloudflare
+core:
+  defaultRuleSyntax: v2
+entryPoints:
+  http-80:
+    address: :80
+  http-443:
+    address: :443
+  http-8025:
+    address: :8025
+  http-8026:
+    address: :8026
+  traefik:
+    address: :10999
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: github.com/tomMoulard/fail2ban
+      version: v0.8.1
+global:
+  sendAnonymousUsage: false
+log:
+  level: DEBUG
+ping:
+  entryPoint: traefik
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/extra_certificates_resolvers.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/extra_certificates_resolvers.yaml
new file mode 100644
index 000000000..870a703ac
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/extra_certificates_resolvers.yaml
@@ -0,0 +1,6 @@
+certificatesResolvers:
+  acmeresolver:
+    acme:
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      dnsChallenge:
+        provider: cloudflare
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/extra_plugins.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/extra_plugins.yaml
new file mode 100644
index 000000000..858f95712
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithCertificatesResolvers/extra_plugins.yaml
@@ -0,0 +1,9 @@
+# fail2ban plugin example from
+# https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban
+# Additional configuration is required to add this as a middleware in router
+
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: "github.com/tomMoulard/fail2ban"
+      version: "v0.8.1"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/base.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/base.yaml
new file mode 100644
index 000000000..8945258d0
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/base.yaml
@@ -0,0 +1,54 @@
+#ddev-generated
+# Traefik "static configuration" file for DDEV
+# DO NOT EDIT this file, your edits will be replaced.
+# Although Traefik calls this "static configuration" it is changed
+# regularly to update `entryPoints`
+
+log:
+  level: DEBUG
+accesslog: { }
+
+api:
+  dashboard: true
+  insecure: true
+
+core:
+  defaultRuleSyntax: v2
+
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      # To avoid Let's Encrypt rate-limiting,
+      # we can uncomment `caServer` here to use the staging caServer and avoid messages like
+      # rateLimited :: Error creating new order :: too many failed authorizations recently:
+      # see https://letsencrypt.org/docs/failed-validation-limit/
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
+global:
+  sendAnonymousUsage: false
+
+ping:
+  entryPoint: "traefik"
+
+entryPoints:
+  traefik:
+    address: ":10999"
+
+  http-443:
+    address: ":443"
+
+  http-80:
+    address: ":80"
+
+  http-8025:
+    address: ":8025"
+
+  http-8026:
+    address: ":8026"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/expectation.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/expectation.yaml
new file mode 100644
index 000000000..9bc65d3bf
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/expectation.yaml
@@ -0,0 +1,38 @@
+accesslog: {}
+api:
+  dashboard: true
+  insecure: true
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+core:
+  defaultRuleSyntax: v2
+entryPoints:
+  http-80:
+    address: :80
+  http-443:
+    address: :443
+  http-8025:
+    address: :8025
+  http-8026:
+    address: :8026
+  traefik:
+    address: :10999
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: github.com/tomMoulard/fail2ban
+      version: v0.8.1
+global:
+  sendAnonymousUsage: false
+log:
+  level: DEBUG
+ping:
+  entryPoint: traefik
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
diff --git a/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/extra_plugins.yaml b/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/extra_plugins.yaml
new file mode 100644
index 000000000..858f95712
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/baseWithPlugins/extra_plugins.yaml
@@ -0,0 +1,9 @@
+# fail2ban plugin example from
+# https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban
+# Additional configuration is required to add this as a middleware in router
+
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: "github.com/tomMoulard/fail2ban"
+      version: "v0.8.1"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/base.yaml b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/base.yaml
new file mode 100644
index 000000000..8945258d0
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/base.yaml
@@ -0,0 +1,54 @@
+#ddev-generated
+# Traefik "static configuration" file for DDEV
+# DO NOT EDIT this file, your edits will be replaced.
+# Although Traefik calls this "static configuration" it is changed
+# regularly to update `entryPoints`
+
+log:
+  level: DEBUG
+accesslog: { }
+
+api:
+  dashboard: true
+  insecure: true
+
+core:
+  defaultRuleSyntax: v2
+
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      # To avoid Let's Encrypt rate-limiting,
+      # we can uncomment `caServer` here to use the staging caServer and avoid messages like
+      # rateLimited :: Error creating new order :: too many failed authorizations recently:
+      # see https://letsencrypt.org/docs/failed-validation-limit/
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
+global:
+  sendAnonymousUsage: false
+
+ping:
+  entryPoint: "traefik"
+
+entryPoints:
+  traefik:
+    address: ":10999"
+
+  http-443:
+    address: ":443"
+
+  http-80:
+    address: ":80"
+
+  http-8025:
+    address: ":8025"
+
+  http-8026:
+    address: ":8026"
diff --git a/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/expectation.yaml b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/expectation.yaml
new file mode 100644
index 000000000..a59665f87
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/expectation.yaml
@@ -0,0 +1,44 @@
+accesslog: {}
+api:
+  dashboard: true
+  insecure: true
+certificatesResolvers:
+  acme-tlsChallenge:
+    acme:
+      email: ""
+      storage: /mnt/ddev-global-cache/traefik/acme.json
+      tlsChallenge: {}
+  acmeresolver:
+    acme:
+      # caServer has been overridden here
+      caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      dnsChallenge:
+        provider: cloudflare
+core:
+  defaultRuleSyntax: v2
+entryPoints:
+  http-80:
+    address: :80
+  http-443:
+    address: :443
+  http-8025:
+    address: :8025
+  http-8026:
+    address: :8026
+  traefik:
+    address: :10999
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: github.com/tomMoulard/fail2ban
+      version: v0.8.1
+global:
+  sendAnonymousUsage: false
+log:
+  level: WARN
+ping:
+  entryPoint: traefik
+providers:
+  file:
+    directory: /mnt/ddev-global-cache/traefik/config
+    watch: true
diff --git a/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_certificates_resolvers.yaml b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_certificates_resolvers.yaml
new file mode 100644
index 000000000..dee34ff7d
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_certificates_resolvers.yaml
@@ -0,0 +1,7 @@
+# Bring in extra acmeresolver (requires merge)
+certificatesResolvers:
+  acmeresolver:
+    acme:
+      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory
+      dnsChallenge:
+        provider: cloudflare
diff --git a/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_late_caserver_overwrite.yaml b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_late_caserver_overwrite.yaml
new file mode 100644
index 000000000..77af788ac
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_late_caserver_overwrite.yaml
@@ -0,0 +1,5 @@
+certificatesResolvers:
+  acmeresolver:
+    acme:
+      # Change to staging caServer
+      caServer: https://acme-staging-v02.api.letsencrypt.org/directory
diff --git a/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_overwrites_2.yaml b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_overwrites_2.yaml
new file mode 100644
index 000000000..70dcd3f1b
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_overwrites_2.yaml
@@ -0,0 +1,2 @@
+log:
+  level: WARN
diff --git a/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_plugins.yaml b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_plugins.yaml
new file mode 100644
index 000000000..858f95712
--- /dev/null
+++ b/pkg/util/testdata/TestMergeYamlFiles/caServerOverride/extra_plugins.yaml
@@ -0,0 +1,9 @@
+# fail2ban plugin example from
+# https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban
+# Additional configuration is required to add this as a middleware in router
+
+experimental:
+  plugins:
+    fail2ban:
+      moduleName: "github.com/tomMoulard/fail2ban"
+      version: "v0.8.1"
diff --git a/pkg/util/yaml_test.go b/pkg/util/yaml_test.go
index b7c564584..5a8a24a2c 100644
--- a/pkg/util/yaml_test.go
+++ b/pkg/util/yaml_test.go
@@ -1,10 +1,15 @@
 package util_test
 
 import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/ddev/ddev/pkg/fileutil"
 	"github.com/ddev/ddev/pkg/util"
 	asrt "github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"testing"
+	"gopkg.in/yaml.v3"
 )
 
 // TestYamlFileToMap tests YamlFileToMap()
@@ -65,3 +70,42 @@ func TestYamlToDict(t *testing.T) {
 	require.True(t, ok)
 	assert.Equal("app", name)
 }
+
+// TestMergeYamlFiles tests many permutations of MergetYamlFiles()
+func TestMergeYamlFiles(t *testing.T) {
+	origDir, _ := os.Getwd()
+	testData := filepath.Join(origDir, "testdata", t.Name())
+
+	testCases := []struct {
+		content string
+		dir     string
+	}{
+		{"Basic no-actual-merge, just using the base file", "baseFileOnly"},
+		{"Basic file with a single file that only adds, no merging", "baseWithPlugins"},
+		{"certificates resolvers", "baseWithCertificatesResolvers"},
+		{"Overrides", "Overrides"},
+		{"caServerOverride", "caServerOverride"},
+	}
+	for _, tc := range testCases {
+		t.Run("", func(t *testing.T) {
+			testDir := filepath.Join(testData, tc.dir)
+			extraFiles, err := filepath.Glob(filepath.Join(testDir, "extra*.yaml"))
+			require.NoError(t, err)
+
+			mergeResult, err := util.MergeYamlFiles(filepath.Join(testDir, "base.yaml"), extraFiles...)
+			require.NoError(t, err)
+
+			expectedResultFile := filepath.Join(testDir, "expectation.yaml")
+			expectedResultString, err := fileutil.ReadFileIntoString(expectedResultFile)
+			require.NoError(t, err)
+			// Unmarshall the loaded result expectation so it will look the same as merged (without comments, etc)
+			var tmpMap map[string]interface{}
+			err = yaml.Unmarshal([]byte(expectedResultString), &tmpMap)
+			require.NoError(t, err)
+			unmarshalledExpectationString, err := yaml.Marshal(tmpMap)
+			require.NoError(t, err)
+
+			require.Equal(t, string(unmarshalledExpectationString), mergeResult)
+		})
+	}
+}