Repository: avenga/couper
Pull request #515 (2022-06-02 11:48:10)

CLI multi files
allow multiple `-f` and `-d` CLI options

---
<details>
    <summary>Reviewer checklist</summary>
    <ul>
        <li>Read PR description: a summary about the changes is required</li>
        <li>Changelog updated</li>
        <li>Documentation: docs/{Reference, Cli, ...}, Docker and cli help/usage</li>
        <li>Pulled branch, manually tested</li>
        <li>Verified requirements are met</li>
        <li>Reviewed the code</li>
        <li>Reviewed the related tests</li>
    </ul>
</details>



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b357c791..51dc5320 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,8 +5,7 @@
 Unreleased changes are available as `avenga/couper:edge` container.
 
 * **Added**
-  * Couper [reads and merges configuration files](./docs/CLI.md#global-options) from a given directory ([#437](https://github.com/avenga/couper/pull/437))
-    * provided via `-d` command-line flag or `COUPER_FILE_DIRECTORY` environment variable
+  * Couper now [reads and merges multiple configuration files](./docs/CLI.md#global-options) ([#437](https://github.com/avenga/couper/pull/437), [#515](https://github.com/avenga/couper/pull/515))
   * `beta_health`-block to `backend`-block to enable continuous health-checks for defined backends ([#313](https://github.com/avenga/couper/pull/313))
     * `backends.<name>.health` variable to access the current health-check state _(subject to change)_
   * Log malformed duration settings ([#487](https://github.com/avenga/couper/pull/487))
diff --git a/command/run.go b/command/run.go
index aa1fcaaf..6f1676a5 100644
--- a/command/run.go
+++ b/command/run.go
@@ -95,6 +95,8 @@ func (a AcceptForwardedValue) Set(s string) error {
 var limitFn func(entry *logrus.Entry)
 
 func (r *Run) Execute(args Args, config *config.Couper, logEntry *logrus.Entry) error {
+	logEntry.WithField("files", config.Files.AsList()).Debug("loaded files")
+
 	r.settingsMu.Lock()
 	*r.settings = *config.Settings
 	r.settingsMu.Unlock()
diff --git a/command/verify.go b/command/verify.go
index 0ce8b6fc..584ccc79 100644
--- a/command/verify.go
+++ b/command/verify.go
@@ -1,8 +1,6 @@
 package command
 
 import (
-	"fmt"
-
 	"github.com/avenga/couper/cache"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload"
@@ -20,16 +18,7 @@ func NewVerify() *Verify {
 }
 
 func (v Verify) Execute(args Args, _ *config.Couper, logger *logrus.Entry) error {
-	if len(args) != 2 {
-		v.Usage()
-
-		err := fmt.Errorf("invalid number of arguments given")
-		logger.WithError(err).Error()
-
-		return err
-	}
-
-	cf, err := configload.LoadFiles(args[0], args[1])
+	cf, err := configload.LoadFiles(args)
 	if diags, ok := err.(hcl.Diagnostics); ok {
 		for _, diag := range diags {
 			logger.WithError(diag).Error()
diff --git a/config/configload/file/file.go b/config/configload/file/file.go
new file mode 100644
index 00000000..438be0b6
--- /dev/null
+++ b/config/configload/file/file.go
@@ -0,0 +1,108 @@
+package file
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+)
+
+type File = struct {
+	Path     string
+	IsDir    bool
+	Children Files
+}
+
+type Files []File
+
+func NewFile(filePath string) File {
+	return File{
+		Path:  filePath,
+		IsDir: false,
+	}
+}
+
+func NewDir(filePath string) (File, error) {
+	children, err := readDir(filePath)
+	return File{
+		Path:     filePath,
+		IsDir:    true,
+		Children: children,
+	}, err
+}
+
+func NewFiles(filesList []string) ([]File, error) {
+	var files []File
+
+	for _, f := range filesList {
+		filePath, err := filepath.Abs(f)
+		if err != nil {
+			return nil, err
+		}
+
+		fileInfo, err := os.Stat(filePath)
+		if err != nil {
+			return nil, err
+		}
+
+		if fileInfo.IsDir() {
+			dir, err := NewDir(filePath)
+			if err != nil {
+				return nil, err
+			}
+
+			files = append(files, dir)
+		} else {
+			files = append(files, NewFile(filePath))
+		}
+	}
+
+	return files, nil
+}
+
+func (f *Files) Refresh() (*Files, error) {
+	var result Files
+	for _, file := range *f {
+		if file.IsDir {
+			dir, err := NewDir(file.Path)
+			if err != nil {
+				return nil, err
+			}
+			result = append(result, dir)
+		} else {
+			result = append(result, file)
+		}
+	}
+	return &result, nil
+}
+
+func (f *Files) AsList() []string {
+	var list []string
+	for _, file := range *f {
+		if file.IsDir {
+			list = append(list, file.Children.AsList()...)
+		} else {
+			list = append(list, file.Path)
+		}
+	}
+	return list
+}
+
+func readDir(filePath string) (Files, error) {
+	// ReadDir ... returns a list ... sorted by filename.
+	listing, err := ioutil.ReadDir(filePath)
+	if err != nil {
+		return nil, err
+	}
+
+	var entries Files
+	for _, item := range listing {
+		if item.IsDir() || filepath.Ext(item.Name()) != ".hcl" {
+			continue
+		}
+
+		filename := filepath.Join(filePath, item.Name())
+		entries = append(entries, NewFile(filename))
+	}
+
+	return entries, nil
+}
diff --git a/config/configload/helper.go b/config/configload/helper.go
index 51909e7e..0bf19021 100644
--- a/config/configload/helper.go
+++ b/config/configload/helper.go
@@ -19,7 +19,7 @@ type helper struct {
 }
 
 // newHelper creates a container with some methods to keep things simple here and there.
-func newHelper(body hcl.Body, src []byte, filename, dirPath string) (*helper, error) {
+func newHelper(body hcl.Body, src []byte, filename string) (*helper, error) {
 	defaultsBlock := &config.DefaultsBlock{}
 	if diags := gohcl.DecodeBody(body, nil, defaultsBlock); diags.HasErrors() {
 		return nil, diags
@@ -32,7 +32,6 @@ func newHelper(body hcl.Body, src []byte, filename, dirPath string) (*helper, er
 		Definitions: &config.Definitions{},
 		Defaults:    defaultsBlock.Defaults,
 		Filename:    filename,
-		Dirpath:     dirPath,
 		Settings:    &defSettings,
 	}
 
diff --git a/config/configload/load.go b/config/configload/load.go
index 7ecef876..eff702f1 100644
--- a/config/configload/load.go
+++ b/config/configload/load.go
@@ -2,8 +2,8 @@ package configload
 
 import (
 	"fmt"
-	"io/ioutil"
 	"os"
+	"path"
 	"path/filepath"
 	"strings"
 
@@ -11,9 +11,11 @@ import (
 	"github.com/hashicorp/hcl/v2/gohcl"
 	"github.com/hashicorp/hcl/v2/hclparse"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
+	"github.com/zclconf/go-cty/cty"
 
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload/collect"
+	configfile "github.com/avenga/couper/config/configload/file"
 	"github.com/avenga/couper/config/parser"
 	"github.com/avenga/couper/config/reader"
 	"github.com/avenga/couper/errors"
@@ -44,9 +46,26 @@ const (
 var defaultsConfig *config.Defaults
 var evalContext *eval.Context
 var envContext *hcl.EvalContext
+var pathBearingAttributesMap map[string]struct{}
 
 func init() {
-	envContext = eval.NewContext(nil, nil).HCLContext()
+	pathBearingAttributes := []string{
+		"bootstrap_file",
+		"ca_file",
+		"document_root",
+		"error_file",
+		"file",
+		"htpasswd_file",
+		"idp_metadata_file",
+		"jwks_url",
+		"key_file",
+		"signing_key_file",
+	}
+
+	pathBearingAttributesMap = make(map[string]struct{})
+	for _, attributeName := range pathBearingAttributes {
+		pathBearingAttributesMap[attributeName] = struct{}{}
+	}
 }
 
 func updateContext(body hcl.Body, srcBytes [][]byte) hcl.Diagnostics {
@@ -63,7 +82,7 @@ func updateContext(body hcl.Body, srcBytes [][]byte) hcl.Diagnostics {
 	return nil
 }
 
-func parseFile(filePath string, srcBytes *[][]byte) (*hcl.File, error) {
+func parseFile(filePath string, srcBytes *[][]byte) (*hclsyntax.Body, error) {
 	src, err := os.ReadFile(filePath)
 	if err != nil {
 		return nil, fmt.Errorf("failed to load configuration: %w", err)
@@ -76,90 +95,45 @@ func parseFile(filePath string, srcBytes *[][]byte) (*hcl.File, error) {
 		return nil, diags
 	}
 
-	return parsed, nil
-}
-
-// SetWorkingDirectory sets the working directory to the given configuration file path.
-func SetWorkingDirectory(configFile string) (string, error) {
-	if err := os.Chdir(filepath.Dir(configFile)); err != nil {
-		return "", err
-	}
-
-	return os.Getwd()
+	return parsed.Body.(*hclsyntax.Body), nil
 }
 
-func LoadFiles(filePath, dirPath string) (*config.Couper, error) {
+func parseFiles(files configfile.Files) ([]*hclsyntax.Body, [][]byte, error) {
 	var (
 		srcBytes     [][]byte
 		parsedBodies []*hclsyntax.Body
-		hasIndexHCL  bool
 	)
 
-	if dirPath != "" {
-		dir, err := filepath.Abs(dirPath)
-		if err != nil {
-			return nil, err
-		}
-
-		dirPath = dir
-
-		// ReadDir ... returns a list ... sorted by filename.
-		listing, err := ioutil.ReadDir(dirPath)
-		if err != nil {
-			return nil, err
-		}
-
-		for _, file := range listing {
-			if file.IsDir() || !strings.HasSuffix(file.Name(), ".hcl") {
-				continue
-			}
-
-			if file.Name() == config.DefaultFilename {
-				hasIndexHCL = true
-				continue
+	for _, file := range files {
+		if file.IsDir {
+			childBodies, bytes, err := parseFiles(file.Children)
+			if err != nil {
+				return nil, bytes, err
 			}
 
-			parsed, err := parseFile(dirPath+"/"+file.Name(), &srcBytes)
+			parsedBodies = append(parsedBodies, childBodies...)
+			srcBytes = append(srcBytes, bytes...)
+		} else {
+			body, err := parseFile(file.Path, &srcBytes)
 			if err != nil {
-				return nil, err
+				return nil, srcBytes, err
 			}
-
-			parsedBodies = append(parsedBodies, parsed.Body.(*hclsyntax.Body))
+			parsedBodies = append(parsedBodies, body)
 		}
 	}
 
-	if hasIndexHCL {
-		parsed, err := parseFile(dirPath+"/"+config.DefaultFilename, &srcBytes)
-		if err != nil {
-			return nil, err
-		}
+	return parsedBodies, srcBytes, nil
+}
 
-		parsedBodies = append([]*hclsyntax.Body{parsed.Body.(*hclsyntax.Body)}, parsedBodies...)
+func LoadFiles(filesList []string) (*config.Couper, error) {
+	configFiles, err := configfile.NewFiles(filesList)
+	if err != nil {
+		return nil, err
 	}
 
-	if filePath != "" {
-		var err error
-		filePath, err = filepath.Abs(filePath)
-		if err != nil {
-			return nil, err
-		}
-
-		parsed, err := parseFile(filePath, &srcBytes)
-		if err != nil {
-			return nil, err
-		}
-
-		parsedBodies = append([]*hclsyntax.Body{parsed.Body.(*hclsyntax.Body)}, parsedBodies...)
-
-		_, err = SetWorkingDirectory(filePath)
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		_, err := SetWorkingDirectory(dirPath + "/dummy.hcl")
-		if err != nil {
-			return nil, err
-		}
+	parsedBodies, srcBytes, err := parseFiles(configFiles)
+	if err != nil {
+		return nil, err
 	}
 
 	if len(srcBytes) == 0 {
@@ -179,6 +153,12 @@ func LoadFiles(filePath, dirPath string) (*config.Couper, error) {
 		return nil, diags
 	}
 
+	for _, body := range parsedBodies {
+		if err := absolutizePaths(body); err != nil {
+			return nil, err
+		}
+	}
+
 	settingsBlock := mergeSettings(parsedBodies)
 
 	definitionsBlock, err := mergeDefinitions(parsedBodies)
@@ -200,14 +180,18 @@ func LoadFiles(filePath, dirPath string) (*config.Couper, error) {
 		Blocks: configBlocks,
 	}
 
-	// Do not make "." from an empty filePath via filepath.Base().
-	// This generates an error when reloading config, if Couper
-	// was started w/o "-f", but w/ "-d" and "-watch" parameters.
-	if filePath != "" {
-		filePath = filepath.Base(filePath)
+	conf, err := LoadConfig(configBody, srcBytes[0], filesList[0])
+	if err != nil {
+		return nil, err
 	}
 
-	return LoadConfig(configBody, srcBytes[0], filePath, dirPath)
+	conf.Files = configFiles
+
+	return conf, nil
+}
+
+func LoadFile(file string) (*config.Couper, error) {
+	return LoadFiles([]string{file})
 }
 
 func LoadBytes(src []byte, filename string) (*config.Couper, error) {
@@ -220,17 +204,17 @@ func LoadBytes(src []byte, filename string) (*config.Couper, error) {
 		return nil, diags
 	}
 
-	return LoadConfig(hclBody, src, filename, "")
+	return LoadConfig(hclBody, src, filename)
 }
 
-func LoadConfig(body hcl.Body, src []byte, filename, dirPath string) (*config.Couper, error) {
+func LoadConfig(body hcl.Body, src []byte, filename string) (*config.Couper, error) {
 	var err error
 
 	if diags := ValidateConfigSchema(body, &config.Couper{}); diags.HasErrors() {
 		return nil, diags
 	}
 
-	helper, err := newHelper(body, src, filename, dirPath)
+	helper, err := newHelper(body, src, filename)
 	if err != nil {
 		return nil, err
 	}
@@ -480,3 +464,57 @@ func checkPermissionMixedConfig(apiConfig *config.API) error {
 
 	return nil
 }
+
+func absolutizePaths(fileBody *hclsyntax.Body) error {
+	visitor := func(node hclsyntax.Node) hcl.Diagnostics {
+		attribute, ok := node.(*hclsyntax.Attribute)
+		if !ok {
+			return nil
+		}
+
+		_, exists := pathBearingAttributesMap[attribute.Name]
+		if !exists {
+			return nil
+		}
+
+		value, diags := attribute.Expr.Value(envContext)
+		if diags.HasErrors() {
+			return diags
+		}
+
+		filePath := value.AsString()
+		basePath := attribute.SrcRange.Filename
+		var absolutePath string
+		if attribute.Name == "jwks_url" {
+			if strings.HasPrefix(filePath, "http://") || strings.HasPrefix(filePath, "https://") {
+				return nil
+			}
+			if strings.HasPrefix(filePath, "file:") {
+				filePath = filePath[5:]
+			}
+			if path.IsAbs(filePath) {
+				return nil
+			}
+
+			absolutePath = "file:" + filepath.ToSlash(path.Join(filepath.Dir(basePath), filePath))
+		} else {
+			if filepath.IsAbs(filePath) {
+				return nil
+			}
+			absolutePath = filepath.Join(filepath.Dir(basePath), filePath)
+		}
+
+		attribute.Expr = &hclsyntax.LiteralValueExpr{
+			Val:      cty.StringVal(absolutePath),
+			SrcRange: attribute.SrcRange,
+		}
+
+		return nil
+	}
+
+	diags := hclsyntax.VisitAll(fileBody, visitor)
+	if diags.HasErrors() {
+		return diags
+	}
+	return nil
+}
diff --git a/config/configload/merge.go b/config/configload/merge.go
index d4d15bc2..516b7779 100644
--- a/config/configload/merge.go
+++ b/config/configload/merge.go
@@ -139,35 +139,9 @@ func mergeServers(bodies []*hclsyntax.Body) (hclsyntax.Blocks, error) {
 				results[serverKey].attributes[name] = attr
 			}
 
-			if attr, ok := results[serverKey].attributes["error_file"]; ok {
-				results[serverKey].attributes["error_file"].Expr = absPath(attr)
-			}
-
 			for _, block := range outerBlock.Body.Blocks {
 				uniqueAPILabels, uniqueSPALabels, uniqueFilesLabels := make(map[string]struct{}), make(map[string]struct{}), make(map[string]struct{})
 
-				for _, name := range []string{"error_file", "document_root"} {
-					if attr, ok := block.Body.Attributes[name]; ok {
-						block.Body.Attributes[name].Expr = absPath(attr)
-					}
-				}
-
-				if block.Type == api || block.Type == endpoint {
-					for _, innerBlock := range block.Body.Blocks {
-						if innerBlock.Type == errorHandler {
-							if attr, ok := innerBlock.Body.Attributes["error_file"]; ok {
-								innerBlock.Body.Attributes["error_file"].Expr = absPath(attr)
-							}
-						} else if innerBlock.Type == endpoint {
-							for _, innerInnerBlock := range innerBlock.Body.Blocks {
-								if innerInnerBlock.Type == backend {
-									absBackendBlock(innerInnerBlock) // Backend block inside a endpoint block in an api block
-								}
-							}
-						}
-					}
-				}
-
 				if block.Type == endpoint {
 					if err := absInBackends(block); err != nil { // Backend block inside a free endpoint block
 						return nil, err
@@ -447,15 +421,6 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 
 					definitionsBlock[innerBlock.Type][innerBlock.Labels[0]] = innerBlock
 
-					// TODO: Do we need this IF around the FOR?
-					if innerBlock.Type == "basic_auth" || innerBlock.Type == "jwt" || innerBlock.Type == "jwt_signing_profile" || innerBlock.Type == "saml" {
-						for _, name := range []string{"htpasswd_file", "key_file", "signing_key_file", "idp_metadata_file"} {
-							if attr, ok := innerBlock.Body.Attributes[name]; ok {
-								innerBlock.Body.Attributes[name].Expr = absPath(attr)
-							}
-						}
-					}
-
 					// Count the "backend" blocks and "backend" attributes to
 					// forbid multiple backend definitions.
 
@@ -463,16 +428,10 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 
 					for _, block := range innerBlock.Body.Blocks {
 						if block.Type == errorHandler {
-							if attr, ok := block.Body.Attributes["error_file"]; ok {
-								block.Body.Attributes["error_file"].Expr = absPath(attr)
-							}
-
 							if err := absInBackends(block); err != nil {
 								return nil, err
 							}
 						} else if block.Type == backend {
-							absBackendBlock(block) // Backend block inside an AC block
-
 							backends++
 						}
 					}
@@ -484,10 +443,6 @@ func mergeDefinitions(bodies []*hclsyntax.Body) (*hclsyntax.Block, error) {
 					if backends > 1 {
 						return nil, newMergeError(errMultipleBackends, innerBlock)
 					}
-
-					if innerBlock.Type == backend {
-						absBackendBlock(innerBlock) // Backend block inside the definitions block
-					}
 				}
 			}
 		}
@@ -562,10 +517,6 @@ func mergeSettings(bodies []*hclsyntax.Body) *hclsyntax.Block {
 		for _, block := range body.Blocks {
 			if block.Type == settings {
 				for name, attr := range block.Body.Attributes {
-					if name == "ca_file" {
-						block.Body.Attributes[name].Expr = absPath(attr)
-					}
-
 					attrs[name] = attr
 				}
 			}
@@ -608,24 +559,8 @@ func absPath(attr *hclsyntax.Attribute) hclsyntax.Expression {
 	}
 }
 
-func absBackendBlock(backendBlock *hclsyntax.Block) {
-	for _, block := range backendBlock.Body.Blocks {
-		if block.Type == "openapi" {
-			if attr, ok := block.Body.Attributes["file"]; ok {
-				block.Body.Attributes["file"].Expr = absPath(attr)
-			}
-		} else if block.Type == oauth2 {
-			for _, innerBlock := range block.Body.Blocks {
-				if innerBlock.Type == backend {
-					absBackendBlock(innerBlock) // Recursive call
-				}
-			}
-		}
-	}
-}
-
 // absInBackends searches for "backend" blocks inside a proxy or request block to
-// be able to rewrite relative pathes. Additionally, the function counts the "backend"
+// count the "backend"
 // blocks and "backend" attributes to forbid multiple backend definitions.
 func absInBackends(block *hclsyntax.Block) error {
 	for _, subBlock := range block.Body.Blocks {
@@ -641,8 +576,6 @@ func absInBackends(block *hclsyntax.Block) error {
 
 		for _, subSubBlock := range subBlock.Body.Blocks {
 			if subSubBlock.Type == backend {
-				absBackendBlock(subSubBlock) // Backend block inside a proxy or request block
-
 				backends++
 			}
 		}
diff --git a/config/couper.go b/config/couper.go
index a613cb5f..32f395d9 100644
--- a/config/couper.go
+++ b/config/couper.go
@@ -2,6 +2,7 @@ package config
 
 import (
 	"context"
+	"github.com/avenga/couper/config/configload/file"
 )
 
 // DefaultFilename defines the default filename for a couper config file.
@@ -11,7 +12,7 @@ const DefaultFilename = "couper.hcl"
 type Couper struct {
 	Context     context.Context
 	Filename    string
-	Dirpath     string
+	Files       file.Files
 	Definitions *Definitions `hcl:"definitions,block"`
 	Servers     Servers      `hcl:"server,block"`
 	Settings    *Settings    `hcl:"settings,block"`
diff --git a/docs/CLI.md b/docs/CLI.md
index 2aa048b6..b074e683 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -18,8 +18,8 @@ Couper is build as binary called `couper` with the following commands:
 
 | Argument             | Default      | Environment                | Description                                                                                                                  |
 |:---------------------|:-------------|:---------------------------|:-----------------------------------------------------------------------------------------------------------------------------|
-| `-f`                 | `couper.hcl` | `COUPER_FILE`              | File path to your Couper configuration file.                                                                                 |
-| `-d`                 | -            | `COUPER_FILE_DIRECTORY`    | Path to your Couper configuration files directory.                                                                           |
+| `-f`                 | `couper.hcl` | `COUPER_FILE`              | Path to a Couper configuration file.                                                                                         |
+| `-d`                 | -            | `COUPER_FILE_DIRECTORY`    | Path to a directory containing Couper configuration files.                                                                   |
 | `-watch`             | `false`      | `COUPER_WATCH`             | Watch for configuration file changes and reload on modifications.                                                            |
 | `-watch-retries`     | `5`          | `COUPER_WATCH_RETRIES`     | Maximum retry count for configuration reloads which could not bind the configured port.                                      |
 | `-watch-retry-delay` | `500ms`      | `COUPER_WATCH_RETRY_DELAY` | Delay duration before next attempt if an error occurs.                                                                       |
@@ -30,22 +30,28 @@ Couper is build as binary called `couper` with the following commands:
 
 **Note:** `log-format`, `log-level` and `log-pretty` also map to [settings](REFERENCE.md#settings-block).
 
-**Note:** Couper can be started with both, `-f` and `-d` arguments. The path of `-f <file>`
-determines the working directory of Couper. If `-d <dir>` argument is given without the `-f <file>`,
-the path of `-d <dir>` is the working directory of Couper. A `couper.hcl` file inside the
-`-d <dir>` is priorized over other files inside the `-d <dir>`, but not over the
-`-f <file>`. Other files in the `-d <dir>` are loaded in alphabetical order. Example:
+**Note:** Couper can be started with multiple `-f <file>` and `-d <dir>` arguments.
+Files in the `-d <dir>` are loaded in alphabetical order. Blocks and attributes
+defined in later files may override those defined earlier. See [Merging](MERGE.md) for details.
+
+**Example:**
 
 ```sh
-|- couper.hcl    # defined via `-f`
-|- couper.d/     # defined via `-d`
-|  |- couper.hcl # step 3: merge configuration into the couper.hcl defined via `-f`
-|  |- a.hcl      # step 2: merge configuration into the couper.d/couper.hcl
-|  |- z.hcl      # step 1: merge configuration into the couper.d/a.hcl
-```
+$ tree
+.
+├── conf
+│   ├── a.hcl
+│   ├── b.hcl
+│   └── c.hcl
+├── devel.hcl
+└── global.hcl
 
-**Note:** When merging configuration files, only one unlabeled `server` or `api` block
-is allowed in each context.
+1 directory, 5 files
+
+$ couper run -f global.hcl -d conf/ -f devel.hcl -log-level=debug
+DEBU[0000] loaded files … […/global.hcl …/conf/a.hcl …/conf/b.hcl …/conf/c.hcl …/devel.hcl] …
+…
+```
 
 ## Run Options
 
diff --git a/main.go b/main.go
index 0aeced15..41425804 100644
--- a/main.go
+++ b/main.go
@@ -8,13 +8,11 @@ import (
 	"context"
 	"flag"
 	"io"
-	"io/ioutil"
 	"net"
 	"net/http"
 	"net/http/pprof"
 	"os"
 	"os/signal"
-	"strings"
 	"syscall"
 	"time"
 
@@ -25,6 +23,7 @@ import (
 	"github.com/avenga/couper/command"
 	"github.com/avenga/couper/config"
 	"github.com/avenga/couper/config/configload"
+	"github.com/avenga/couper/config/configload/file"
 	"github.com/avenga/couper/config/env"
 	"github.com/avenga/couper/config/runtime"
 	"github.com/avenga/couper/eval"
@@ -41,6 +40,10 @@ var (
 	testHook logrus.Hook
 )
 
+type filesList struct {
+	paths []string
+}
+
 func main() {
 	logrus.Exit(realmain(os.Args))
 }
@@ -48,6 +51,7 @@ func main() {
 func realmain(arguments []string) int {
 	args := command.NewArgs(arguments)
 	ctx := context.Background()
+	filesList := filesList{}
 
 	type globalFlags struct {
 		DebugEndpoint       bool          `env:"debug"`
@@ -64,8 +68,8 @@ func realmain(arguments []string) int {
 
 	set := flag.NewFlagSet("global options", flag.ContinueOnError)
 	set.BoolVar(&flags.DebugEndpoint, "debug", false, "-debug")
-	set.StringVar(&flags.FilePath, "f", "", "-f ./my-path/couper.hcl")
-	set.StringVar(&flags.DirPath, "d", "", "-d ./path/to/couper.d")
+	set.Var(&filesList, "f", "-f /path/to/couper.hcl ...")
+	set.Var(&filesList, "d", "-d /path/to/couper.d/ ...")
 	set.BoolVar(&flags.FileWatch, "watch", false, "-watch")
 	set.DurationVar(&flags.FileWatchRetryDelay, "watch-retry-delay", time.Millisecond*500, "-watch-retry-delay 1s")
 	set.IntVar(&flags.FileWatchRetries, "watch-retries", 5, "-watch-retries 10")
@@ -99,21 +103,32 @@ func realmain(arguments []string) int {
 	}
 	env.Decode(&flags)
 
-	if flags.DirPath == "" && flags.FilePath == "" {
-		flags.FilePath = config.DefaultFilename
+	if len(filesList.paths) == 0 {
+		// Get paths from COUPER_FILE and then COUPER_DIRECTORY
+		if flags.FilePath != "" {
+			filesList.paths = append(filesList.paths, flags.FilePath)
+		}
+
+		if flags.DirPath != "" {
+			filesList.paths = append(filesList.paths, flags.DirPath)
+		}
+
+		if len(filesList.paths) == 0 {
+			filesList.paths = append(filesList.paths, config.DefaultFilename)
+		}
 	}
 
 	if cmd == "verify" {
 		log := newLogger(flags.LogFormat, flags.LogLevel, flags.LogPretty)
 
-		err = command.NewCommand(ctx, cmd).Execute(command.Args{flags.FilePath, flags.DirPath}, nil, log)
+		err = command.NewCommand(ctx, cmd).Execute(filesList.paths, nil, log)
 		if err != nil {
 			return 1
 		}
 		return 0
 	}
 
-	confFile, err := configload.LoadFiles(flags.FilePath, flags.DirPath)
+	confFile, err := configload.LoadFiles(filesList.paths)
 	if err != nil {
 		newLogger(flags.LogFormat, flags.LogLevel, flags.LogPretty).WithError(err).Error()
 		return 1
@@ -132,13 +147,6 @@ func realmain(arguments []string) int {
 	}
 	logger := newLogger(confFile.Settings.LogFormat, confFile.Settings.LogLevel, confFile.Settings.LogPretty)
 
-	wd, err := os.Getwd()
-	if err != nil {
-		logger.Error(err)
-		return 1
-	}
-	logger.Infof("working directory: %s", wd)
-
 	if !flags.FileWatch {
 		if err = command.NewCommand(ctx, cmd).Execute(args, confFile, logger); err != nil {
 			logger.WithError(err).Error()
@@ -166,7 +174,7 @@ func realmain(arguments []string) int {
 	sigCh := make(chan os.Signal, 1)
 	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 
-	reloadCh := watchConfigFiles(confFile.Filename, confFile.Dirpath, logger, flags.FileWatchRetries, flags.FileWatchRetryDelay)
+	reloadCh := watchConfigFiles(confFile.Files, logger, flags.FileWatchRetries, flags.FileWatchRetryDelay)
 	for {
 		select {
 		case err = <-errCh:
@@ -203,7 +211,7 @@ func realmain(arguments []string) int {
 			errRetries = 0 // reset
 			logger.Info("reloading couper configuration")
 
-			cf, reloadErr := configload.LoadFiles(confFile.Filename, confFile.Dirpath) // we are at wd, just filename
+			cf, reloadErr := configload.LoadFiles(filesList.paths)
 			if reloadErr != nil {
 				logger.WithError(reloadErr).Error("reload failed")
 				time.Sleep(flags.FileWatchRetryDelay)
@@ -279,25 +287,17 @@ func newLogger(format, level string, pretty bool) *logrus.Entry {
 	return logger.WithField("type", logConf.TypeFieldKey).WithFields(fields)
 }
 
-func getWatchFilesList(filename, dirPath string) (map[string]struct{}, error) {
+func getWatchFilesList(watchFiles file.Files) (map[string]struct{}, error) {
 	files := make(map[string]struct{})
 
-	if filename != "" {
-		files[filename] = struct{}{}
-	}
-	if dirPath != "" {
-		listing, err := ioutil.ReadDir(dirPath)
-		if err != nil {
-			return nil, err
-		}
+	refreshedFiles, err := watchFiles.Refresh()
+	if err != nil {
+		return nil, err
 
-		for _, file := range listing {
-			if file.IsDir() || !strings.HasSuffix(file.Name(), ".hcl") {
-				continue
-			}
+	}
 
-			files[dirPath+"/"+file.Name()] = struct{}{}
-		}
+	for _, file := range refreshedFiles.AsList() {
+		files[file] = struct{}{}
 	}
 
 	return files, nil
@@ -334,7 +334,7 @@ func syncWatchFilesList(new map[string]struct{}, old map[string]time.Time) map[s
 	return synced
 }
 
-func watchConfigFiles(filename, dirPath string, logger logrus.FieldLogger, maxRetries int, retryDelay time.Duration) <-chan struct{} {
+func watchConfigFiles(watchFiles file.Files, logger logrus.FieldLogger, maxRetries int, retryDelay time.Duration) <-chan struct{} {
 	reloadCh := make(chan struct{})
 
 	go func() {
@@ -344,7 +344,7 @@ func watchConfigFiles(filename, dirPath string, logger logrus.FieldLogger, maxRe
 
 		lastChanges := make(map[string]time.Time)
 
-		files, err := getWatchFilesList(filename, dirPath)
+		files, err := getWatchFilesList(watchFiles)
 		if err != nil {
 			logger.Error(err)
 			close(reloadCh)
@@ -360,7 +360,7 @@ func watchConfigFiles(filename, dirPath string, logger logrus.FieldLogger, maxRe
 			<-ticker.C
 
 			// Compare files list
-			filesList, err := getWatchFilesList(filename, dirPath)
+			filesList, err := getWatchFilesList(watchFiles)
 			if err != nil {
 				errorsSeen++
 
@@ -447,3 +447,12 @@ func debugListenAndServe(logEntry *logrus.Entry) {
 		_ = traceSrv.Shutdown(ctx)
 	}()
 }
+
+func (list *filesList) String() string {
+	return ""
+}
+
+func (list *filesList) Set(value string) error {
+	list.paths = append(list.paths, value)
+	return nil
+}


Test patch:
diff --git a/command/run_test.go b/command/run_test.go
index 86f3a2ee..4c3abfd0 100644
--- a/command/run_test.go
+++ b/command/run_test.go
@@ -105,7 +105,7 @@ func TestNewRun(t *testing.T) {
 				return
 			}
 
-			couperFile, err := configload.LoadFiles(filepath.Join(wd, "testdata/settings", tt.file), "")
+			couperFile, err := configload.LoadFile(filepath.Join(wd, "testdata/settings", tt.file))
 			if err != nil {
 				subT.Error(err)
 			}
@@ -192,7 +192,7 @@ func TestAcceptForwarded(t *testing.T) {
 				return
 			}
 
-			couperFile, err := configload.LoadFiles(filepath.Join(wd, "testdata/settings", tt.file), "")
+			couperFile, err := configload.LoadFile(filepath.Join(wd, "testdata/settings", tt.file))
 			if err != nil {
 				subT.Error(err)
 			}
diff --git a/main_test.go b/main_test.go
index 36a8a357..fa26546b 100644
--- a/main_test.go
+++ b/main_test.go
@@ -3,6 +3,7 @@ package main
 import (
 	"fmt"
 	"os"
+	"path/filepath"
 	"regexp"
 	"strings"
 	"testing"
@@ -17,7 +18,6 @@ func Test_realmain(t *testing.T) {
 	testHook = localHook
 
 	base := "server/testdata/settings"
-	wd, _ := os.Getwd()
 
 	tests := []struct {
 		name    string
@@ -29,13 +29,13 @@ func Test_realmain(t *testing.T) {
 		{"verify", []string{"couper", "verify", "-f", base + "/10_couper.hcl"}, nil, `10_couper.hcl:2,3-6: Unsupported block type; Blocks of type \"foo\" are not expected here.`, 1},
 		{"verify w/o server", []string{"couper", "verify", "-f", base + "/11_couper.hcl"}, nil, `configuration error: missing 'server' block"`, 1},
 		{"verify unique map-attr keys", []string{"couper", "verify", "-f", base + "/12_couper.hcl"}, nil, `12_couper.hcl:5,28-8,6: key in an attribute must be unique: 'test-key'; Key must be unique for test-key.`, 1},
-		{"common log format & info log level /wo file", []string{"couper", "run"}, nil, `level=error msg="failed to load configuration: open .*/couper.hcl: no such file or directory" build=dev`, 1},
-		{"common log format via env /wo file", []string{"couper", "run", "-log-format", "json"}, []string{"COUPER_LOG_FORMAT=common"}, `level=error msg="failed to load configuration: open .*/couper.hcl: no such file or directory" build=dev`, 1},
-		{"info log level via env /wo file", []string{"couper", "run", "-log-level", "debug"}, []string{"COUPER_LOG_LEVEL=info"}, `level=error msg="failed to load configuration: open .*/couper.hcl: no such file or directory" build=dev`, 1},
-		{"json log format /wo file", []string{"couper", "run", "-log-format", "json"}, nil, `{"build":"dev","level":"error","message":"failed to load configuration: open .*/couper.hcl: no such file or directory"`, 1},
-		{"json log format via env /wo file", []string{"couper", "run"}, []string{"COUPER_LOG_FORMAT=json"}, `{"build":"dev","level":"error","message":"failed to load configuration: open .*/couper.hcl: no such file or directory"`, 1},
-		{"non-existent log level /wo file", []string{"couper", "run", "-log-level", "test"}, nil, `level=error msg="failed to load configuration: open .*/couper.hcl: no such file or directory" build=dev`, 1},
-		{"non-existent log level via env /wo file", []string{"couper", "run"}, []string{"COUPER_LOG_LEVEL=test"}, `level=error msg="failed to load configuration: open .*/couper.hcl: no such file or directory" build=dev`, 1},
+		{"common log format & info log level /wo file", []string{"couper", "run"}, nil, `level=error msg="stat %s/couper.hcl: no such file or directory" build=dev`, 1},
+		{"common log format via env /wo file", []string{"couper", "run", "-log-format", "json"}, []string{"COUPER_LOG_FORMAT=common"}, `level=error msg="stat %s/couper.hcl: no such file or directory" build=dev`, 1},
+		{"info log level via env /wo file", []string{"couper", "run", "-log-level", "debug"}, []string{"COUPER_LOG_LEVEL=info"}, `level=error msg="stat %s/couper.hcl: no such file or directory" build=dev`, 1},
+		{"json log format /wo file", []string{"couper", "run", "-log-format", "json"}, nil, `{"build":"dev","level":"error","message":"stat %s/couper.hcl: no such file or directory"`, 1},
+		{"json log format via env /wo file", []string{"couper", "run"}, []string{"COUPER_LOG_FORMAT=json"}, `{"build":"dev","level":"error","message":"stat %s/couper.hcl: no such file or directory"`, 1},
+		{"non-existent log level /wo file", []string{"couper", "run", "-log-level", "test"}, nil, `level=error msg="stat %s/couper.hcl: no such file or directory" build=dev`, 1},
+		{"non-existent log level via env /wo file", []string{"couper", "run"}, []string{"COUPER_LOG_LEVEL=test"}, `level=error msg="stat %s/couper.hcl: no such file or directory" build=dev`, 1},
 		{"common log format & info log level /w file", []string{"couper", "run", "-f", base + "/log_default.hcl"}, nil, `level=error msg="configuration error: missing 'server' block" build=dev`, 1},
 		{"common log format via env /w file", []string{"couper", "run", "-f", base + "/log_altered.hcl"}, []string{"COUPER_LOG_FORMAT=common"}, `level=error msg="configuration error: missing 'server' block" build=dev`, 1},
 		{"info log level via env /w file", []string{"couper", "run", "-f", base + "/log_default.hcl"}, []string{"COUPER_LOG_LEVEL=info"}, `level=error msg="configuration error: missing 'server' block" build=dev`, 1},
@@ -43,6 +43,7 @@ func Test_realmain(t *testing.T) {
 		{"json log format via env /w file", []string{"couper", "run", "-f", base + "/log_default.hcl"}, []string{"COUPER_LOG_FORMAT=json"}, `{"build":"dev","level":"error","message":"configuration error: missing 'server' block"`, 1},
 		{"non-existent log level via env /w file", []string{"couper", "run", "-f", base + "/log_altered.hcl"}, []string{"COUPER_LOG_LEVEL=test"}, `level=error msg="configuration error: missing 'server' block" build=dev`, 1},
 		{"-f w/o file", []string{"couper", "run", "-f"}, nil, `level=error msg="flag needs an argument: -f" build=dev`, 1},
+		{"path from env", []string{"couper", "run", "-f", base + "/path_from_env.hcl"}, nil, `level=error msg="configuration error: token: jwt key: read error: open %s/public.pem: no such file or directory" build=dev`, 1},
 		{"undefined AC", []string{"couper", "run", "-f", base + "/04_couper.hcl"}, nil, `level=error msg="accessControl is not defined: undefined" build=dev`, 1},
 		{"empty string in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/13_couper.hcl"}, nil, `level=error msg="%s/13_couper.hcl:3,5-27: method contains invalid character(s); " build=dev`, 1},
 		{"invalid method in allowed_methods in endpoint", []string{"couper", "run", "-f", base + "/14_couper.hcl"}, nil, `level=error msg="%s/14_couper.hcl:3,5-35: method contains invalid character(s); " build=dev`, 1},
@@ -63,30 +64,34 @@ func Test_realmain(t *testing.T) {
 
 			currWD, _ := os.Getwd()
 
-			entry, _ := localHook.LastEntry().String()
-			//println(entry)
+			entry := localHook.LastEntry()
+			if entry == nil {
+				subT.Error("missing log entry")
+				return
+			}
+			entryStr, _ := entry.String()
+			if tt.wantLog == "" && subT.Failed() {
+				println(entryStr)
+			}
 
 			wantLog := tt.wantLog
-			if strings.Contains(wantLog, `msg="%s/`) {
+			if strings.Contains(wantLog, `msg="%s/`) || strings.Contains(wantLog, `open %s/`) {
+				wantLog = fmt.Sprintf(wantLog, filepath.Join(currWD, base))
+			} else if strings.Contains(wantLog, `stat %s/`) {
 				wantLog = fmt.Sprintf(wantLog, currWD)
 			}
 
-			if wantLog != "" && !strings.Contains(entry, wantLog) {
+			if wantLog != "" && !strings.Contains(entryStr, wantLog) {
 				if strings.Contains(wantLog, `failed to load configuration:`) {
 					re := regexp.MustCompile(wantLog)
 
-					if !re.MatchString(entry) {
-						subT.Errorf("\nwant:\t%s\ngot:\t%s\n", wantLog, entry)
+					if !re.MatchString(entryStr) {
+						subT.Errorf("\nwant:\t%s\ngot:\t%s\n", wantLog, entryStr)
 					}
 				} else {
-					subT.Errorf("\nwant:\t%s\ngot:\t%s\n", wantLog, entry)
+					subT.Errorf("\nwant:\t%s\ngot:\t%s\n", wantLog, entryStr)
 				}
 			}
-
-			err := os.Chdir(wd)
-			if err != nil {
-				subT.Error(err)
-			}
 		})
 	}
 }
diff --git a/server/http_endpoints_test.go b/server/http_endpoints_test.go
index 3367282d..f08f06cf 100644
--- a/server/http_endpoints_test.go
+++ b/server/http_endpoints_test.go
@@ -806,7 +806,7 @@ func TestEndpointCyclicSequence(t *testing.T) {
 			defer cleanup(func() {}, test.New(t))
 
 			path := filepath.Join(testdataPath, testcase.file)
-			_, err := configload.LoadFiles(path, "")
+			_, err := configload.LoadFile(path)
 
 			diags, ok := err.(*hcl.Diagnostic)
 			if !ok {
diff --git a/server/http_error_handler_test.go b/server/http_error_handler_test.go
index 0812761a..4077365c 100644
--- a/server/http_error_handler_test.go
+++ b/server/http_error_handler_test.go
@@ -115,7 +115,7 @@ func TestAccessControl_ErrorHandler_BasicAuth_Wildcard(t *testing.T) {
 }
 
 func TestAccessControl_ErrorHandler_Configuration_Error(t *testing.T) {
-	_, err := configload.LoadFiles("testdata/integration/error_handler/03_couper.hcl", "")
+	_, err := configload.LoadFile("testdata/integration/error_handler/03_couper.hcl")
 
 	expectedMsg := "03_couper.hcl:24,12-12: Missing required argument; The argument \"grant_type\" is required, but no definition was found."
 
@@ -334,7 +334,7 @@ func TestAccessControl_ErrorHandler_Permissions(t *testing.T) {
 }
 
 func Test_Panic_Multi_EH(t *testing.T) {
-	_, err := configload.LoadFiles("testdata/settings/16_couper.hcl", "")
+	_, err := configload.LoadFile("testdata/settings/16_couper.hcl")
 
 	expectedMsg := `: duplicate error type registration: "*"; `
 
diff --git a/server/http_integration_test.go b/server/http_integration_test.go
index d9b532be..0875c578 100644
--- a/server/http_integration_test.go
+++ b/server/http_integration_test.go
@@ -85,21 +85,14 @@ func teardown() {
 }
 
 func newCouper(file string, helper *test.Helper) (func(), *logrustest.Hook) {
-	couperConfig, err := configload.LoadFiles(filepath.Join(testWorkingDir, file), "")
+	couperConfig, err := configload.LoadFile(filepath.Join(testWorkingDir, file))
 	helper.Must(err)
 
 	return newCouperWithConfig(couperConfig, helper)
 }
 
 func newCouperMultiFiles(file, dir string, helper *test.Helper) (func(), *logrustest.Hook) {
-	filePath, dirPath := file, dir
-	if filePath != "" {
-		filePath = filepath.Join(testWorkingDir, file)
-	}
-	if dirPath != "" {
-		dirPath = filepath.Join(testWorkingDir, dir)
-	}
-	couperConfig, err := configload.LoadFiles(filePath, dirPath)
+	couperConfig, err := configload.LoadFiles([]string{file, dir})
 	helper.Must(err)
 
 	return newCouperWithConfig(couperConfig, helper)
@@ -3513,7 +3506,7 @@ func TestJWKsMaxStale(t *testing.T) {
 
 func TestJWTAccessControlSourceConfig(t *testing.T) {
 	helper := test.New(t)
-	couperConfig, err := configload.LoadFiles("testdata/integration/config/05_couper.hcl", "")
+	couperConfig, err := configload.LoadFile("testdata/integration/config/05_couper.hcl")
 	helper.Must(err)
 
 	log, _ := logrustest.NewNullLogger()
@@ -3521,7 +3514,7 @@ func TestJWTAccessControlSourceConfig(t *testing.T) {
 
 	expectedMsg := "configuration error: invalid-source: token source is invalid"
 
-	err = command.NewRun(ctx).Execute([]string{couperConfig.Filename}, couperConfig, log.WithContext(ctx))
+	err = command.NewRun(ctx).Execute(nil, couperConfig, log.WithContext(ctx))
 	logErr, _ := err.(errors.GoError)
 	if logErr == nil {
 		t.Error("logErr should not be nil")
@@ -4207,6 +4200,7 @@ func TestFunction_to_number_errors(t *testing.T) {
 	if werr != nil {
 		t.Fatal(werr)
 	}
+	wd = wd + "/testdata/integration/functions"
 
 	type testCase struct {
 		name   string
@@ -4250,6 +4244,7 @@ func TestFunction_length_errors(t *testing.T) {
 	if werr != nil {
 		t.Fatal(werr)
 	}
+	wd = wd + "/testdata/integration/functions"
 
 	type testCase struct {
 		name   string
@@ -4292,6 +4287,7 @@ func TestFunction_lookup_errors(t *testing.T) {
 	if werr != nil {
 		t.Fatal(werr)
 	}
+	wd = wd + "/testdata/integration/functions"
 
 	type testCase struct {
 		name   string
diff --git a/server/multi_files_test.go b/server/multi_files_test.go
index 9d76d290..2acf6063 100644
--- a/server/multi_files_test.go
+++ b/server/multi_files_test.go
@@ -190,7 +190,7 @@ func TestMultiFiles_MultipleBackends(t *testing.T) {
 		{"testdata/multi/backends/errors/api_ep.hcl"},
 	} {
 		t.Run(tc.config, func(st *testing.T) {
-			_, err := configload.LoadFiles(filepath.Join(testWorkingDir, tc.config), "")
+			_, err := configload.LoadFile(filepath.Join(testWorkingDir, tc.config))
 
 			if !strings.Contains(err.Error(), "Multiple definitions of backend are not allowed.") {
 				st.Errorf("Unexpected error: %s", err.Error())
@@ -234,7 +234,7 @@ func Test_MultipleLabels(t *testing.T) {
 		},
 	} {
 		t.Run(tc.name, func(st *testing.T) {
-			_, err := configload.LoadFiles(filepath.Join(testWorkingDir, tc.configPath), "")
+			_, err := configload.LoadFile(filepath.Join(testWorkingDir, tc.configPath))
 
 			if (err != nil && tc.expError == "") ||
 				(tc.expError != "" && (err == nil || !strings.Contains(err.Error(), tc.expError))) {
diff --git a/server/testdata/multi/definitions/couper.d/aaa.hcl b/server/testdata/multi/definitions/couper.d/aaa.hcl
index 8742dbf2..6d10b971 100644
--- a/server/testdata/multi/definitions/couper.d/aaa.hcl
+++ b/server/testdata/multi/definitions/couper.d/aaa.hcl
@@ -10,8 +10,7 @@ server {
 
 definitions {
   backend "Backend" {
-    origin = "${env.COUPER_TEST_BACKEND_ADDR}"
-    path = "/small"
+    origin = "https://example.org"
   }
 
   basic_auth "BA" {
diff --git a/server/testdata/multi/definitions/couper.d/couper.hcl b/server/testdata/multi/definitions/couper.d/couper.hcl
index 6d1606a3..102c9050 100644
--- a/server/testdata/multi/definitions/couper.d/couper.hcl
+++ b/server/testdata/multi/definitions/couper.d/couper.hcl
@@ -1,6 +1,7 @@
 definitions {
   backend "Backend" {
-    origin = "https://example.org"
+    origin = "${env.COUPER_TEST_BACKEND_ADDR}"
+    path = "/small"
   }
 
   backend "Added" {
diff --git a/server/testdata/multi/server/couper.d/aaa.hcl b/server/testdata/multi/server/couper.d/aaa.hcl
index f0276b40..37a28f2c 100644
--- a/server/testdata/multi/server/couper.d/aaa.hcl
+++ b/server/testdata/multi/server/couper.d/aaa.hcl
@@ -1,6 +1,6 @@
 server {
   files {
-    document_root = "./www"
+    document_root = "./web"
     custom_log_fields = {
       from = "final"
     }
@@ -8,7 +8,7 @@ server {
 
   endpoint "/free" {
     response {
-      status = 403
+      status = 401
     }
   }
 }
diff --git a/server/testdata/multi/server/couper.d/couper.hcl b/server/testdata/multi/server/couper.d/couper.hcl
index 17c4042d..a48a7367 100644
--- a/server/testdata/multi/server/couper.d/couper.hcl
+++ b/server/testdata/multi/server/couper.d/couper.hcl
@@ -6,7 +6,7 @@ server {
   }
 
   files {
-    document_root = "./web"
+    document_root = "./www"
     custom_log_fields = {
       from = "override"
     }
@@ -14,7 +14,7 @@ server {
 
   endpoint "/free" {
     response {
-      status = 401
+      status = 403
     }
   }
 
diff --git a/server/testdata/multi/settings/couper.d/aaa.hcl b/server/testdata/multi/settings/couper.d/aaa.hcl
index 056dc99b..5b35c56a 100644
--- a/server/testdata/multi/settings/couper.d/aaa.hcl
+++ b/server/testdata/multi/settings/couper.d/aaa.hcl
@@ -5,7 +5,7 @@ defaults {
 }
 
 settings {
-  health_path = env.ZZZ
+  health_path = "/def"
 
   request_id_client_header  = "Req-Id-Cl-Hdr"
   request_id_backend_header = "Req-Id-Be-Hdr"
diff --git a/server/testdata/multi/settings/couper.d/couper.hcl b/server/testdata/multi/settings/couper.d/couper.hcl
index ba5aed34..9451772d 100644
--- a/server/testdata/multi/settings/couper.d/couper.hcl
+++ b/server/testdata/multi/settings/couper.d/couper.hcl
@@ -5,6 +5,6 @@ defaults {
 }
 
 settings {
-  health_path = "/def"
-  request_id_backend_header = "R-I-B-H"
+  health_path = env.ZZZ
+  request_id_backend_header = "Req-Id-Be-Hdr"
 }
diff --git a/server/testdata/settings/path_from_env.hcl b/server/testdata/settings/path_from_env.hcl
new file mode 100644
index 00000000..dc9e0e0c
--- /dev/null
+++ b/server/testdata/settings/path_from_env.hcl
@@ -0,0 +1,16 @@
+server {
+  access_control = ["token"]
+}
+
+definitions {
+  jwt "token" {
+    key_file = env.KEY
+    signature_algorithm = "HS256"
+  }
+}
+
+defaults {
+  environment_variables = {
+    KEY = "public.pem"
+  }
+}