Repository: gruntwork-io/terragrunt (language: go)

Issue:
Take into account user `provider_installation` settings when using Terragrunt Provider Cache
## Describe the feature

1. If there is a `provider_installation` block in the use CLI configuration `.terraformrc`, `terraform` returns errors `Error: At most one provider_installation block can be specified`, this is because Terragrunt Provider Cache adds another block, so we need to merge them into one.
2. If the user CLI configuration `.terraformrc` has `network_mirror` settings, this should be taken into account when configuring **Terragrunt Provider Cache**. That is, **Terragrunt Provider Cache** server should make requests not to the remote register, but to the one specified in the `network_mirror` settings.



Discussion / hints:

Fix patch:
diff --git a/cli/provider_cache.go b/cli/provider_cache.go
index 9f10d53ce8..64c13d60f5 100644
--- a/cli/provider_cache.go
+++ b/cli/provider_cache.go
@@ -18,8 +18,8 @@ import (
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/gruntwork-io/terragrunt/terraform/cache"
-	"github.com/gruntwork-io/terragrunt/terraform/cache/controllers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
 	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
 	"github.com/gruntwork-io/terragrunt/terraform/getproviders"
 	"github.com/gruntwork-io/terragrunt/util"
@@ -29,6 +29,13 @@ import (
 const (
 	// The paths to the automatically generated local CLI configs
 	localCLIFilename = ".terraformrc"
+
+	// The status returned when making a request to the caching provider.
+	// It is needed to prevent further loading of providers by terraform, and at the same time make sure that the request was processed successfully.
+	cacheProviderHTTPStatusCode = http.StatusLocked
+
+	// Authentication type on the Terragrunt Provider Cache server.
+	apiKeyAuth = "x-api-key"
 )
 
 var (
@@ -46,11 +53,13 @@ var (
 	//    │ provider registry for registry.terraform.io/snowflake-labs/snowflake: 423
 	//    │ Locked
 	//    ╵
-	HTTPStatusCacheProviderReg = regexp.MustCompile(`(?smi)` + strconv.Itoa(controllers.HTTPStatusCacheProvider) + `.*` + http.StatusText(controllers.HTTPStatusCacheProvider))
+	HTTPStatusCacheProviderReg = regexp.MustCompile(`(?smi)` + strconv.Itoa(cacheProviderHTTPStatusCode) + `.*` + http.StatusText(cacheProviderHTTPStatusCode))
 )
 
 type ProviderCache struct {
 	*cache.Server
+	cliCfg          *cliconfig.Config
+	providerService *services.ProviderService
 }
 
 func InitProviderCacheServer(opts *options.TerragruntOptions) (*ProviderCache, error) {
@@ -73,24 +82,56 @@ func InitProviderCacheServer(opts *options.TerragruntOptions) (*ProviderCache, e
 		opts.ProviderCacheToken = uuid.New().String()
 	}
 	// Currently, the cache server only supports the `x-api-key` token.
-	if !strings.HasPrefix(strings.ToLower(opts.ProviderCacheToken), handlers.AuthorizationApiKeyHeaderName+":") {
-		opts.ProviderCacheToken = fmt.Sprintf("%s:%s", handlers.AuthorizationApiKeyHeaderName, opts.ProviderCacheToken)
+	if !strings.HasPrefix(strings.ToLower(opts.ProviderCacheToken), apiKeyAuth+":") {
+		opts.ProviderCacheToken = fmt.Sprintf("%s:%s", apiKeyAuth, opts.ProviderCacheToken)
 	}
 
 	userProviderDir, err := cliconfig.UserProviderDir()
 	if err != nil {
 		return nil, err
 	}
+	providerService := services.NewProviderService(opts.ProviderCacheDir, userProviderDir)
+
+	cliCfg, err := cliconfig.LoadUserConfig()
+	if err != nil {
+		return nil, err
+	}
+
+	var (
+		providerHandlers []handlers.ProviderHandler
+		excludeAddrs     []string
+	)
+
+	for _, registryName := range opts.ProviderCacheRegistryNames {
+		excludeAddrs = append(excludeAddrs, fmt.Sprintf("%s/*/*", registryName))
+	}
+
+	for _, method := range cliCfg.ProviderInstallation.Methods {
+		switch method := method.(type) {
+		case *cliconfig.ProviderInstallationFilesystemMirror:
+			providerHandlers = append(providerHandlers, handlers.NewProviderFilesystemMirrorHandler(providerService, cacheProviderHTTPStatusCode, method))
+		case *cliconfig.ProviderInstallationNetworkMirror:
+			providerHandlers = append(providerHandlers, handlers.NewProviderNetworkMirrorHandler(providerService, cacheProviderHTTPStatusCode, method))
+		case *cliconfig.ProviderInstallationDirect:
+			providerHandlers = append(providerHandlers, handlers.NewProviderDirectHandler(providerService, cacheProviderHTTPStatusCode, method))
+		}
+		method.AppendExclude(excludeAddrs)
+	}
+	providerHandlers = append(providerHandlers, handlers.NewProviderDirectHandler(providerService, cacheProviderHTTPStatusCode, new(cliconfig.ProviderInstallationDirect)))
 
 	cache := cache.NewServer(
 		cache.WithHostname(opts.ProviderCacheHostname),
 		cache.WithPort(opts.ProviderCachePort),
 		cache.WithToken(opts.ProviderCacheToken),
-		cache.WithUserProviderDir(userProviderDir),
-		cache.WithProviderCacheDir(opts.ProviderCacheDir),
+		cache.WithServices(providerService),
+		cache.WithProviderHandlers(providerHandlers...),
 	)
 
-	return &ProviderCache{Server: cache}, nil
+	return &ProviderCache{
+		Server:          cache,
+		cliCfg:          cliCfg,
+		providerService: providerService,
+	}, nil
 }
 
 func (cache *ProviderCache) TerraformCommandHook(ctx context.Context, opts *options.TerragruntOptions, args []string) (*shell.CmdOutput, error) {
@@ -117,11 +158,11 @@ func (cache *ProviderCache) TerraformCommandHook(ctx context.Context, opts *opti
 	// Before each init, we warm up the global cache to ensure that all necessary providers are cached.
 	// To do this we are using 'terraform providers lock' to force TF to request all the providers from our TG cache, and that's how we know what providers TF needs, and can load them into the cache.
 	// It's low cost operation, because it does not cache the same provider twice, but only new previously non-existent providers.
-	if err := runTerraformCommand(ctx, opts, args, env); err != nil {
-		return nil, err
+	if output, err := runTerraformCommand(ctx, opts, args, env); err != nil {
+		return output, err
 	}
 
-	caches := cache.Provider.WaitForCacheReady(cacheRequestID)
+	caches := cache.providerService.WaitForCacheReady(cacheRequestID)
 	if err := getproviders.UpdateLockfile(ctx, opts.WorkingDir, caches); err != nil {
 		return nil, err
 	}
@@ -168,10 +209,7 @@ func (cache *ProviderCache) TerraformCommandHook(ctx context.Context, opts *opti
 // 1. If `cacheRequestID` is set, `terraform init` does _not_ use the provider cache directory, the cache server creates a cache for requested providers and returns HTTP status 423. Since for each module we create the CLI config, using `cacheRequestID` we have the opportunity later retrieve from the cache server exactly those cached providers that were requested by `terraform init` using this configuration.
 // 2. If `cacheRequestID` is empty, 'terraform init` uses provider cache directory, the cache server acts as a proxy.
 func (cache *ProviderCache) createLocalCLIConfig(opts *options.TerragruntOptions, filename string, cacheRequestID string) error {
-	cfg, err := cliconfig.LoadUserConfig()
-	if err != nil {
-		return err
-	}
+	cfg := cache.cliCfg.Clone()
 	cfg.PluginCacheDir = ""
 
 	var providerInstallationIncludes []string
@@ -180,24 +218,22 @@ func (cache *ProviderCache) createLocalCLIConfig(opts *options.TerragruntOptions
 		providerInstallationIncludes = append(providerInstallationIncludes, fmt.Sprintf("%s/*/*", registryName))
 
 		cfg.AddHost(registryName, map[string]string{
-			"providers.v1": fmt.Sprintf("%s/%s/%s/", cache.ProviderURL(), cacheRequestID, registryName),
+			"providers.v1": fmt.Sprintf("%s/%s/%s/", cache.ProviderController.URL(), cacheRequestID, registryName),
 			// Since Terragrunt Provider Cache only caches providers, we need to route module requests to the original registry.
 			"modules.v1": fmt.Sprintf("https://%s/v1/modules", registryName),
 		})
 	}
 
-	if cacheRequestID != "" {
-		cfg.SetProviderInstallation(
-			nil,
-			cliconfig.NewProviderInstallationDirect(nil, nil),
-		)
-	} else {
-		cfg.SetProviderInstallation(
+	if cacheRequestID == "" {
+		cfg.AddProviderInstallationMethods(
 			cliconfig.NewProviderInstallationFilesystemMirror(opts.ProviderCacheDir, providerInstallationIncludes, nil),
-			cliconfig.NewProviderInstallationDirect(providerInstallationIncludes, nil),
 		)
 	}
 
+	cfg.AddProviderInstallationMethods(
+		cliconfig.NewProviderInstallationDirect(nil, nil),
+	)
+
 	if cfgDir := filepath.Dir(filename); !util.FileExists(cfgDir) {
 		if err := os.MkdirAll(cfgDir, os.ModePerm); err != nil {
 			return errors.WithStackTrace(err)
@@ -207,7 +243,7 @@ func (cache *ProviderCache) createLocalCLIConfig(opts *options.TerragruntOptions
 	return cfg.Save(filename)
 }
 
-func runTerraformCommand(ctx context.Context, opts *options.TerragruntOptions, args []string, envs map[string]string) error {
+func runTerraformCommand(ctx context.Context, opts *options.TerragruntOptions, args []string, envs map[string]string) (*shell.CmdOutput, error) {
 	// We use custom writer in order to trap the log from `terraform providers lock -platform=provider-cache` command, which terraform considers an error, but to us a success.
 	errWriter := util.NewTrapWriter(opts.ErrWriter, HTTPStatusCacheProviderReg)
 
@@ -227,11 +263,11 @@ func runTerraformCommand(ctx context.Context, opts *options.TerragruntOptions, a
 	}
 
 	// If the Terraform error matches `HTTPStatusCacheProviderReg` we ignore it and hide the log from users, otherwise we process the error as is.
-	if err := shell.RunTerraformCommand(ctx, cloneOpts, cloneOpts.TerraformCliArgs...); err != nil && len(errWriter.Msgs()) == 0 {
-		return err
+	if output, err := shell.RunTerraformCommandWithOutput(ctx, cloneOpts, cloneOpts.TerraformCliArgs...); err != nil && len(errWriter.Msgs()) == 0 {
+		return output, err
 	}
 
-	return nil
+	return nil, nil
 }
 
 // providerCacheEnvironment returns TF_* name/value ENVs, which we use to force terraform processes to make requests through our cache server (proxy) instead of making direct requests to the origin servers.
diff --git a/main.go b/main.go
index b72765aa2f..a256199852 100644
--- a/main.go
+++ b/main.go
@@ -2,11 +2,13 @@ package main
 
 import (
 	"os"
+	"strings"
 
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/cli"
 	"github.com/gruntwork-io/terragrunt/shell"
 	"github.com/gruntwork-io/terragrunt/util"
+	"github.com/hashicorp/go-multierror"
 )
 
 // The main entrypoint for Terragrunt
@@ -24,7 +26,7 @@ func checkForErrorsAndExit(err error) {
 	if err == nil {
 		os.Exit(0)
 	} else {
-		util.GlobalFallbackLogEntry.Debugf(errors.PrintErrorWithStackTrace(err))
+		util.GlobalFallbackLogEntry.Debugf(printErrorWithStackTrace(err))
 		util.GlobalFallbackLogEntry.Errorf(err.Error())
 
 		// exit with the underlying error code
@@ -40,3 +42,14 @@ func checkForErrorsAndExit(err error) {
 	}
 
 }
+
+func printErrorWithStackTrace(err error) string {
+	if err, ok := err.(*multierror.Error); ok {
+		var errsStr []string
+		for _, err := range err.Errors {
+			errsStr = append(errsStr, errors.PrintErrorWithStackTrace(err))
+		}
+		return strings.Join(errsStr, "\n")
+	}
+	return errors.PrintErrorWithStackTrace(err)
+}
diff --git a/pkg/log/exported.go b/pkg/log/exported.go
index f3eafd3562..65fc203994 100644
--- a/pkg/log/exported.go
+++ b/pkg/log/exported.go
@@ -131,3 +131,9 @@ func Logf(level logrus.Level, format string, args ...interface{}) {
 		logger.Log(level, fmt.Sprintf(format, args...))
 	}
 }
+
+// WithField allocates a new entry and adds a field to it.
+func WithField(key string, value interface{}) *logrus.Entry {
+	return logger.WithField(key, value)
+
+}
diff --git a/terraform/cache/config.go b/terraform/cache/config.go
index dddffabf35..e101836521 100644
--- a/terraform/cache/config.go
+++ b/terraform/cache/config.go
@@ -4,6 +4,9 @@ import (
 	"net"
 	"strconv"
 	"time"
+
+	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
 )
 
 const (
@@ -38,16 +41,16 @@ func WithToken(token string) Option {
 	}
 }
 
-func WithProviderCacheDir(cacheDir string) Option {
+func WithServices(services ...services.Service) Option {
 	return func(cfg Config) Config {
-		cfg.providerCacheDir = cacheDir
+		cfg.services = services
 		return cfg
 	}
 }
 
-func WithUserProviderDir(userProviderDir string) Option {
+func WithProviderHandlers(handlers ...handlers.ProviderHandler) Option {
 	return func(cfg Config) Config {
-		cfg.userProviderDir = userProviderDir
+		cfg.providerHandlers = handlers
 		return cfg
 	}
 }
@@ -58,8 +61,8 @@ type Config struct {
 	token           string
 	shutdownTimeout time.Duration
 
-	userProviderDir  string
-	providerCacheDir string
+	services         []services.Service
+	providerHandlers []handlers.ProviderHandler
 }
 
 func NewConfig(opts ...Option) *Config {
diff --git a/terraform/cache/controllers/discovery.go b/terraform/cache/controllers/discovery.go
index 3190f042b3..89f77937d9 100644
--- a/terraform/cache/controllers/discovery.go
+++ b/terraform/cache/controllers/discovery.go
@@ -17,16 +17,18 @@ type Endpointer interface {
 }
 
 type DiscoveryController struct {
+	*router.Router
+
 	Endpointers []Endpointer
 }
 
 // Register implements router.Controller.Register
 func (controller *DiscoveryController) Register(router *router.Router) {
-	router = router.Group(discoveryPath)
+	controller.Router = router.Group(discoveryPath)
 
 	// Discovery Process
 	// https://developer.hashicorp.com/terraform/internals/remote-service-discovery#discovery-process
-	router.GET("/terraform.json", controller.terraformAction)
+	controller.GET("/terraform.json", controller.terraformAction)
 }
 
 func (controller *DiscoveryController) terraformAction(ctx echo.Context) error {
diff --git a/terraform/cache/controllers/downloader.go b/terraform/cache/controllers/downloader.go
index 9e32febe14..b95ac3a747 100644
--- a/terraform/cache/controllers/downloader.go
+++ b/terraform/cache/controllers/downloader.go
@@ -1,43 +1,31 @@
 package controllers
 
 import (
+	"net/http"
 	"net/url"
-	"path"
 
-	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
-	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
 	"github.com/labstack/echo/v4"
 )
 
 const (
-	downloadPath         = "/downloads"
-	downloadProviderPath = "/provider"
+	downloadPath = "/downloads"
 )
 
 type DownloaderController struct {
-	ReverseProxy    *handlers.ReverseProxy
-	ProviderService *services.ProviderService
+	*router.Router
 
-	basePath string
-}
-
-// ProviderProxyURL returns URL for using as a cache to download remote archives through this controller with caching
-func (controller *DownloaderController) ProviderProxyURL() *url.URL {
-	cacheURL := *controller.ReverseProxy.ServerURL
-	cacheURL.Path = path.Join(cacheURL.Path, controller.basePath, downloadProviderPath)
-	return &cacheURL
+	ProviderHandlers []handlers.ProviderHandler
 }
 
 // Register implements router.Controller.Register
 func (controller *DownloaderController) Register(router *router.Router) {
-	router = router.Group(downloadPath)
-	controller.basePath = router.Prefix()
+	controller.Router = router.Group(downloadPath)
 
 	// Download provider
-	router.GET(downloadProviderPath+"/:remote_host/:remote_path", controller.downloadProviderAction)
+	controller.GET("/:remote_host/:remote_path", controller.downloadProviderAction)
 }
 
 func (controller *DownloaderController) downloadProviderAction(ctx echo.Context) error {
@@ -46,19 +34,23 @@ func (controller *DownloaderController) downloadProviderAction(ctx echo.Context)
 		remotePath = ctx.Param("remote_path")
 	)
 
-	downloadURL := &url.URL{
+	downloadURL := url.URL{
 		Scheme: "https",
 		Host:   remoteHost,
 		Path:   "/" + remotePath,
 	}
-	provider := models.NewProviderFromDownloadURL(downloadURL.String())
+	provider := &models.Provider{
+		ResponseBody: &models.ResponseBody{
+			DownloadURL: downloadURL.String(),
+		},
+	}
 
-	if cache := controller.ProviderService.GetProviderCache(provider); cache != nil {
-		if filename := cache.Filename(); filename != "" {
-			log.Debugf("Using cached provider %s", cache.Provider)
-			return ctx.File(filename)
+	for _, handler := range controller.ProviderHandlers {
+		if handler.CanHandleProvider(provider) {
+			if err := handler.Download(ctx, provider); err == nil {
+				break
+			}
 		}
 	}
-
-	return controller.ReverseProxy.NewRequest(ctx, downloadURL)
+	return ctx.NoContent(http.StatusNotFound)
 }
diff --git a/terraform/cache/controllers/provider.go b/terraform/cache/controllers/provider.go
index e824737292..52143b2333 100644
--- a/terraform/cache/controllers/provider.go
+++ b/terraform/cache/controllers/provider.go
@@ -1,18 +1,11 @@
 package controllers
 
 import (
-	"encoding/json"
 	"net/http"
-	"net/http/httputil"
-	"net/url"
-	"path"
-	"strconv"
 
 	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
-	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
-	"github.com/gruntwork-io/terragrunt/terraform/getproviders"
 	"github.com/labstack/echo/v4"
 )
 
@@ -21,55 +14,29 @@ const (
 	providerName = "providers.v1"
 	// URL path to this controller
 	providerPath = "/providers"
-
-	// The platform name used to request provider caching, e.g `terraform providers block -platform=cache_provider`
-	PlatformNameCacheProvider = "cache_provider"
-	// The status returned when making a request to the caching provider.
-	// It is needed to prevent further loading of providers by terraform, and at the same time make sure that the request was processed successfully.
-	HTTPStatusCacheProvider = http.StatusLocked
-
-	// Provider's assets consist of three files/URLs: zipped binary, hashes and signature
-	ProviderDownloadURLName         ProviderURLName = "download_url"
-	ProviderSHASumsURLName          ProviderURLName = "shasums_url"
-	ProviderSHASumsSignatureURLName ProviderURLName = "shasums_signature_url"
-)
-
-var (
-	// ProviderURLNames contains urls that must be modified to forward terraform requests through this server.
-	ProviderURLNames = []ProviderURLName{
-		ProviderDownloadURLName,
-		ProviderSHASumsURLName,
-		ProviderSHASumsSignatureURLName,
-	}
 )
 
-type ProviderURLName string
-
 type ProviderController struct {
-	Authorization   *handlers.Authorization
-	ReverseProxy    *handlers.ReverseProxy
-	Downloader      *DownloaderController
-	ProviderService *services.ProviderService
+	*router.Router
 
-	basePath string
-}
+	Server               http.Server
+	DownloaderController router.Controller
 
-func (controller *ProviderController) Path() string {
-	return controller.basePath
+	AuthMiddleware   echo.MiddlewareFunc
+	ProviderHandlers []handlers.ProviderHandler
 }
 
 // Endpoints implements controllers.Endpointer.Endpoints
 func (controller *ProviderController) Endpoints() map[string]any {
-	return map[string]any{providerName: controller.basePath}
+	return map[string]any{providerName: controller.URL().Path}
 }
 
 // Register implements router.Controller.Register
 func (controller *ProviderController) Register(router *router.Router) {
-	router = router.Group(providerPath)
-	controller.basePath = router.Prefix()
+	controller.Router = router.Group(providerPath)
 
-	if controller.Authorization != nil {
-		router.Use(controller.Authorization.MiddlewareFunc())
+	if controller.AuthMiddleware != nil {
+		controller.Use(controller.AuthMiddleware)
 	}
 
 	// Api should be compliant with the Terraform Registry Protocol for providers.
@@ -77,14 +44,14 @@ func (controller *ProviderController) Register(router *router.Router) {
 
 	// Get All Versions for a Single Provider
 	// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-versions-for-a-single-provider
-	router.GET("/:cache_request_id/:registry_name/:namespace/:name/versions", controller.findVersionsAction)
+	controller.GET("/:cache_request_id/:registry_name/:namespace/:name/versions", controller.getVersionsAction)
 
-	// Get All Platforms for a Single Version
-	// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-platforms-for-a-single-version
-	router.GET("/:cache_request_id/:registry_name/:namespace/:name/:version/download/:os/:arch", controller.findPlatformsAction)
+	// Get a Platform
+	// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-a-platform
+	controller.GET("/:cache_request_id/:registry_name/:namespace/:name/:version/download/:os/:arch", controller.getPlatformsAction)
 }
 
-func (controller *ProviderController) findVersionsAction(ctx echo.Context) error {
+func (controller *ProviderController) getVersionsAction(ctx echo.Context) error {
 	var (
 		registryName = ctx.Param("registry_name")
 		namespace    = ctx.Param("namespace")
@@ -96,10 +63,18 @@ func (controller *ProviderController) findVersionsAction(ctx echo.Context) error
 		Namespace:    namespace,
 		Name:         name,
 	}
-	return controller.ReverseProxy.NewRequest(ctx, provider.VersionURL())
+
+	for _, handler := range controller.ProviderHandlers {
+		if handler.CanHandleProvider(provider) {
+			if err := handler.GetVersions(ctx, provider); err == nil {
+				break
+			}
+		}
+	}
+	return ctx.NoContent(http.StatusNotFound)
 }
 
-func (controller *ProviderController) findPlatformsAction(ctx echo.Context) error {
+func (controller *ProviderController) getPlatformsAction(ctx echo.Context) (er error) {
 	var (
 		registryName   = ctx.Param("registry_name")
 		namespace      = ctx.Param("namespace")
@@ -108,8 +83,6 @@ func (controller *ProviderController) findPlatformsAction(ctx echo.Context) erro
 		os             = ctx.Param("os")
 		arch           = ctx.Param("arch")
 		cacheRequestID = ctx.Param("cache_request_id")
-
-		proxyURL = controller.Downloader.ProviderProxyURL()
 	)
 
 	provider := &models.Provider{
@@ -121,55 +94,12 @@ func (controller *ProviderController) findPlatformsAction(ctx echo.Context) erro
 		Arch:         arch,
 	}
 
-	return controller.ReverseProxy.
-		WithRewrite(func(req *httputil.ProxyRequest) {
-			// Remove all encoding parameters, otherwise we will not be able to modify the body response without decoding.
-			req.Out.Header.Del("Accept-Encoding")
-		}).
-		WithModifyResponse(func(resp *http.Response) error {
-			var body map[string]json.RawMessage
-
-			if cacheRequestID != "" {
-				var responseBody = new(getproviders.Package)
-
-				if err := handlers.DecodeJSONBody(resp, responseBody); err != nil {
-					return err
-				}
-				provider.Package = responseBody
-
-				controller.ProviderService.CacheProvider(ctx.Request().Context(), cacheRequestID, provider)
-				return ctx.NoContent(HTTPStatusCacheProvider)
+	for _, handler := range controller.ProviderHandlers {
+		if handler.CanHandleProvider(provider) {
+			if err := handler.GetPlatform(ctx, provider, controller.DownloaderController, cacheRequestID); err == nil {
+				break
 			}
-
-			return handlers.ModifyJSONBody(resp, &body, func() error {
-				for _, name := range ProviderURLNames {
-					linkBytes, ok := body[string(name)]
-					if !ok || linkBytes == nil {
-						continue
-					}
-					link := string(linkBytes)
-
-					link, err := strconv.Unquote(link)
-					if err != nil {
-						return err
-					}
-
-					linkURL, err := url.Parse(link)
-					if err != nil {
-						return err
-					}
-
-					// Modify link to htpp://{localhost_host}/downloads/provider/{remote_host}/{remote_path}
-					linkURL.Path = path.Join(proxyURL.Path, linkURL.Host, linkURL.Path)
-					linkURL.Scheme = proxyURL.Scheme
-					linkURL.Host = proxyURL.Host
-
-					link = strconv.Quote(linkURL.String())
-					body[string(name)] = []byte(link)
-				}
-
-				return nil
-			})
-		}).
-		NewRequest(ctx, provider.PlatformURL())
+		}
+	}
+	return ctx.NoContent(http.StatusNotFound)
 }
diff --git a/terraform/cache/handlers/helper.go b/terraform/cache/handlers/helper.go
index ffa358f72f..94b3413f7f 100644
--- a/terraform/cache/handlers/helper.go
+++ b/terraform/cache/handlers/helper.go
@@ -2,19 +2,56 @@ package handlers
 
 import (
 	"bytes"
+	"compress/gzip"
 	"encoding/json"
 	"io"
 	"net/http"
+	"strconv"
 )
 
+func ResponseReader(resp *http.Response) (io.ReadCloser, error) {
+	// Check that the server actually sent compressed data
+	switch resp.Header.Get("Content-Encoding") {
+	case "gzip":
+		reader, err := gzip.NewReader(resp.Body)
+		if err != nil {
+			return nil, err
+		}
+
+		resp.Header.Del("Content-Encoding")
+		resp.Header.Del("Content-Length")
+		resp.ContentLength = -1
+		resp.Uncompressed = true
+
+		return reader, nil
+	default:
+		return resp.Body, nil
+	}
+}
+
+func ResponseBuffer(resp *http.Response) (*bytes.Buffer, error) {
+	reader, err := ResponseReader(resp)
+	if err != nil {
+		return nil, err
+	}
+	defer reader.Close() //nolint:errcheck
+
+	buffer := new(bytes.Buffer)
+
+	if _, err := buffer.ReadFrom(reader); err != nil {
+		return nil, err
+	}
+
+	return buffer, nil
+}
+
 func DecodeJSONBody(resp *http.Response, value any) error {
 	if resp.StatusCode != http.StatusOK {
 		return nil
 	}
 
-	buffer := new(bytes.Buffer)
-
-	if _, err := buffer.ReadFrom(resp.Body); err != nil {
+	buffer, err := ResponseBuffer(resp)
+	if err != nil {
 		return err
 	}
 
@@ -22,6 +59,8 @@ func DecodeJSONBody(resp *http.Response, value any) error {
 	if err := decoder.Decode(value); err != nil {
 		return err
 	}
+
+	resp.Body = io.NopCloser(buffer)
 	return nil
 }
 
@@ -30,9 +69,8 @@ func ModifyJSONBody(resp *http.Response, value any, fn func() error) error {
 		return nil
 	}
 
-	buffer := new(bytes.Buffer)
-
-	if _, err := buffer.ReadFrom(resp.Body); err != nil {
+	buffer, err := ResponseBuffer(resp)
+	if err != nil {
 		return err
 	}
 
@@ -56,6 +94,7 @@ func ModifyJSONBody(resp *http.Response, value any, fn func() error) error {
 
 	resp.Body = io.NopCloser(buffer)
 	resp.ContentLength = int64(buffer.Len())
+	resp.Header.Set("Content-Length", strconv.Itoa(buffer.Len()))
 
 	return nil
 }
diff --git a/terraform/cache/handlers/provider.go b/terraform/cache/handlers/provider.go
new file mode 100644
index 0000000000..158351fe8d
--- /dev/null
+++ b/terraform/cache/handlers/provider.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
+	"github.com/labstack/echo/v4"
+)
+
+var availablePlatforms []*models.Platform = []*models.Platform{
+	{OS: "solaris", Arch: "amd64"},
+	{OS: "openbsd", Arch: "386"},
+	{OS: "openbsd", Arch: "arm"},
+	{OS: "openbsd", Arch: "amd64"},
+	{OS: "freebsd", Arch: "386"},
+	{OS: "freebsd", Arch: "arm"},
+	{OS: "freebsd", Arch: "amd64"},
+	{OS: "linux", Arch: "386"},
+	{OS: "linux", Arch: "arm"},
+	{OS: "linux", Arch: "arm64"},
+	{OS: "linux", Arch: "amd64"},
+	{OS: "darwin", Arch: "amd64"},
+	{OS: "darwin", Arch: "arm64"},
+	{OS: "windows", Arch: "386"},
+	{OS: "windows", Arch: "amd64"},
+}
+
+type ProviderHandler interface {
+	// CanHandleProvider returns true if the given provider can be handled by this handler.
+	CanHandleProvider(provider *models.Provider) bool
+
+	// GetVersions serves a request that returns all versions for a single provider.
+	GetVersions(ctx echo.Context, provider *models.Provider) error
+
+	// GetPlatform serves a request that returns a provider for a specific platform.
+	GetPlatform(ctx echo.Context, provider *models.Provider, downloaderController router.Controller, cacheRequestID string) error
+
+	// Download serves a request to download the target file.
+	Download(ctx echo.Context, provider *models.Provider) error
+}
+
+type CommonProviderHandler struct {
+	// includeProviders and excludeProviders are sets of provider matching patterns that together define which providers are eligible to be potentially installed from the corresponding Source.
+	includeProviders models.Providers
+	excludeProviders models.Providers
+}
+
+func NewCommonProviderHandler(includes, excludes *[]string) *CommonProviderHandler {
+	var includeProviders, excludeProviders models.Providers
+
+	if includes != nil {
+		includeProviders = models.ParseProviders(*includes...)
+	}
+
+	if excludes != nil {
+		excludeProviders = models.ParseProviders(*excludes...)
+	}
+
+	return &CommonProviderHandler{
+		includeProviders: includeProviders,
+		excludeProviders: excludeProviders,
+	}
+}
+
+// CanHandleProvider implements ProviderHandler.CanHandleProvider
+func (handler *CommonProviderHandler) CanHandleProvider(provider *models.Provider) bool {
+	switch {
+	case handler.excludeProviders.Find(provider) != nil:
+		return false
+	case len(handler.includeProviders) > 0:
+		return handler.includeProviders.Find(provider) != nil
+	default:
+		return true
+	}
+}
diff --git a/terraform/cache/handlers/provider_direct.go b/terraform/cache/handlers/provider_direct.go
new file mode 100644
index 0000000000..cfac92b787
--- /dev/null
+++ b/terraform/cache/handlers/provider_direct.go
@@ -0,0 +1,154 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
+	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
+	"github.com/labstack/echo/v4"
+)
+
+const (
+	// Provider's assets consist of three files/URLs: zipped binary, hashes and signature
+	ProviderDownloadURLName         providerURLName = "download_url"
+	ProviderSHASumsURLName          providerURLName = "shasums_url"
+	ProviderSHASumsSignatureURLName providerURLName = "shasums_signature_url"
+)
+
+var (
+	// providerURLNames contains urls that must be modified to forward terraform requests through this server.
+	providerURLNames = []providerURLName{
+		ProviderDownloadURLName,
+		ProviderSHASumsURLName,
+		ProviderSHASumsSignatureURLName,
+	}
+)
+
+type providerURLName string
+
+type ProviderDirectHandler struct {
+	*CommonProviderHandler
+
+	*ReverseProxy
+	providerService             *services.ProviderService
+	cacheProviderHTTPStatusCode int
+}
+
+func NewProviderDirectHandler(providerService *services.ProviderService, cacheProviderHTTPStatusCode int, method *cliconfig.ProviderInstallationDirect) ProviderHandler {
+	return &ProviderDirectHandler{
+		CommonProviderHandler:       NewCommonProviderHandler(method.Include, method.Exclude),
+		ReverseProxy:                &ReverseProxy{},
+		providerService:             providerService,
+		cacheProviderHTTPStatusCode: cacheProviderHTTPStatusCode,
+	}
+}
+
+func (handler *ProviderDirectHandler) String() string {
+	return "direct handler "
+}
+
+// GetVersions implements ProviderHandler.GetVersions
+func (handler *ProviderDirectHandler) GetVersions(ctx echo.Context, provider *models.Provider) error {
+	// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-versions-for-a-single-provider
+	reqURL := &url.URL{
+		Scheme: "https",
+		Host:   provider.RegistryName,
+		Path:   path.Join("/v1/providers", provider.Namespace, provider.Name, "versions"),
+	}
+
+	return handler.ReverseProxy.NewRequest(ctx, reqURL)
+}
+
+// GetPlatform implements ProviderHandler.GetPlatform
+func (handler *ProviderDirectHandler) GetPlatform(ctx echo.Context, provider *models.Provider, downloaderController router.Controller, cacheRequestID string) error {
+	return handler.ReverseProxy.
+		WithModifyResponse(func(resp *http.Response) error {
+			// start caching and return 423 status
+			if cacheRequestID != "" {
+				var body = new(models.ResponseBody)
+
+				if err := DecodeJSONBody(resp, body); err != nil {
+					return err
+				}
+
+				provider.ResponseBody = body
+
+				handler.providerService.CacheProvider(ctx.Request().Context(), cacheRequestID, provider)
+				return ctx.NoContent(handler.cacheProviderHTTPStatusCode)
+			}
+
+			// act as a proxy
+			return proxyGetVersionsRequest(resp, downloaderController)
+		}).
+		NewRequest(ctx, handler.platformURL(provider))
+
+}
+
+// Download implements ProviderHandler.Download
+func (handler *ProviderDirectHandler) Download(ctx echo.Context, provider *models.Provider) error {
+	if cache := handler.providerService.GetProviderCache(provider); cache != nil {
+		if path := cache.ArchivePath(); path != "" {
+			log.Debugf("Download cached provider %s", cache.Provider)
+			return ctx.File(path)
+		}
+	}
+
+	downloadURL, err := url.Parse(provider.DownloadURL)
+	if err != nil {
+		return err
+	}
+	return handler.ReverseProxy.NewRequest(ctx, downloadURL)
+
+}
+
+// platformURL returns the URL used to query the all platforms for a single version.
+// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-a-platform
+func (handler *ProviderDirectHandler) platformURL(provider *models.Provider) *url.URL {
+	return &url.URL{
+		Scheme: "https",
+		Host:   provider.RegistryName,
+		Path:   path.Join("/v1/providers", provider.Namespace, provider.Name, provider.Version, "download", provider.OS, provider.Arch),
+	}
+}
+
+// proxyGetVersionsRequest proxies the request to the remote registry and modifies the response to redirect the download URLs to the local server.
+func proxyGetVersionsRequest(resp *http.Response, downloaderController router.Controller) error {
+	var data map[string]json.RawMessage
+
+	return ModifyJSONBody(resp, &data, func() error {
+		for _, name := range providerURLNames {
+			linkBytes, ok := data[string(name)]
+			if !ok || linkBytes == nil {
+				continue
+			}
+			link := string(linkBytes)
+
+			link, err := strconv.Unquote(link)
+			if err != nil {
+				return err
+			}
+
+			linkURL, err := url.Parse(link)
+			if err != nil {
+				return err
+			}
+
+			// Modify link to htpp://{localhost_host}/downloads/provider/{remote_host}/{remote_path}
+			linkURL.Path = path.Join(downloaderController.URL().Path, linkURL.Host, linkURL.Path)
+			linkURL.Scheme = downloaderController.URL().Scheme
+			linkURL.Host = downloaderController.URL().Host
+
+			link = strconv.Quote(linkURL.String())
+			data[string(name)] = []byte(link)
+		}
+
+		return nil
+	})
+}
diff --git a/terraform/cache/handlers/provider_filesystem_mirror.go b/terraform/cache/handlers/provider_filesystem_mirror.go
new file mode 100644
index 0000000000..94bbb69eb9
--- /dev/null
+++ b/terraform/cache/handlers/provider_filesystem_mirror.go
@@ -0,0 +1,117 @@
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+	"os"
+	"path/filepath"
+
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
+	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
+	"github.com/labstack/echo/v4"
+)
+
+type ProviderFilesystemMirrorHandler struct {
+	*CommonProviderHandler
+
+	providerService             *services.ProviderService
+	cacheProviderHTTPStatusCode int
+	filesystemMirrorPath        string
+}
+
+func NewProviderFilesystemMirrorHandler(providerService *services.ProviderService, cacheProviderHTTPStatusCode int, method *cliconfig.ProviderInstallationFilesystemMirror) ProviderHandler {
+	return &ProviderFilesystemMirrorHandler{
+		CommonProviderHandler:       NewCommonProviderHandler(method.Include, method.Exclude),
+		providerService:             providerService,
+		cacheProviderHTTPStatusCode: cacheProviderHTTPStatusCode,
+		filesystemMirrorPath:        method.Path,
+	}
+}
+
+func (handler *ProviderFilesystemMirrorHandler) String() string {
+	return "filesystem mirror handler "
+}
+
+// GetVersions implements ProviderHandler.GetVersions
+func (handler *ProviderFilesystemMirrorHandler) GetVersions(ctx echo.Context, provider *models.Provider) error {
+	var mirrorData struct {
+		Versions map[string]struct{} `json:"versions"`
+	}
+
+	filename := filepath.Join(provider.RegistryName, provider.Namespace, provider.Name, "index.json")
+	if err := handler.readMirrorData(filename, &mirrorData); err != nil {
+		return err
+	}
+
+	versions := struct {
+		ID       string           `json:"id"`
+		Versions []models.Version `json:"versions"`
+	}{
+		ID: provider.Address(),
+	}
+
+	for version := range mirrorData.Versions {
+		versions.Versions = append(versions.Versions, models.Version{
+			Version:   version,
+			Platforms: availablePlatforms,
+		})
+	}
+
+	return ctx.JSON(http.StatusOK, versions)
+}
+
+// GetPlatform implements ProviderHandler.GetPlatform
+func (handler *ProviderFilesystemMirrorHandler) GetPlatform(ctx echo.Context, provider *models.Provider, downloaderController router.Controller, cacheRequestID string) error {
+	if cacheRequestID == "" {
+		// it is impossible to return all platform properties from the filesystem mirror, return 404 status
+		return ctx.NoContent(http.StatusNotFound)
+	}
+
+	var mirrorData struct {
+		Archives map[string]struct {
+			URL    string   `json:"url"`
+			Hashes []string `json:"hashes"`
+		} `json:"archives"`
+	}
+
+	filename := filepath.Join(provider.RegistryName, provider.Namespace, provider.Name, provider.Version+".json")
+	if err := handler.readMirrorData(filename, &mirrorData); err != nil {
+		return err
+	}
+
+	if archive, ok := mirrorData.Archives[provider.Platform()]; ok {
+		provider.ResponseBody = &models.ResponseBody{
+			Filename:    filepath.Base(archive.URL),
+			DownloadURL: filepath.Join(handler.filesystemMirrorPath, provider.RegistryName, provider.Namespace, provider.Name, archive.URL),
+		}
+	} else {
+		return ctx.NoContent(http.StatusNotFound)
+	}
+
+	// start caching and return 423 status
+	handler.providerService.CacheProvider(ctx.Request().Context(), cacheRequestID, provider)
+	return ctx.NoContent(handler.cacheProviderHTTPStatusCode)
+}
+
+// Download implements ProviderHandler.Download
+func (handler *ProviderFilesystemMirrorHandler) Download(ctx echo.Context, provider *models.Provider) error {
+	return ctx.NoContent(http.StatusNotImplemented)
+}
+
+func (handler *ProviderFilesystemMirrorHandler) readMirrorData(filename string, value any) error {
+	filename = filepath.Join(handler.filesystemMirrorPath, filename)
+
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	if err := json.Unmarshal(data, value); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
diff --git a/terraform/cache/handlers/provider_network_mirror.go b/terraform/cache/handlers/provider_network_mirror.go
new file mode 100644
index 0000000000..2cecb8fcfa
--- /dev/null
+++ b/terraform/cache/handlers/provider_network_mirror.go
@@ -0,0 +1,122 @@
+package handlers
+
+import (
+	"fmt"
+	"net/http"
+	"path"
+	"path/filepath"
+	"strings"
+
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/models"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
+	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
+	"github.com/labstack/echo/v4"
+)
+
+type ProviderNetworkMirrorHandler struct {
+	*CommonProviderHandler
+
+	*http.Client
+	providerService             *services.ProviderService
+	cacheProviderHTTPStatusCode int
+	networkMirrorURL            string
+}
+
+func NewProviderNetworkMirrorHandler(providerService *services.ProviderService, cacheProviderHTTPStatusCode int, networkMirror *cliconfig.ProviderInstallationNetworkMirror) ProviderHandler {
+	return &ProviderNetworkMirrorHandler{
+		CommonProviderHandler:       NewCommonProviderHandler(networkMirror.Include, networkMirror.Exclude),
+		Client:                      &http.Client{},
+		providerService:             providerService,
+		cacheProviderHTTPStatusCode: cacheProviderHTTPStatusCode,
+		networkMirrorURL:            networkMirror.URL,
+	}
+}
+
+func (handler *ProviderNetworkMirrorHandler) String() string {
+	return "network mirror handler "
+}
+
+// GetVersions implements ProviderHandler.GetVersions
+func (handler *ProviderNetworkMirrorHandler) GetVersions(ctx echo.Context, provider *models.Provider) error {
+	var mirrorData struct {
+		Versions map[string]struct{} `json:"versions"`
+	}
+
+	reqPath := path.Join(provider.RegistryName, provider.Namespace, provider.Name, "index.json")
+	if err := handler.request(ctx, http.MethodGet, reqPath, &mirrorData); err != nil {
+		return err
+	}
+
+	versions := struct {
+		ID       string           `json:"id"`
+		Versions []models.Version `json:"versions"`
+	}{
+		ID: provider.Address(),
+	}
+
+	for version := range mirrorData.Versions {
+		versions.Versions = append(versions.Versions, models.Version{
+			Version:   version,
+			Platforms: availablePlatforms,
+		})
+	}
+
+	return ctx.JSON(http.StatusOK, versions)
+}
+
+// GetPlatform implements ProviderHandler.GetPlatform
+func (handler *ProviderNetworkMirrorHandler) GetPlatform(ctx echo.Context, provider *models.Provider, downloaderController router.Controller, cacheRequestID string) error {
+	if cacheRequestID == "" {
+		// it is impossible to return all platform properties from the network mirror, return 404 status
+		return ctx.NoContent(http.StatusNotFound)
+	}
+
+	var mirrorData struct {
+		Archives map[string]struct {
+			URL    string   `json:"url"`
+			Hashes []string `json:"hashes"`
+		} `json:"archives"`
+	}
+
+	reqPath := path.Join(provider.RegistryName, provider.Namespace, provider.Name, provider.Version+".json")
+	if err := handler.request(ctx, http.MethodGet, reqPath, &mirrorData); err != nil {
+		return err
+	}
+
+	if archive, ok := mirrorData.Archives[provider.Platform()]; ok {
+		provider.ResponseBody = &models.ResponseBody{
+			Filename:    filepath.Base(archive.URL),
+			DownloadURL: archive.URL,
+		}
+	} else {
+		return ctx.NoContent(http.StatusNotFound)
+	}
+
+	// start caching and return 423 status
+	handler.providerService.CacheProvider(ctx.Request().Context(), cacheRequestID, provider)
+	return ctx.NoContent(handler.cacheProviderHTTPStatusCode)
+}
+
+// Download implements ProviderHandler.Download
+func (handler *ProviderNetworkMirrorHandler) Download(ctx echo.Context, provider *models.Provider) error {
+	return ctx.NoContent(http.StatusNotImplemented)
+}
+
+func (handler *ProviderNetworkMirrorHandler) request(ctx echo.Context, method, reqPath string, value any) error {
+	reqURL := fmt.Sprintf("%s/%s", strings.TrimRight(handler.networkMirrorURL, "/"), reqPath)
+
+	req, err := http.NewRequestWithContext(ctx.Request().Context(), method, reqURL, nil)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	resp, err := handler.Client.Do(req)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+	defer resp.Body.Close() //nolint:errcheck
+
+	return DecodeJSONBody(resp, value)
+}
diff --git a/terraform/cache/handlers/reverse_proxy.go b/terraform/cache/handlers/reverse_proxy.go
index 73d51ebd6f..b1c6615d83 100644
--- a/terraform/cache/handlers/reverse_proxy.go
+++ b/terraform/cache/handlers/reverse_proxy.go
@@ -27,7 +27,7 @@ func (reverseProxy ReverseProxy) WithModifyResponse(fn func(resp *http.Response)
 	return &reverseProxy
 }
 
-func (reverseProxy *ReverseProxy) NewRequest(ctx echo.Context, targetURL *url.URL) error {
+func (reverseProxy *ReverseProxy) NewRequest(ctx echo.Context, targetURL *url.URL) (er error) {
 	proxy := &httputil.ReverseProxy{
 		Rewrite: func(req *httputil.ProxyRequest) {
 			req.Out.Host = targetURL.Host
diff --git a/terraform/cache/handlers/auth.go b/terraform/cache/middleware/key_auth.go
similarity index 73%
rename from terraform/cache/handlers/auth.go
rename to terraform/cache/middleware/key_auth.go
index 43ef3aff3f..3108b92467 100644
--- a/terraform/cache/handlers/auth.go
+++ b/terraform/cache/middleware/key_auth.go
@@ -1,4 +1,4 @@
-package handlers
+package middleware
 
 import (
 	"github.com/labstack/echo/v4"
@@ -6,15 +6,13 @@ import (
 	"github.com/pkg/errors"
 )
 
-const AuthorizationApiKeyHeaderName = "x-api-key"
-
 type Authorization struct {
 	Token string
 }
 
 // To enhance security, we use token-based authentication to connect to the cache server in order to prevent unauthorized connections from third-party applications.
 // Currently, the cache server only supports `x-api-key` token, the value of which can be any text.
-func (auth *Authorization) Auth(bearerToken string, ctx echo.Context) (bool, error) {
+func (auth *Authorization) Validator(bearerToken string, ctx echo.Context) (bool, error) {
 	if bearerToken != auth.Token {
 		return false, errors.Errorf("Authorization: token either expired or inexistent")
 	}
@@ -22,11 +20,16 @@ func (auth *Authorization) Auth(bearerToken string, ctx echo.Context) (bool, err
 	return true, nil
 }
 
-func (auth *Authorization) MiddlewareFunc() echo.MiddlewareFunc {
+// KeyAuth returns an KeyAuth middleware.
+func KeyAuth(token string) echo.MiddlewareFunc {
+	auth := Authorization{
+		Token: token,
+	}
+
 	return middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
 		Skipper:    middleware.DefaultSkipper,
 		KeyLookup:  "header:" + echo.HeaderAuthorization,
 		AuthScheme: "Bearer",
-		Validator:  auth.Auth,
+		Validator:  auth.Validator,
 	})
 }
diff --git a/terraform/cache/middleware/logger.go b/terraform/cache/middleware/logger.go
new file mode 100644
index 0000000000..14a53476a3
--- /dev/null
+++ b/terraform/cache/middleware/logger.go
@@ -0,0 +1,25 @@
+package middleware
+
+import (
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
+)
+
+func Logger() echo.MiddlewareFunc {
+	return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
+		LogStatus:   true,
+		LogURI:      true,
+		LogError:    true,
+		HandleError: true, // forwards error to the global error handler, so it can decide appropriate status code
+		LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
+			log := log.WithField("uri", v.URI).WithField("status", v.Status)
+			if v.Error != nil {
+				log.Errorf("Cache server was unable to process the received request, %s", v.Error.Error())
+			} else {
+				log.Tracef("Cache server received request")
+			}
+			return nil
+		},
+	})
+}
diff --git a/terraform/cache/middleware/recover.go b/terraform/cache/middleware/recover.go
new file mode 100644
index 0000000000..be127d1a83
--- /dev/null
+++ b/terraform/cache/middleware/recover.go
@@ -0,0 +1,19 @@
+package middleware
+
+import (
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/labstack/echo/v4"
+)
+
+func Recover() echo.MiddlewareFunc {
+	return func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(ctx echo.Context) (er error) {
+			defer errors.Recover(func(err error) {
+				log.Debugf(errors.PrintErrorWithStackTrace(err))
+				er = err
+			})
+			return next(ctx)
+		}
+	}
+}
diff --git a/terraform/cache/models/provider.go b/terraform/cache/models/provider.go
index a42b25fa10..82d9260779 100644
--- a/terraform/cache/models/provider.go
+++ b/terraform/cache/models/provider.go
@@ -2,15 +2,80 @@ package models
 
 import (
 	"fmt"
-	"net/url"
 	"path"
-
-	"github.com/gruntwork-io/terragrunt/terraform/getproviders"
+	"strings"
 )
 
+type Providers []*Provider
+
+func ParseProviders(strs ...string) Providers {
+	var prvoiders Providers
+
+	for _, str := range strs {
+		if provider := ParseProvider(str); provider != nil {
+			prvoiders = append(prvoiders, provider)
+		}
+	}
+	return prvoiders
+}
+
+func (providers Providers) Find(target *Provider) *Provider {
+	for _, provider := range providers {
+		if provider.Match(target) {
+			return provider
+		}
+	}
+	return nil
+}
+
+// SigningKey represents a key used to sign packages from a registry, along with an optional trust signature from the registry operator. These are both in ASCII armored OpenPGP format.
+type SigningKey struct {
+	ASCIIArmor     string `json:"ascii_armor"`
+	TrustSignature string `json:"trust_signature"`
+}
+
+type SigningKeyList struct {
+	GPGPublicKeys []*SigningKey `json:"gpg_public_keys"`
+}
+
+func (list SigningKeyList) Keys() map[string]string {
+	keys := make(map[string]string)
+
+	for _, key := range list.GPGPublicKeys {
+		keys[key.ASCIIArmor] = key.TrustSignature
+	}
+	return keys
+}
+
+type Version struct {
+	Version   string      `json:"version"`
+	Protocols []string    `json:"protocols"`
+	Platforms []*Platform `json:"platforms"`
+}
+
+type Platform struct {
+	OS   string `json:"os"`
+	Arch string `json:"arch"`
+}
+
+// ResponseBody represents the details of the Terraform provider received from a registry.
+type ResponseBody struct {
+	Platform
+
+	Protocols []string `json:"protocols,omitempty"`
+	Filename  string   `json:"filename"`
+
+	DownloadURL            string `json:"download_url"`
+	SHA256SumsURL          string `json:"shasums_url,omitempty"`
+	SHA256SumsSignatureURL string `json:"shasums_signature_url,omitempty"`
+
+	SHA256Sum   string         `json:"shasum,omitempty"`
+	SigningKeys SigningKeyList `json:"signing_keys,omitempty"`
+}
+
 // Provider represents the details of the Terraform provider.
 type Provider struct {
-	*getproviders.Package
+	*ResponseBody
 
 	RegistryName string
 	Namespace    string
@@ -20,32 +85,31 @@ type Provider struct {
 	Arch         string
 }
 
-func NewProviderFromDownloadURL(downloadURL string) *Provider {
-	return &Provider{
-		Package: &getproviders.Package{
-			DownloadURL: downloadURL,
-		},
+func ParseProvider(str string) *Provider {
+	parts := strings.Split(str, "/")
+	for i := range parts {
+		if parts[i] == "*" {
+			parts[i] = ""
+		}
 	}
-}
 
-// VersionURL returns the URL used to query the all Versions for a single provider.
-// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-versions-for-a-single-provider
-func (provider *Provider) VersionURL() *url.URL {
-	return &url.URL{
-		Scheme: "https",
-		Host:   provider.RegistryName,
-		Path:   path.Join("/v1/providers", provider.Namespace, provider.Name, "versions"),
-	}
-}
+	const twoVals = 2
 
-// PlatformURL returns the URL used to query the all platforms for a single version.
-// https://developer.hashicorp.com/terraform/cloud-docs/api-docs/private-registry/provider-versions-platforms#get-all-platforms-for-a-single-version
-func (provider *Provider) PlatformURL() *url.URL {
-	return &url.URL{
-		Scheme: "https",
-		Host:   provider.RegistryName,
-		Path:   path.Join("/v1/providers", provider.Namespace, provider.Name, provider.Version, "download", provider.OS, provider.Arch),
+	switch {
+	case len(parts) == twoVals:
+		return &Provider{
+			Namespace: parts[0],
+			Name:      parts[1],
+		}
+	case len(parts) > twoVals:
+		return &Provider{
+			RegistryName: parts[0],
+			Namespace:    parts[1],
+			Name:         parts[2],
+		}
 	}
+
+	return nil
 }
 
 func (provider *Provider) String() string {
@@ -62,13 +126,14 @@ func (provider *Provider) Address() string {
 
 // Match returns true if all defined provider properties are matched.
 func (provider *Provider) Match(target *Provider) bool {
-	if (provider.RegistryName == "" || target.RegistryName == "" || provider.RegistryName == target.RegistryName) &&
-		(provider.Namespace == "" || target.Namespace == "" || provider.Namespace == target.Namespace) &&
-		(provider.Name == "" || target.Name == "" || provider.Name == target.Name) &&
-		(provider.Version == "" || target.Version == "" || provider.Version == target.Version) &&
-		(provider.OS == "" || target.OS == "" || provider.OS == target.OS) &&
-		(provider.Arch == "" || target.Arch == "" || provider.Arch == target.Arch) &&
-		(provider.DownloadURL == "" || target.DownloadURL == "" || provider.DownloadURL == target.DownloadURL) {
+	registryNameMatch := provider.RegistryName == "" || target.RegistryName == "" || provider.RegistryName == target.RegistryName
+	namespaceMatch := provider.Namespace == "" || target.Namespace == "" || provider.Namespace == target.Namespace
+	nameMatch := provider.Name == "" || target.Name == "" || provider.Name == target.Name
+	osMatch := provider.OS == "" || target.OS == "" || provider.OS == target.OS
+	archMatch := provider.Arch == "" || target.Arch == "" || provider.Arch == target.Arch
+	downloadURLMatch := provider.ResponseBody == nil || provider.DownloadURL == "" || target.DownloadURL == "" || provider.DownloadURL == target.DownloadURL
+
+	if registryNameMatch && namespaceMatch && nameMatch && osMatch && archMatch && downloadURLMatch {
 		return true
 	}
 	return false
diff --git a/terraform/cache/router/controller.go b/terraform/cache/router/controller.go
index 752c41f9be..443efa87cd 100644
--- a/terraform/cache/router/controller.go
+++ b/terraform/cache/router/controller.go
@@ -1,8 +1,13 @@
 package router
 
+import "net/url"
+
 // Controller is an interface implemented by a REST controller
 type Controller interface {
 	// Register is the method called by the router, passing the router
 	// groups to let the controller register its methods
 	Register(router *Router)
+
+	// URL returns the controller fqdn.
+	URL() *url.URL
 }
diff --git a/terraform/cache/router/router.go b/terraform/cache/router/router.go
index a2527a7f57..588f5ad61a 100644
--- a/terraform/cache/router/router.go
+++ b/terraform/cache/router/router.go
@@ -1,6 +1,7 @@
 package router
 
 import (
+	"net/url"
 	"path"
 	"strings"
 
@@ -10,26 +11,30 @@ import (
 type Router struct {
 	*echo.Echo
 
-	// prefix is the router prefix
-	prefix string
+	// urlPath is the router urlPath
+	urlPath string
 }
 
 func New() *Router {
 	return &Router{
-		Echo:   echo.New(),
-		prefix: "/",
+		Echo:    echo.New(),
+		urlPath: "/",
 	}
 }
 
-func (router *Router) Group(prefix string) *Router {
+func (router *Router) Group(urlPath string) *Router {
 	return &Router{
-		Echo:   router.Echo,
-		prefix: path.Join(router.prefix, prefix),
+		Echo:    router.Echo,
+		urlPath: path.Join(router.urlPath, urlPath),
 	}
 }
 
-func (router *Router) Prefix() string {
-	return router.prefix
+func (router *Router) URL() *url.URL {
+	return &url.URL{
+		Scheme: "http",
+		Host:   router.Server.Addr,
+		Path:   router.urlPath,
+	}
 }
 
 // Register registers controller's endpoints
@@ -44,7 +49,7 @@ func (router *Router) Use(middlewares ...echo.MiddlewareFunc) {
 	for _, middleware := range middlewares {
 		middleware := func(next echo.HandlerFunc) echo.HandlerFunc {
 			return func(ctx echo.Context) error {
-				if strings.HasPrefix(ctx.Path(), router.prefix) {
+				if strings.HasPrefix(strings.Trim(ctx.Path(), "/"), strings.Trim(router.urlPath, "/")) {
 					return middleware(next)(ctx)
 				}
 				return next(ctx)
@@ -56,42 +61,42 @@ func (router *Router) Use(middlewares ...echo.MiddlewareFunc) {
 
 // GET registers a new GET route for a path with matching handler in the router
 // with optional route-level middleware.
-func (router *Router) GET(prefix string, handle echo.HandlerFunc) {
-	router.Echo.GET(path.Join(router.prefix, prefix), handle)
+func (router *Router) GET(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.GET(path.Join(router.urlPath, urlPath), handle)
 }
 
 // HEAD registers a new HEAD route for a path with matching handler in the
 // router with optional route-level middleware.
-func (router *Router) HEAD(prefix string, handle echo.HandlerFunc) {
-	router.Echo.HEAD(path.Join(router.prefix, prefix), handle)
+func (router *Router) HEAD(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.HEAD(path.Join(router.urlPath, urlPath), handle)
 }
 
 // OPTIONS registers a new OPTIONS route for a path with matching handler in the
 // router with optional route-level middleware.
-func (router *Router) OPTIONS(prefix string, handle echo.HandlerFunc) {
-	router.Echo.OPTIONS(path.Join(router.prefix, prefix), handle)
+func (router *Router) OPTIONS(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.OPTIONS(path.Join(router.urlPath, urlPath), handle)
 }
 
 // POST registers a new POST route for a path with matching handler in the
 // router with optional route-level middleware.
-func (router *Router) POST(prefix string, handle echo.HandlerFunc) {
-	router.Echo.POST(path.Join(router.prefix, prefix), handle)
+func (router *Router) POST(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.POST(path.Join(router.urlPath, urlPath), handle)
 }
 
 // PUT registers a new PUT route for a path with matching handler in the
 // router with optional route-level middleware.
-func (router *Router) PUT(prefix string, handle echo.HandlerFunc) {
-	router.Echo.PUT(path.Join(router.prefix, prefix), handle)
+func (router *Router) PUT(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.PUT(path.Join(router.urlPath, urlPath), handle)
 }
 
 // PATCH registers a new PATCH route for a path with matching handler in the
 // router with optional route-level middleware.
-func (router *Router) PATCH(prefix string, handle echo.HandlerFunc) {
-	router.Echo.PATCH(path.Join(router.prefix, prefix), handle)
+func (router *Router) PATCH(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.PATCH(path.Join(router.urlPath, urlPath), handle)
 }
 
 // DELETE registers a new DELETE route for a path with matching handler in the router
 // with optional route-level middleware.
-func (router *Router) DELETE(prefix string, handle echo.HandlerFunc) {
-	router.Echo.DELETE(path.Join(router.prefix, prefix), handle)
+func (router *Router) DELETE(urlPath string, handle echo.HandlerFunc) {
+	router.Echo.DELETE(path.Join(router.urlPath, urlPath), handle)
 }
diff --git a/terraform/cache/server.go b/terraform/cache/server.go
index c06c48e480..6c5eec3e8e 100644
--- a/terraform/cache/server.go
+++ b/terraform/cache/server.go
@@ -4,12 +4,11 @@ import (
 	"context"
 	"net"
 	"net/http"
-	"net/url"
 
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/controllers"
-	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/middleware"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/router"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
 	"golang.org/x/sync/errgroup"
@@ -17,36 +16,27 @@ import (
 
 // Server is a private Terraform cache for provider caching.
 type Server struct {
-	*http.Server
+	*router.Router
 	config *Config
 
-	Provider           *services.ProviderService
-	providerController *controllers.ProviderController
-	reverseProxy       *handlers.ReverseProxy
+	services           []services.Service
+	ProviderController *controllers.ProviderController
 }
 
 // NewServer returns a new Server instance.
 func NewServer(opts ...Option) *Server {
 	cfg := NewConfig(opts...)
 
-	providerService := services.NewProviderService(cfg.providerCacheDir, cfg.userProviderDir)
-
-	authorization := &handlers.Authorization{
-		Token: cfg.token,
-	}
-
-	reverseProxy := &handlers.ReverseProxy{}
+	authMiddleware := middleware.KeyAuth(cfg.token)
 
 	downloaderController := &controllers.DownloaderController{
-		ReverseProxy:    reverseProxy,
-		ProviderService: providerService,
+		ProviderHandlers: cfg.providerHandlers,
 	}
 
 	providerController := &controllers.ProviderController{
-		Authorization:   authorization,
-		ReverseProxy:    reverseProxy,
-		ProviderService: providerService,
-		Downloader:      downloaderController,
+		AuthMiddleware:       authMiddleware,
+		DownloaderController: downloaderController,
+		ProviderHandlers:     cfg.providerHandlers,
 	}
 
 	discoveryController := &controllers.DiscoveryController{
@@ -54,26 +44,18 @@ func NewServer(opts ...Option) *Server {
 	}
 
 	rootRouter := router.New()
+	rootRouter.Use(middleware.Logger())
+	rootRouter.Use(middleware.Recover())
 	rootRouter.Register(discoveryController, downloaderController)
 
 	v1Group := rootRouter.Group("v1")
 	v1Group.Register(providerController)
 
 	return &Server{
-		Server:             &http.Server{Handler: rootRouter},
+		Router:             rootRouter,
 		config:             cfg,
-		Provider:           providerService,
-		providerController: providerController,
-		reverseProxy:       reverseProxy,
-	}
-}
-
-// ProviderURL returns a full URL to the provider controller, e.g. http://localhost:5758/v1/providers
-func (server *Server) ProviderURL() *url.URL {
-	return &url.URL{
-		Scheme: "http",
-		Host:   server.Addr,
-		Path:   server.providerController.Path(),
+		services:           cfg.services,
+		ProviderController: providerController,
 	}
 }
 
@@ -83,14 +65,9 @@ func (server *Server) Listen() (net.Listener, error) {
 	if err != nil {
 		return nil, errors.WithStackTrace(err)
 	}
+	server.Server.Addr = ln.Addr().String()
 
-	log.Infof("Terragrunt Cache server is listening on %s", server.Addr)
-
-	server.Addr = ln.Addr().String()
-	server.reverseProxy.ServerURL = &url.URL{
-		Scheme: "http",
-		Host:   ln.Addr().String(),
-	}
+	log.Infof("Terragrunt Cache server is listening on %s", ln.Addr())
 	return ln, nil
 }
 
@@ -99,9 +76,12 @@ func (server *Server) Run(ctx context.Context, ln net.Listener) error {
 	log.Infof("Start Terragrunt Cache server")
 
 	errGroup, ctx := errgroup.WithContext(ctx)
-	errGroup.Go(func() error {
-		return server.Provider.RunCacheWorker(ctx)
-	})
+	for _, service := range server.services {
+		service := service
+		errGroup.Go(func() error {
+			return service.Run(ctx)
+		})
+	}
 	errGroup.Go(func() error {
 		<-ctx.Done()
 		log.Infof("Shutting down Terragrunt Cache server...")
@@ -109,14 +89,13 @@ func (server *Server) Run(ctx context.Context, ln net.Listener) error {
 		ctx, cancel := context.WithTimeout(ctx, server.config.shutdownTimeout)
 		defer cancel()
 
-		server.SetKeepAlivesEnabled(false)
 		if err := server.Shutdown(ctx); err != nil {
 			return errors.WithStackTrace(err)
 		}
 		return nil
 	})
 
-	if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
+	if err := server.Server.Serve(ln); err != nil && err != http.ErrServerClosed {
 		return errors.Errorf("error starting terragrunt cache server: %w", err)
 	}
 	defer log.Infof("Terragrunt Cache server stopped")
diff --git a/terraform/cache/services/provider.go b/terraform/cache/services/provider_cache.go
similarity index 73%
rename from terraform/cache/services/provider.go
rename to terraform/cache/services/provider_cache.go
index 06cecae4a9..5be867b4f6 100644
--- a/terraform/cache/services/provider.go
+++ b/terraform/cache/services/provider_cache.go
@@ -61,6 +61,15 @@ func (caches ProviderCaches) FindByRequestID(requestID string) ProviderCaches {
 	return foundCaches
 }
 
+func (caches ProviderCaches) removeArchive() error {
+	for _, cache := range caches {
+		if err := cache.removeArchive(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 type ProviderCache struct {
 	*models.Provider
 	requestIDs []string
@@ -127,11 +136,11 @@ func (cache *ProviderCache) AuthenticatePackage(ctx context.Context) (*getprovid
 		err                error
 	)
 
-	if documentSHA256Sums, err = cache.DocumentSHA256Sums(ctx); err != nil {
+	if documentSHA256Sums, err = cache.DocumentSHA256Sums(ctx); err != nil || documentSHA256Sums == nil {
 		return nil, err
 	}
 
-	if signature, err = cache.Signature(ctx); err != nil {
+	if signature, err = cache.Signature(ctx); err != nil || signature == nil {
 		return nil, err
 	}
 
@@ -139,20 +148,22 @@ func (cache *ProviderCache) AuthenticatePackage(ctx context.Context) (*getprovid
 		return nil, errors.Errorf("registry response includes invalid SHA256 hash %q for provider %q: %w", cache.SHA256Sum, cache.Provider, err)
 	}
 
-	keys := make([]getproviders.SigningKey, len(cache.SigningKeys.GPGPublicKeys))
-	for i, key := range cache.SigningKeys.GPGPublicKeys {
-		keys[i] = *key
+	checks := []getproviders.PackageAuthentication{
+		getproviders.NewMatchingChecksumAuthentication(documentSHA256Sums, cache.Filename, checksum),
+		getproviders.NewArchiveChecksumAuthentication(checksum),
 	}
 
-	providerPackage := getproviders.PackageAuthenticationAll(
-		getproviders.NewMatchingChecksumAuthentication(documentSHA256Sums, cache.Package.Filename, checksum),
-		getproviders.NewArchiveChecksumAuthentication(checksum),
-		getproviders.NewSignatureAuthentication(documentSHA256Sums, signature, keys),
-	)
-	return providerPackage.Authenticate(cache.archivePath)
+	if len(cache.SigningKeys.Keys()) != 0 {
+		checks = append(checks, getproviders.NewSignatureAuthentication(documentSHA256Sums, signature, cache.SigningKeys.Keys()))
+	} else {
+		// `registry.opentofu.org` does not have signatures for some providers.
+		log.Warnf("Signature validation was skipped due to the registry not containing GPG keys for the provider %s", cache.Provider)
+	}
+
+	return getproviders.PackageAuthenticationAll(checks...).Authenticate(cache.archivePath)
 }
 
-func (cache *ProviderCache) Filename() string {
+func (cache *ProviderCache) ArchivePath() string {
 	if util.FileExists(cache.archivePath) {
 		return cache.archivePath
 	}
@@ -188,12 +199,16 @@ func (cache *ProviderCache) warmUp(ctx context.Context) error {
 		return errors.Errorf("not found provider download url")
 	}
 
-	if err := util.DoWithRetry(ctx, fmt.Sprintf("Fetching provider %s", cache.Provider), maxRetriesFetchFile, retryDelayFetchFile, logrus.DebugLevel, func() error {
-		return util.FetchToFile(ctx, cache.DownloadURL, cache.archivePath)
-	}); err != nil {
-		return err
+	if util.FileExists(cache.DownloadURL) {
+		cache.archivePath = cache.DownloadURL
+	} else {
+		if err := util.DoWithRetry(ctx, fmt.Sprintf("Fetching provider %s", cache.Provider), maxRetriesFetchFile, retryDelayFetchFile, logrus.DebugLevel, func() error {
+			return util.FetchToFile(ctx, cache.DownloadURL, cache.archivePath)
+		}); err != nil {
+			return err
+		}
+		cache.archiveCached = true
 	}
-	cache.archiveCached = true
 
 	log.Debugf("Unpack provider archive %s", cache.archivePath)
 
@@ -238,10 +253,13 @@ func (cache *ProviderCache) acquireLockFile(ctx context.Context) (*util.Lockfile
 
 type ProviderService struct {
 	// The path to store unpacked providers. The file structure is the same as terraform plugin cache dir.
-	baseCacheDir string
+	cacheDir string
+
+	// The path to a predictable temporary directory for provider archives and lock files.
+	tempDir string
 
 	// the user plugins directory, by default: %APPDATA%\terraform.d\plugins on Windows, ~/.terraform.d/plugins on other systems.
-	baseUserCacheDir string
+	userCacheDir string
 
 	providerCaches        ProviderCaches
 	providerCacheWarmUpCh chan *ProviderCache
@@ -250,21 +268,21 @@ type ProviderService struct {
 	cacheReadyMu sync.RWMutex
 }
 
-func NewProviderService(baseCacheDir, baseUserCacheDir string) *ProviderService {
+func NewProviderService(cacheDir, userCacheDir string) *ProviderService {
 	return &ProviderService{
-		baseCacheDir:          baseCacheDir,
-		baseUserCacheDir:      baseUserCacheDir,
+		cacheDir:              cacheDir,
+		userCacheDir:          userCacheDir,
 		providerCacheWarmUpCh: make(chan *ProviderCache),
 	}
 }
 
 // WaitForCacheReady returns cached providers that were requested by `terraform init` from the cache server, with an  URL containing the given `requestID` value.
 // The function returns the value only when all cache requests have been processed.
-func (service *ProviderService) WaitForCacheReady(requestID string) getproviders.Providers {
+func (service *ProviderService) WaitForCacheReady(requestID string) []getproviders.Provider {
 	service.cacheReadyMu.Lock()
 	defer service.cacheReadyMu.Unlock()
 
-	var providers getproviders.Providers
+	var providers []getproviders.Provider
 
 	for _, provider := range service.providerCaches.FindByRequestID(requestID) {
 		providers = append(providers, provider)
@@ -288,10 +306,10 @@ func (service *ProviderService) CacheProvider(ctx context.Context, requestID str
 		Provider: provider,
 		started:  make(chan struct{}, 1),
 
-		userProviderDir: filepath.Join(service.baseUserCacheDir, provider.Address(), provider.Version, provider.Platform()),
-		packageDir:      filepath.Join(service.baseCacheDir, provider.Address(), provider.Version, provider.Platform()),
-		lockfilePath:    filepath.Join(service.baseCacheDir, fmt.Sprintf("%s.lock", packageName)),
-		archivePath:     filepath.Join(service.baseCacheDir, fmt.Sprintf("%s%s", packageName, path.Ext(provider.Filename))),
+		userProviderDir: filepath.Join(service.userCacheDir, provider.Address(), provider.Version, provider.Platform()),
+		packageDir:      filepath.Join(service.cacheDir, provider.Address(), provider.Version, provider.Platform()),
+		lockfilePath:    filepath.Join(service.tempDir, fmt.Sprintf("%s.lock", packageName)),
+		archivePath:     filepath.Join(service.tempDir, fmt.Sprintf("%s%s", packageName, path.Ext(provider.Filename))),
 	}
 
 	select {
@@ -318,59 +336,67 @@ func (service *ProviderService) GetProviderCache(provider *models.Provider) *Pro
 	return nil
 }
 
-// RunCacheWorker is responsible to handle a new caching requestID and removing temporary files upon completion.
-func (service *ProviderService) RunCacheWorker(ctx context.Context) error {
-	if service.baseCacheDir == "" {
+// Run is responsible to handle a new caching requestID and removing temporary files upon completion.
+func (service *ProviderService) Run(ctx context.Context) error {
+	if service.cacheDir == "" {
 		return errors.Errorf("provider cache directory not specified")
 	}
-	log.Debugf("Provider cache dir %q", service.baseCacheDir)
+	log.Debugf("Provider cache dir %q", service.cacheDir)
 
-	if err := os.MkdirAll(service.baseCacheDir, os.ModePerm); err != nil {
+	if err := os.MkdirAll(service.cacheDir, os.ModePerm); err != nil {
 		return errors.WithStackTrace(err)
 	}
 
+	tempDir, err := util.GetTempDir()
+	if err != nil {
+		return err
+	}
+	service.tempDir = filepath.Join(tempDir, "providers")
+
+	merr := &multierror.Error{}
 	errGroup, ctx := errgroup.WithContext(ctx)
 	for {
 		select {
 		case cache := <-service.providerCacheWarmUpCh:
 			errGroup.Go(func() error {
-				service.cacheReadyMu.RLock()
-				defer service.cacheReadyMu.RUnlock()
-
-				cache.started <- struct{}{}
-
-				// We need to use a locking mechanism between Terragrunt processes to prevent simultaneous write access to the same provider.
-				lockfile, err := cache.acquireLockFile(ctx)
-				if err != nil {
-					return err
-				}
-				defer lockfile.Unlock() //nolint:errcheck
-
-				if err := cache.warmUp(ctx); err != nil {
-					os.Remove(cache.packageDir)  //nolint:errcheck
-					os.Remove(cache.archivePath) //nolint:errcheck
-					return err
+				if err := service.startProviderCaching(ctx, cache); err != nil {
+					merr = multierror.Append(merr, err)
 				}
-				cache.ready = true
-
 				return nil
 			})
 		case <-ctx.Done():
-			merr := &multierror.Error{}
-
 			if err := errGroup.Wait(); err != nil && !goerrors.Is(err, context.Canceled) {
 				merr = multierror.Append(merr, err)
 			}
 
-			for _, cache := range service.providerCaches {
-				close(cache.started)
-
-				if err := cache.removeArchive(); err != nil && !goerrors.Is(err, context.Canceled) {
-					merr = multierror.Append(merr, errors.WithStackTrace(err))
-				}
+			if err := service.providerCaches.removeArchive(); err != nil {
+				merr = multierror.Append(merr, errors.WithStackTrace(err))
 			}
 
 			return merr.ErrorOrNil()
 		}
 	}
 }
+
+func (service *ProviderService) startProviderCaching(ctx context.Context, cache *ProviderCache) error {
+	service.cacheReadyMu.RLock()
+	defer service.cacheReadyMu.RUnlock()
+
+	cache.started <- struct{}{}
+
+	// We need to use a locking mechanism between Terragrunt processes to prevent simultaneous write access to the same provider.
+	lockfile, err := cache.acquireLockFile(ctx)
+	if err != nil {
+		return err
+	}
+	defer lockfile.Unlock() //nolint:errcheck
+
+	if err := cache.warmUp(ctx); err != nil {
+		os.Remove(cache.packageDir)  //nolint:errcheck
+		os.Remove(cache.archivePath) //nolint:errcheck
+		return err
+	}
+	cache.ready = true
+
+	return nil
+}
diff --git a/terraform/cache/services/service.go b/terraform/cache/services/service.go
new file mode 100644
index 0000000000..9d34da12b4
--- /dev/null
+++ b/terraform/cache/services/service.go
@@ -0,0 +1,9 @@
+package services
+
+import (
+	"context"
+)
+
+type Service interface {
+	Run(ctx context.Context) error
+}
diff --git a/terraform/cliconfig/config.go b/terraform/cliconfig/config.go
index c11a3e0e13..b4520235ed 100644
--- a/terraform/cliconfig/config.go
+++ b/terraform/cliconfig/config.go
@@ -2,34 +2,69 @@ package cliconfig
 
 import (
 	"os"
-	"regexp"
 
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/hashicorp/hcl/v2/gohcl"
 	"github.com/hashicorp/hcl/v2/hclwrite"
 )
 
-var (
-	// matches the line starting with `plugin_cache_dir =`
-	configParamPluginCacheDirReg = regexp.MustCompile(`(?mi)^\s*plugin_cache_dir\s*=\s*(.*?)\s*$`)
-)
-
 // ConfigHost is the structure of the "host" nested block within the CLI configuration, which can be used to override the default service host discovery behavior for a particular hostname.
 type ConfigHost struct {
 	Name     string            `hcl:",label"`
 	Services map[string]string `hcl:"services,attr"`
 }
 
+// ConfigCredentials is the structure of the "credentials" nested block within the CLI configuration.
+type ConfigCredentials struct {
+	Name  string `hcl:",label"`
+	Token string `hcl:"token"`
+}
+
+// ConfigCredentialsHelper is the structure of the "credentials_helper" nested block within the CLI configuration.
+type ConfigCredentialsHelper struct {
+	Name string   `hcl:",label"`
+	Args []string `hcl:"args"`
+}
+
 // Config provides methods to create a terraform [CLI config file](https://developer.hashicorp.com/terraform/cli/config/config-file).
 // The main purpose of which is to create a local config that will inherit the default user CLI config and adding new sections to force Terraform to send requests through the Terragrunt Cache server and use the provider cache directory.
 type Config struct {
-	rawHCL []byte
+	DisableCheckpoint          bool `hcl:"disable_checkpoint"`
+	DisableCheckpointSignature bool `hcl:"disable_checkpoint_signature"`
+
+	Credentials        []ConfigCredentials      `hcl:"credentials,block"`
+	CredentialsHelpers *ConfigCredentialsHelper `hcl:"credentials_helper,block"`
 
 	PluginCacheDir       string                `hcl:"plugin_cache_dir"`
 	Hosts                []ConfigHost          `hcl:"host,block"`
 	ProviderInstallation *ProviderInstallation `hcl:"provider_installation,block"`
 }
 
+func (cfg *Config) Clone() *Config {
+	var (
+		providerInstallation *ProviderInstallation
+		hosts                []ConfigHost
+	)
+
+	hosts = append(hosts, cfg.Hosts...)
+
+	if cfg.ProviderInstallation != nil {
+		providerInstallation = &ProviderInstallation{
+			Methods: cfg.ProviderInstallation.Methods,
+		}
+	}
+
+	return &Config{
+		PluginCacheDir:             cfg.PluginCacheDir,
+		DisableCheckpoint:          cfg.DisableCheckpoint,
+		DisableCheckpointSignature: cfg.DisableCheckpointSignature,
+		Credentials:                cfg.Credentials,
+		CredentialsHelpers:         cfg.CredentialsHelpers,
+		Hosts:                      hosts,
+		ProviderInstallation:       providerInstallation,
+	}
+}
+
 // AddHost adds a host (officially undocumented), https://github.com/hashicorp/terraform/issues/28309
 // It gives us opportunity rewrite path to the remote registry and the most important thing is that it works smoothly with HTTP (without HTTPS)
 //
@@ -45,7 +80,7 @@ func (cfg *Config) AddHost(name string, services map[string]string) {
 	})
 }
 
-// SetProviderInstallation sets an installation method, https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation
+// AddProviderInstallationMethods adds installation methods, https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation
 //
 //	provider_installation {
 //		filesystem_mirror {
@@ -56,29 +91,19 @@ func (cfg *Config) AddHost(name string, services map[string]string) {
 //			exclude = ["example.com/*/*"]
 //		}
 //	}
-func (cfg *Config) SetProviderInstallation(filesystemMethod *ProviderInstallationFilesystemMirror, directMethod *ProviderInstallationDirect) {
-	if filesystemMethod == nil && directMethod == nil {
-		return
-	}
-	providerInstallation := &ProviderInstallation{
-		FilesystemMirror: filesystemMethod,
-		Direct:           directMethod,
+func (cfg *Config) AddProviderInstallationMethods(methods ...ProviderInstallationMethod) {
+	if cfg.ProviderInstallation == nil {
+		cfg.ProviderInstallation = &ProviderInstallation{}
 	}
-	cfg.ProviderInstallation = providerInstallation
+	cfg.ProviderInstallation.Methods = append(cfg.ProviderInstallation.Methods, methods...)
 }
 
 // Save marshalls and saves CLI config with the given config path.
 func (cfg *Config) Save(configPath string) error {
-	rawHCL := cfg.rawHCL
-	// Since `Config` structure already has `plugin_cache_dir`, remove it from the raw HCL config to prevent repeating in the saved file.
-	rawHCL = configParamPluginCacheDirReg.ReplaceAll(rawHCL, []byte{})
-
 	file := hclwrite.NewEmptyFile()
-
 	gohcl.EncodeIntoBody(cfg, file.Body())
-	newHCL := append(rawHCL, file.Bytes()...)
 
-	if err := os.WriteFile(configPath, newHCL, os.FileMode(0644)); err != nil {
+	if err := os.WriteFile(configPath, file.Bytes(), os.FileMode(0644)); err != nil {
 		return errors.WithStackTrace(err)
 	}
 
diff --git a/terraform/cliconfig/provider_installation.go b/terraform/cliconfig/provider_installation.go
index 79d5a01561..85ca597dd3 100644
--- a/terraform/cliconfig/provider_installation.go
+++ b/terraform/cliconfig/provider_installation.go
@@ -1,14 +1,58 @@
 package cliconfig
 
+// ProviderInstallation is the structure of the "provider_installation" nested block within the CLI configuration.
+type ProviderInstallation struct {
+	Methods []ProviderInstallationMethod `hcl:",block"`
+}
+
+// ProviderInstallationMethod is an interface type representing the different installation path types and represents an installation method block inside a provider_installation block. The concrete implementations of this interface are:
+//
+//	ProviderInstallationDirect:           install from the provider's origin registry
+//	ProviderInstallationFilesystemMirror: install from a local filesystem mirror
+type ProviderInstallationMethod interface {
+	AppendExclude(addrs []string)
+}
+
+type ProviderInstallationDirect struct {
+	Name    string    `hcl:",label"`
+	Include *[]string `hcl:"include,optional"`
+	Exclude *[]string `hcl:"exclude,optional"`
+}
+
+func NewProviderInstallationDirect(include, exclude []string) *ProviderInstallationDirect {
+	res := &ProviderInstallationDirect{
+		Name: "direct",
+	}
+
+	if len(include) > 0 {
+		res.Include = &include
+	}
+
+	if len(exclude) > 0 {
+		res.Exclude = &exclude
+	}
+
+	return res
+}
+
+func (method *ProviderInstallationDirect) AppendExclude(addrs []string) {
+	if method.Exclude == nil {
+		method.Exclude = &[]string{}
+	}
+	*method.Exclude = append(*method.Exclude, addrs...)
+}
+
 type ProviderInstallationFilesystemMirror struct {
-	Location string    `hcl:"path,attr"`
-	Include  *[]string `hcl:"include,attr"`
-	Exclude  *[]string `hcl:"exclude,attr"`
+	Name    string    `hcl:",label"`
+	Path    string    `hcl:"path,attr"`
+	Include *[]string `hcl:"include,optional"`
+	Exclude *[]string `hcl:"exclude,optional"`
 }
 
-func NewProviderInstallationFilesystemMirror(location string, include, exclude []string) *ProviderInstallationFilesystemMirror {
+func NewProviderInstallationFilesystemMirror(path string, include, exclude []string) *ProviderInstallationFilesystemMirror {
 	res := &ProviderInstallationFilesystemMirror{
-		Location: location,
+		Name: "filesystem_mirror",
+		Path: path,
 	}
 
 	if len(include) > 0 {
@@ -22,13 +66,25 @@ func NewProviderInstallationFilesystemMirror(location string, include, exclude [
 	return res
 }
 
-type ProviderInstallationDirect struct {
-	Include *[]string `hcl:"include,attr"`
-	Exclude *[]string `hcl:"exclude,attr"`
+func (method *ProviderInstallationFilesystemMirror) AppendExclude(addrs []string) {
+	if method.Exclude == nil {
+		method.Exclude = &[]string{}
+	}
+	*method.Exclude = append(*method.Exclude, addrs...)
 }
 
-func NewProviderInstallationDirect(include, exclude []string) *ProviderInstallationDirect {
-	res := new(ProviderInstallationDirect)
+type ProviderInstallationNetworkMirror struct {
+	Name    string    `hcl:",label"`
+	URL     string    `hcl:"url,attr"`
+	Include *[]string `hcl:"include,optional"`
+	Exclude *[]string `hcl:"exclude,optional"`
+}
+
+func NewProviderInstallationNetworkMirror(url string, include, exclude []string) *ProviderInstallationNetworkMirror {
+	res := &ProviderInstallationNetworkMirror{
+		Name: "network_mirror",
+		URL:  url,
+	}
 
 	if len(include) > 0 {
 		res.Include = &include
@@ -41,8 +97,9 @@ func NewProviderInstallationDirect(include, exclude []string) *ProviderInstallat
 	return res
 }
 
-// ProviderInstallation is the structure of the "provider_installation" nested block within the CLI configuration.
-type ProviderInstallation struct {
-	FilesystemMirror *ProviderInstallationFilesystemMirror `hcl:"filesystem_mirror,block"`
-	Direct           *ProviderInstallationDirect           `hcl:"direct,block"`
+func (method *ProviderInstallationNetworkMirror) AppendExclude(addrs []string) {
+	if method.Exclude == nil {
+		method.Exclude = &[]string{}
+	}
+	*method.Exclude = append(*method.Exclude, addrs...)
 }
diff --git a/terraform/cliconfig/user_config.go b/terraform/cliconfig/user_config.go
index 78906a549f..b92af7cf3c 100644
--- a/terraform/cliconfig/user_config.go
+++ b/terraform/cliconfig/user_config.go
@@ -1,11 +1,9 @@
 package cliconfig
 
 import (
-	"os"
 	"path/filepath"
 
 	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/hashicorp/terraform/command/cliconfig"
 	"github.com/hashicorp/terraform/tfdiags"
 )
@@ -13,35 +11,32 @@ import (
 // The user configuration is read as raw data and stored at the top of the saved configuration file.
 // The location of the default config is different for each OS https://developer.hashicorp.com/terraform/cli/config/config-file#locations
 func LoadUserConfig() (*Config, error) {
-	return loadUserConfig(cliconfig.ConfigFile, cliconfig.LoadConfig)
+	return loadUserConfig(cliconfig.LoadConfig)
 }
 
 func loadUserConfig(
-	configFileFn func() (string, error),
 	loadConfigFn func() (*cliconfig.Config, tfdiags.Diagnostics),
 ) (*Config, error) {
-	var rawHCL []byte
-
-	configFile, err := configFileFn()
-	if err != nil {
-		return nil, errors.WithStackTrace(err)
-	}
-
-	if util.FileExists(configFile) {
-		rawHCL, err = os.ReadFile(configFile)
-		if err != nil {
-			return nil, errors.WithStackTrace(err)
-		}
-	}
-
 	cfg, diag := loadConfigFn()
 	if diag.HasErrors() {
 		return nil, diag.Err()
 	}
 
+	var (
+		installationMethods = getUserProviderInstallationMethods(cfg)
+		hosts               = getUserHosts(cfg)
+		credentialsHelpers  = getUserCredentialsHelpers(cfg)
+		credentials         = getUserCredentials(cfg)
+	)
+
 	return &Config{
-		rawHCL:         rawHCL,
-		PluginCacheDir: cfg.PluginCacheDir,
+		DisableCheckpoint:          cfg.DisableCheckpoint,
+		DisableCheckpointSignature: cfg.DisableCheckpointSignature,
+		PluginCacheDir:             cfg.PluginCacheDir,
+		Credentials:                credentials,
+		CredentialsHelpers:         credentialsHelpers,
+		ProviderInstallation:       &ProviderInstallation{Methods: installationMethods},
+		Hosts:                      hosts,
 	}, nil
 }
 
@@ -53,3 +48,82 @@ func UserProviderDir() (string, error) {
 
 	return filepath.Join(configDir, "plugins"), nil
 }
+
+func getUserCredentials(cfg *cliconfig.Config) []ConfigCredentials {
+	var credentials []ConfigCredentials
+
+	for name, credential := range cfg.Credentials {
+		var token string
+
+		if val, ok := credential["token"]; ok {
+			if val, ok := val.(string); ok {
+				token = val
+			}
+		}
+
+		credential := ConfigCredentials{
+			Name:  name,
+			Token: token,
+		}
+		credentials = append(credentials, credential)
+	}
+
+	return credentials
+}
+
+func getUserCredentialsHelpers(cfg *cliconfig.Config) *ConfigCredentialsHelper {
+	var credentialsHelpers *ConfigCredentialsHelper
+
+	for name, helper := range cfg.CredentialsHelpers {
+		var args []string
+		if helper != nil {
+			args = helper.Args
+		}
+
+		credentialsHelpers = &ConfigCredentialsHelper{
+			Name: name,
+			Args: args,
+		}
+	}
+
+	return credentialsHelpers
+}
+
+func getUserHosts(cfg *cliconfig.Config) []ConfigHost {
+	var hosts []ConfigHost
+
+	for name, host := range cfg.Hosts {
+		services := make(map[string]string)
+		if host != nil {
+			for key, val := range host.Services {
+				if val, ok := val.(string); ok {
+					services[key] = val
+				}
+			}
+		}
+
+		host := ConfigHost{Name: name, Services: services}
+		hosts = append(hosts, host)
+	}
+
+	return hosts
+}
+
+func getUserProviderInstallationMethods(cfg *cliconfig.Config) []ProviderInstallationMethod {
+	var methods []ProviderInstallationMethod
+
+	for _, providerInstallation := range cfg.ProviderInstallation {
+		for _, method := range providerInstallation.Methods {
+			switch location := method.Location.(type) {
+			case cliconfig.ProviderInstallationFilesystemMirror:
+				methods = append(methods, NewProviderInstallationFilesystemMirror(string(location), method.Include, method.Exclude))
+			case cliconfig.ProviderInstallationNetworkMirror:
+				methods = append(methods, NewProviderInstallationNetworkMirror(string(location), method.Include, method.Exclude))
+			default:
+				methods = append(methods, NewProviderInstallationDirect(method.Include, method.Exclude))
+			}
+		}
+	}
+
+	return methods
+}
diff --git a/terraform/getproviders/hclwrite.go b/terraform/getproviders/hclwrite.go
deleted file mode 100644
index 545e75ac7c..0000000000
--- a/terraform/getproviders/hclwrite.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package getproviders
-
-import (
-	"strings"
-
-	"github.com/hashicorp/hcl/v2/hclsyntax"
-	"github.com/hashicorp/hcl/v2/hclwrite"
-	"github.com/zclconf/go-cty/cty"
-)
-
-// getAttributeValueAsString returns a value of Attribute as string. There is no way to get value as string directly, so we parses tokens of Attribute and build string representation.
-func getAttributeValueAsUnquotedString(attr *hclwrite.Attribute) string {
-	// find TokenEqual
-	expr := attr.Expr()
-	exprTokens := expr.BuildTokens(nil)
-
-	// TokenIdent records SpaceBefore, but we should ignore it here.
-	quotedValue := strings.TrimSpace(string(exprTokens.Bytes()))
-
-	// unquote
-	value := strings.Trim(quotedValue, "\"")
-
-	return value
-}
-
-// tokensForListPerLine builds a hclwrite.Tokens for a given hashes, but breaks the line for each element.
-func tokensForListPerLine(hashes []Hash) hclwrite.Tokens {
-	// The original TokensForValue implementation does not break line by line for hashes, so we build a token sequence by ourselves.
-	tokens := append(hclwrite.Tokens{},
-		&hclwrite.Token{Type: hclsyntax.TokenOBrack, Bytes: []byte{'['}},
-		&hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}})
-
-	for _, hash := range hashes {
-		ts := hclwrite.TokensForValue(cty.StringVal(hash.String()))
-		tokens = append(tokens, ts...)
-		tokens = append(tokens,
-			&hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte{','}},
-			&hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}})
-	}
-
-	tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte{']'}})
-	return tokens
-}
diff --git a/terraform/getproviders/lock.go b/terraform/getproviders/lock.go
index c4d52ca4d9..cba13a917e 100644
--- a/terraform/getproviders/lock.go
+++ b/terraform/getproviders/lock.go
@@ -1,3 +1,5 @@
+//go:generate mockery --name Provider
+
 package getproviders
 
 import (
@@ -5,19 +7,21 @@ import (
 	"os"
 	"path/filepath"
 	"sort"
+	"strings"
 
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
 	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/gruntwork-io/terragrunt/util"
 	"github.com/hashicorp/hcl/v2"
+	"github.com/hashicorp/hcl/v2/hclsyntax"
 	"github.com/hashicorp/hcl/v2/hclwrite"
 	"github.com/zclconf/go-cty/cty"
 	"golang.org/x/exp/slices"
 )
 
 // UpdateLockfile updates the dependency lock file. If `.terraform.lock.hcl` does not exist, it will be created, otherwise it will be updated.
-func UpdateLockfile(ctx context.Context, workingDir string, providers Providers) error {
+func UpdateLockfile(ctx context.Context, workingDir string, providers []Provider) error {
 	var (
 		filename = filepath.Join(workingDir, terraform.TerraformLockFile)
 		file     = hclwrite.NewFile()
@@ -46,7 +50,7 @@ func UpdateLockfile(ctx context.Context, workingDir string, providers Providers)
 	return nil
 }
 
-func updateLockfile(ctx context.Context, file *hclwrite.File, providers Providers) error {
+func updateLockfile(ctx context.Context, file *hclwrite.File, providers []Provider) error {
 	sort.Slice(providers, func(i, j int) bool {
 		return providers[i].Address() < providers[j].Address()
 	})
@@ -92,20 +96,57 @@ func updateProviderBlock(ctx context.Context, providerBlock *hclwrite.Block, pro
 	// Constraints can contain multiple constraint expressions, including comparison operators, but in the Terragrunt Provider Cache use case, we assume that the required_providers are pinned to a specific version to detect the required version without terraform init, so we can simply specify the constraints attribute as the same as the version. This may differ from what terraform generates, but we expect that it doesn't matter in practice.
 	providerBlock.Body().SetAttributeValue("constraints", cty.StringVal(provider.Version()))
 
-	documentSHA256Sums, err := provider.DocumentSHA256Sums(ctx)
+	h1Hash, err := PackageHashV1(provider.PackageDir())
 	if err != nil {
 		return err
 	}
+	hashes := []Hash{h1Hash}
 
-	h1Hash, err := PackageHashV1(provider.PackageDir())
+	documentSHA256Sums, err := provider.DocumentSHA256Sums(ctx)
 	if err != nil {
 		return err
 	}
-	zipHashes := DocumentHashes(documentSHA256Sums)
+	if documentSHA256Sums != nil {
+		zipHashes := DocumentHashes(documentSHA256Sums)
+		hashes = append(hashes, zipHashes...)
+	}
 
-	hashes := append(zipHashes, h1Hash)
 	slices.Sort(hashes)
 
 	providerBlock.Body().SetAttributeRaw("hashes", tokensForListPerLine(hashes))
 	return nil
 }
+
+// getAttributeValueAsString returns a value of Attribute as string. There is no way to get value as string directly, so we parses tokens of Attribute and build string representation.
+func getAttributeValueAsUnquotedString(attr *hclwrite.Attribute) string {
+	// find TokenEqual
+	expr := attr.Expr()
+	exprTokens := expr.BuildTokens(nil)
+
+	// TokenIdent records SpaceBefore, but we should ignore it here.
+	quotedValue := strings.TrimSpace(string(exprTokens.Bytes()))
+
+	// unquote
+	value := strings.Trim(quotedValue, "\"")
+
+	return value
+}
+
+// tokensForListPerLine builds a hclwrite.Tokens for a given hashes, but breaks the line for each element.
+func tokensForListPerLine(hashes []Hash) hclwrite.Tokens {
+	// The original TokensForValue implementation does not break line by line for hashes, so we build a token sequence by ourselves.
+	tokens := append(hclwrite.Tokens{},
+		&hclwrite.Token{Type: hclsyntax.TokenOBrack, Bytes: []byte{'['}},
+		&hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}})
+
+	for _, hash := range hashes {
+		ts := hclwrite.TokensForValue(cty.StringVal(hash.String()))
+		tokens = append(tokens, ts...)
+		tokens = append(tokens,
+			&hclwrite.Token{Type: hclsyntax.TokenComma, Bytes: []byte{','}},
+			&hclwrite.Token{Type: hclsyntax.TokenNewline, Bytes: []byte{'\n'}})
+	}
+
+	tokens = append(tokens, &hclwrite.Token{Type: hclsyntax.TokenCBrack, Bytes: []byte{']'}})
+	return tokens
+}
diff --git a/terraform/getproviders/package.go b/terraform/getproviders/package.go
deleted file mode 100644
index 0e9643aee5..0000000000
--- a/terraform/getproviders/package.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package getproviders
-
-import (
-	"bytes"
-	"context"
-	"crypto/sha256"
-	"encoding/hex"
-	"fmt"
-
-	"github.com/gruntwork-io/go-commons/errors"
-	"github.com/gruntwork-io/terragrunt/util"
-)
-
-// SigningKey represents a key used to sign packages from a registry, along with an optional trust signature from the registry operator. These are both in ASCII armored OpenPGP format.
-type SigningKey struct {
-	ASCIIArmor     string `json:"ascii_armor"`
-	TrustSignature string `json:"trust_signature"`
-}
-
-type SigningKeyList struct {
-	GPGPublicKeys []*SigningKey `json:"gpg_public_keys"`
-}
-
-// Package represents the details of the Terraform provider.
-type Package struct {
-	Protocols []string `json:"protocols"`
-	Filename  string   `json:"filename"`
-	OS        string   `json:"os"`
-	Arch      string   `json:"arch"`
-
-	DownloadURL            string `json:"download_url"`
-	SHA256SumsURL          string `json:"shasums_url"`
-	SHA256SumsSignatureURL string `json:"shasums_signature_url"`
-
-	SHA256Sum   string         `json:"shasum"`
-	SigningKeys SigningKeyList `json:"signing_keys"`
-}
-
-func (provider *Package) Checksum() ([sha256.Size]byte, error) {
-	var checksum [sha256.Size]byte
-
-	if _, err := hex.Decode(checksum[:], []byte(provider.SHA256Sum)); err != nil {
-		return checksum, errors.Errorf("registry response includes invalid SHA256 hash %q: %w", provider.SHA256Sum, err)
-	}
-	return checksum, nil
-}
-
-func (provider *Package) FetchSignature(ctx context.Context) ([]byte, error) {
-	var signature = new(bytes.Buffer)
-
-	if err := util.Fetch(ctx, provider.SHA256SumsSignatureURL, signature); err != nil {
-		return nil, fmt.Errorf("failed to retrieve authentication checksums: %w", err)
-	}
-
-	return signature.Bytes(), nil
-}
-
-func (provider *Package) FetchSHA256Sums(ctx context.Context) ([]byte, error) {
-	var document = new(bytes.Buffer)
-
-	if err := util.Fetch(ctx, provider.SHA256SumsURL, document); err != nil {
-		return nil, fmt.Errorf("failed to retrieve authentication checksums: %w", err)
-	}
-
-	return document.Bytes(), nil
-}
-
-func (provider *Package) FetchArchive(ctx context.Context, saveTo string) error {
-	return util.FetchToFile(ctx, provider.DownloadURL, saveTo)
-}
diff --git a/terraform/getproviders/package_authentication.go b/terraform/getproviders/package_authentication.go
index ee0e9e49bc..216b5ddf47 100644
--- a/terraform/getproviders/package_authentication.go
+++ b/terraform/getproviders/package_authentication.go
@@ -188,11 +188,11 @@ func (auth matchingChecksumAuthentication) Authenticate(location string) (*Packa
 type signatureAuthentication struct {
 	Document  []byte
 	Signature []byte
-	Keys      []SigningKey
+	Keys      map[string]string
 }
 
 // NewSignatureAuthentication returns a PackageAuthentication implementation that verifies the cryptographic signature for a package against any of the provided keys.
-func NewSignatureAuthentication(document, signature []byte, keys []SigningKey) PackageAuthentication {
+func NewSignatureAuthentication(document, signature []byte, keys map[string]string) PackageAuthentication {
 	return signatureAuthentication{
 		Document:  document,
 		Signature: signature,
@@ -202,7 +202,7 @@ func NewSignatureAuthentication(document, signature []byte, keys []SigningKey) P
 
 func (auth signatureAuthentication) Authenticate(location string) (*PackageAuthenticationResult, error) {
 	// Find the key that signed the checksum file. This can fail if there is no valid signature for any of the provided keys.
-	signingKey, err := auth.findSigningKey()
+	asciiArmor, trustSignature, err := auth.findSigningKey()
 	if err != nil {
 		return nil, err
 	}
@@ -218,18 +218,18 @@ func (auth signatureAuthentication) Authenticate(location string) (*PackageAuthe
 	}
 
 	// If the signing key has a trust signature, attempt to verify it with the HashiCorp partners public key.
-	if signingKey.TrustSignature != "" {
+	if trustSignature != "" {
 		hashicorpPartnersKeyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(HashicorpPartnersKey))
 		if err != nil {
 			return nil, errors.Errorf("error creating HashiCorp Partners keyring: %s", err)
 		}
 
-		authorKey, err := openpgpArmor.Decode(strings.NewReader(signingKey.ASCIIArmor))
+		authorKey, err := openpgpArmor.Decode(strings.NewReader(asciiArmor))
 		if err != nil {
 			return nil, errors.Errorf("error decoding signing key: %s", err)
 		}
 
-		trustSignature, err := openpgpArmor.Decode(strings.NewReader(signingKey.TrustSignature))
+		trustSignature, err := openpgpArmor.Decode(strings.NewReader(trustSignature))
 		if err != nil {
 			return nil, errors.Errorf("error decoding trust signature: %s", err)
 		}
@@ -262,11 +262,11 @@ func (auth signatureAuthentication) AcceptableHashes() []Hash {
 }
 
 // findSigningKey attempts to verify the signature using each of the keys returned by the registry. If a valid signature is found, it returns the signing key.
-func (auth signatureAuthentication) findSigningKey() (*SigningKey, error) {
-	for _, key := range auth.Keys {
-		keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(key.ASCIIArmor))
+func (auth signatureAuthentication) findSigningKey() (string, string, error) {
+	for asciiArmor, trustSignature := range auth.Keys {
+		keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(asciiArmor))
 		if err != nil {
-			return nil, errors.Errorf("error decoding signing key: %s", err)
+			return "", "", errors.Errorf("error decoding signing key: %s", err)
 		}
 
 		if err := auth.checkDetachedSignature(keyring, bytes.NewReader(auth.Document), bytes.NewReader(auth.Signature), nil); err != nil {
@@ -276,12 +276,12 @@ func (auth signatureAuthentication) findSigningKey() (*SigningKey, error) {
 			}
 
 			// Any other signature error is terminal.
-			return nil, errors.Errorf("error checking signature: %s", err)
+			return "", "", errors.Errorf("error checking signature: %s", err)
 		}
 
-		return &key, nil
+		return asciiArmor, trustSignature, nil
 	}
 
 	// If none of the provided keys issued the signature, this package is unsigned. This is currently a terminal authentication error.
-	return nil, errors.Errorf("authentication signature from unknown issuer")
+	return "", "", errors.Errorf("authentication signature from unknown issuer")
 }
diff --git a/terraform/getproviders/provider.go b/terraform/getproviders/provider.go
index 4e4fdde1ea..77dd7ebe3a 100644
--- a/terraform/getproviders/provider.go
+++ b/terraform/getproviders/provider.go
@@ -2,11 +2,7 @@
 
 package getproviders
 
-import (
-	"context"
-)
-
-type Providers []Provider
+import "context"
 
 type Provider interface {
 	// Address returns a source address of the provider. e.g.: registry.terraform.io/hashicorp/aws
diff --git a/util/file.go b/util/file.go
index 812934376b..6165d4d774 100644
--- a/util/file.go
+++ b/util/file.go
@@ -645,3 +645,16 @@ func GetCacheDir() (string, error) {
 
 	return cacheDir, nil
 }
+
+// GetTempDir returns the global terragrunt temp directory.
+func GetTempDir() (string, error) {
+	tempDir := filepath.Join(os.TempDir(), "terragrunt")
+
+	if !FileExists(tempDir) {
+		if err := os.MkdirAll(tempDir, os.ModePerm); err != nil {
+			return "", errors.WithStackTrace(err)
+		}
+	}
+
+	return tempDir, nil
+}


Test patch:
diff --git a/terraform/cache/server_test.go b/cli/provider_cache_test.go
similarity index 60%
rename from terraform/cache/server_test.go
rename to cli/provider_cache_test.go
index 9f6acda9ee..4ff8ee2640 100644
--- a/terraform/cache/server_test.go
+++ b/cli/provider_cache_test.go
@@ -1,4 +1,4 @@
-package cache
+package cli
 
 import (
 	"context"
@@ -12,7 +12,10 @@ import (
 	"testing"
 
 	"github.com/google/uuid"
+	"github.com/gruntwork-io/terragrunt/terraform/cache"
 	"github.com/gruntwork-io/terragrunt/terraform/cache/handlers"
+	"github.com/gruntwork-io/terragrunt/terraform/cache/services"
+	"github.com/gruntwork-io/terragrunt/terraform/cliconfig"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"golang.org/x/sync/errgroup"
@@ -32,10 +35,10 @@ func createFakeProvider(t *testing.T, cacheDir, relativePath string) string {
 	return relativePath
 }
 
-func TestServer(t *testing.T) {
+func TestProviderCache(t *testing.T) {
 	t.Parallel()
 
-	token := fmt.Sprintf("%s:%s", handlers.AuthorizationApiKeyHeaderName, uuid.New().String())
+	token := fmt.Sprintf("%s:%s", apiKeyAuth, uuid.New().String())
 
 	providerCacheDir, err := os.MkdirTemp("", "*")
 	require.NoError(t, err)
@@ -43,10 +46,10 @@ func TestServer(t *testing.T) {
 	pluginCacheDir, err := os.MkdirTemp("", "*")
 	require.NoError(t, err)
 
-	opts := []Option{WithToken(token), WithProviderCacheDir(providerCacheDir), WithUserProviderDir(pluginCacheDir)}
+	opts := []cache.Option{cache.WithToken(token)}
 
 	testCases := []struct {
-		opts               []Option
+		opts               []cache.Option
 		urlPath            string
 		expectedStatusCode int
 		expectedBodyReg    *regexp.Regexp
@@ -59,7 +62,7 @@ func TestServer(t *testing.T) {
 			expectedBodyReg:    regexp.MustCompile(regexp.QuoteMeta(`{"providers.v1":"/v1/providers"}`)),
 		},
 		{
-			opts:               append(opts, WithToken("")),
+			opts:               append(opts, cache.WithToken("")),
 			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/versions",
 			expectedStatusCode: http.StatusUnauthorized,
 		},
@@ -75,48 +78,6 @@ func TestServer(t *testing.T) {
 			expectedStatusCode: http.StatusLocked,
 			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
 		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
-		{
-			opts:               opts,
-			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
-			expectedStatusCode: http.StatusLocked,
-			expectedCachePath:  "registry.terraform.io/hashicorp/aws/5.36.0/darwin_arm64/terraform-provider-aws_v5.36.0_x5",
-		},
 		{
 			opts:               opts,
 			urlPath:            "/v1/providers/cache/registry.terraform.io/hashicorp/template/2.2.0/download/linux/amd64",
@@ -133,7 +94,7 @@ func TestServer(t *testing.T) {
 			opts:               opts,
 			urlPath:            "/v1/providers//registry.terraform.io/hashicorp/aws/5.36.0/download/darwin/arm64",
 			expectedStatusCode: http.StatusOK,
-			expectedBodyReg:    regexp.MustCompile(`\{.*` + regexp.QuoteMeta(`"download_url":"http://127.0.0.1:`) + `\d+` + regexp.QuoteMeta(`/downloads/provider/releases.hashicorp.com/terraform-provider-aws/5.36.0/terraform-provider-aws_5.36.0_darwin_arm64.zip"`) + `.*\}`),
+			expectedBodyReg:    regexp.MustCompile(`\{.*` + regexp.QuoteMeta(`"download_url":"http://127.0.0.1:`) + `\d+` + regexp.QuoteMeta(`/downloads/releases.hashicorp.com/terraform-provider-aws/5.36.0/terraform-provider-aws_5.36.0_darwin_arm64.zip"`) + `.*\}`),
 		},
 	}
 	//
@@ -149,7 +110,12 @@ func TestServer(t *testing.T) {
 
 			errGroup, ctx := errgroup.WithContext(ctx)
 
-			server := NewServer(testCase.opts...)
+			providerService := services.NewProviderService(providerCacheDir, pluginCacheDir)
+			providerHandler := handlers.NewProviderDirectHandler(providerService, cacheProviderHTTPStatusCode, new(cliconfig.ProviderInstallationDirect))
+
+			testCase.opts = append(testCase.opts, cache.WithServices(providerService), cache.WithProviderHandlers(providerHandler))
+
+			server := cache.NewServer(testCase.opts...)
 			ln, err := server.Listen()
 			require.NoError(t, err)
 			defer ln.Close()
@@ -158,8 +124,7 @@ func TestServer(t *testing.T) {
 				return server.Run(ctx, ln)
 			})
 
-			urlPath := server.ProviderURL()
-
+			urlPath := server.ProviderController.URL()
 			urlPath.Path = testCase.urlPath
 
 			req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlPath.String(), nil)
@@ -178,7 +143,7 @@ func TestServer(t *testing.T) {
 				assert.Regexp(t, testCase.expectedBodyReg, string(body))
 			}
 
-			server.Provider.WaitForCacheReady("")
+			providerService.WaitForCacheReady("")
 
 			if testCase.expectedCachePath != "" {
 				assert.FileExists(t, filepath.Join(providerCacheDir, testCase.expectedCachePath))
diff --git a/terraform/cliconfig/config_test.go b/terraform/cliconfig/config_test.go
index 89910edbe0..e92ea5abcf 100644
--- a/terraform/cliconfig/config_test.go
+++ b/terraform/cliconfig/config_test.go
@@ -12,32 +12,36 @@ import (
 func TestConfig(t *testing.T) {
 	t.Parallel()
 
+	var (
+		include = []string{"registry.terraform.io/*/*"}
+		exclude = []string{"registry.opentofu.org/*/*"}
+	)
+
 	tempCacheDir, err := os.MkdirTemp("", "*")
 	assert.NoError(t, err)
 
 	testCases := []struct {
-		filesystemMethod *ProviderInstallationFilesystemMirror
-		directMethod     *ProviderInstallationDirect
-		hosts            map[string]map[string]string
-		config           *Config
-		expectedHCL      string
+		providerInstallationMethods []ProviderInstallationMethod
+		hosts                       []ConfigHost
+		config                      Config
+		expectedHCL                 string
 	}{
 		{
-			filesystemMethod: NewProviderInstallationFilesystemMirror(tempCacheDir, []string{"registry.terraform.io/*/*", "registry.opentofu.org/*/*"}, nil),
-			directMethod:     NewProviderInstallationDirect([]string{"registry.terraform.io/*/*", "registry.opentofu.org/*/*"}, nil),
-			hosts: map[string]map[string]string{
-				"registry.terraform.io": map[string]string{
-					"providers.v1": "http://localhost:5758/v1/providers/registry.terraform.io/",
-				},
+			providerInstallationMethods: []ProviderInstallationMethod{
+				NewProviderInstallationFilesystemMirror(tempCacheDir, include, exclude),
+				NewProviderInstallationNetworkMirror("https://network-mirror.io/providers/", include, exclude),
+				NewProviderInstallationDirect(include, exclude),
+			},
+			hosts: []ConfigHost{
+				{"registry.terraform.io", map[string]string{"providers.v1": "http://localhost:5758/v1/providers/registry.terraform.io/"}},
 			},
-			config: &Config{
-				rawHCL: []byte(`
-disable_checkpoint = true
-plugin_cache_dir   = "path/to/plugin/cache/dir"`),
+			config: Config{
+				DisableCheckpoint: true,
+				PluginCacheDir:    "path/to/plugin/cache/dir1",
 			},
-			expectedHCL: `
-disable_checkpoint = true
-plugin_cache_dir = ""
+			expectedHCL: `disable_checkpoint           = true
+disable_checkpoint_signature = false
+plugin_cache_dir             = "path/to/plugin/cache/dir1"
 
 host "registry.terraform.io" {
   services = {
@@ -47,27 +51,34 @@ host "registry.terraform.io" {
 
 provider_installation {
 
-  filesystem_mirror {
+   "filesystem_mirror" {
     path    = "` + tempCacheDir + `"
-    include = ["registry.terraform.io/*/*", "registry.opentofu.org/*/*"]
+    include = ["registry.terraform.io/*/*"]
+    exclude = ["registry.opentofu.org/*/*"]
   }
-
-  direct {
-    include = ["registry.terraform.io/*/*", "registry.opentofu.org/*/*"]
+   "network_mirror" {
+    url     = "https://network-mirror.io/providers/"
+    include = ["registry.terraform.io/*/*"]
+    exclude = ["registry.opentofu.org/*/*"]
+  }
+   "direct" {
+    include = ["registry.terraform.io/*/*"]
+    exclude = ["registry.opentofu.org/*/*"]
   }
 }
 `,
 		},
 		{
-			config: &Config{
-				rawHCL: []byte(`
-disable_checkpoint = true
-plugin_cache_dir   = "path/to/plugin/cache/dir"`),
-				PluginCacheDir: tempCacheDir,
+			config: Config{
+				DisableCheckpoint: false,
+				PluginCacheDir:    tempCacheDir,
 			},
-			expectedHCL: `
-disable_checkpoint = true
-plugin_cache_dir = "` + tempCacheDir + `"
+			expectedHCL: `disable_checkpoint           = false
+disable_checkpoint_signature = false
+plugin_cache_dir             = "` + tempCacheDir + `"
+
+provider_installation {
+}
 `,
 		},
 	}
@@ -82,10 +93,10 @@ plugin_cache_dir = "` + tempCacheDir + `"
 			assert.NoError(t, err)
 			configFile := filepath.Join(tempDir, ".terraformrc")
 
-			for host, service := range testCase.hosts {
-				testCase.config.AddHost(host, service)
+			for _, host := range testCase.hosts {
+				testCase.config.AddHost(host.Name, host.Services)
 			}
-			testCase.config.SetProviderInstallation(testCase.filesystemMethod, testCase.directMethod)
+			testCase.config.AddProviderInstallationMethods(testCase.providerInstallationMethods...)
 
 			err = testCase.config.Save(configFile)
 			assert.NoError(t, err)
diff --git a/terraform/getproviders/lock_test.go b/terraform/getproviders/lock_test.go
index c88efe5f05..920be4ddf2 100644
--- a/terraform/getproviders/lock_test.go
+++ b/terraform/getproviders/lock_test.go
@@ -49,12 +49,12 @@ func TestUpdateLockfile(t *testing.T) {
 	t.Parallel()
 
 	testCases := []struct {
-		providers        Providers
+		providers        []Provider
 		initialLockfile  string
 		expectedLockfile string
 	}{
 		{
-			Providers{
+			[]Provider{
 				mockProviderUpdateLock(t, "registry.terraform.io/hashicorp/aws", "5.37.0"),
 			},
 			``,
@@ -71,7 +71,7 @@ provider "registry.terraform.io/hashicorp/aws" {
 `,
 		},
 		{
-			Providers{
+			[]Provider{
 				mockProviderUpdateLock(t, "registry.terraform.io/hashicorp/aws", "5.36.0"),
 				mockProviderUpdateLock(t, "registry.terraform.io/hashicorp/template", "2.2.0"),
 			},
diff --git a/terraform/getproviders/package_authentication_test.go b/terraform/getproviders/package_authentication_test.go
index eb3c506c46..56936834d2 100644
--- a/terraform/getproviders/package_authentication_test.go
+++ b/terraform/getproviders/package_authentication_test.go
@@ -259,7 +259,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 		path           string
 		document       []byte
 		signature      string
-		keys           []SigningKey
+		keys           map[string]string
 		expectedResult *PackageAuthenticationResult
 		expectedErr    error
 	}{
@@ -267,7 +267,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 			"testdata/my-package.zip",
 			[]byte(testProviderShaSums),
 			testHashicorpSignatureGoodBase64,
-			[]SigningKey{{ASCIIArmor: HashicorpPublicKey}},
+			map[string]string{HashicorpPublicKey: ""},
 			NewPackageAuthenticationResult(officialProvider),
 			nil,
 		},
@@ -275,7 +275,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 			"testdata/my-package.zip",
 			[]byte("example shasums data"),
 			testHashicorpSignatureGoodBase64,
-			[]SigningKey{{ASCIIArmor: "invalid PGP armor value"}},
+			map[string]string{"invalid PGP armor value": ""},
 			nil,
 			errors.New("error decoding signing key: openpgp: invalid argument: no armored data found"),
 		},
@@ -283,7 +283,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 			"testdata/my-package.zip",
 			[]byte("example shasums data"),
 			testSignatureBadBase64,
-			[]SigningKey{{ASCIIArmor: testAuthorKeyArmor}},
+			map[string]string{testAuthorKeyArmor: ""},
 			nil,
 			errors.New("error checking signature: openpgp: invalid data: signature subpacket truncated"),
 		},
@@ -291,7 +291,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 			"testdata/my-package.zip",
 			[]byte("example shasums data"),
 			testAuthorSignatureGoodBase64,
-			[]SigningKey{{ASCIIArmor: HashicorpPublicKey}},
+			map[string]string{HashicorpPublicKey: ""},
 			nil,
 			errors.New("authentication signature from unknown issuer"),
 		},
@@ -299,7 +299,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 			"testdata/my-package.zip",
 			[]byte("example shasums data"),
 			testAuthorSignatureGoodBase64,
-			[]SigningKey{{ASCIIArmor: testAuthorKeyArmor, TrustSignature: "invalid PGP armor value"}},
+			map[string]string{testAuthorKeyArmor: "invalid PGP armor value"},
 			nil,
 			errors.New("error decoding trust signature: EOF"),
 		},
@@ -307,7 +307,7 @@ func TestSignatureAuthenticate(t *testing.T) {
 			"testdata/my-package.zip",
 			[]byte("example shasums data"),
 			testAuthorSignatureGoodBase64,
-			[]SigningKey{{ASCIIArmor: testAuthorKeyArmor, TrustSignature: testOtherKeyTrustSignatureArmor}},
+			map[string]string{testAuthorKeyArmor: testOtherKeyTrustSignatureArmor},
 			nil,
 			errors.New("error verifying trust signature: openpgp: invalid signature: RSA verification failure"),
 		},
diff --git a/test/cliconfig.go b/test/cliconfig.go
new file mode 100644
index 0000000000..90c52bd728
--- /dev/null
+++ b/test/cliconfig.go
@@ -0,0 +1,74 @@
+// common integration test functions
+package test
+
+import (
+	"html/template"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+var testCLIConfigTemplate = `
+{{ if or (gt (len .FilesystemMirrorMethods) 0) (gt (len .NetworkMirrorMethods) 0) (gt (len .DirectMethods) 0) }}
+provider_installation {
+{{ if gt (len .FilesystemMirrorMethods) 0 }}{{ range $method := .FilesystemMirrorMethods }}
+  filesystem_mirror {
+    path    = "{{ $method.Path }}"
+{{ if gt (len $method.Include) 0 }}
+    include = [{{ range $index, $include := $method.Include }}{{ if $index }},{{ end }}"{{ $include }}"{{ end }}]
+{{ end }}{{ if gt (len $method.Exclude) 0 }}
+    include = [{{ range $index, $exclude := $method.Exclude }}{{ if $index }},{{ end }}"{{ $exclude }}"{{ end }}]
+{{ end }}
+  }
+{{ end }}{{ end }}
+{{ if gt (len .NetworkMirrorMethods) 0 }}{{ range $method := .NetworkMirrorMethods }}
+  network_mirror {
+    url    = "{{ $method.URL }}"
+{{ if gt (len $method.Include) 0 }}
+    include = [{{ range $index, $include := $method.Include }}{{ if $index }},{{ end }}"{{ $include }}"{{ end }}]
+{{ end }}{{ if gt (len $method.Exclude) 0 }}
+    include = [{{ range $index, $exclude := $method.Exclude }}{{ if $index }},{{ end }}"{{ $exclude }}"{{ end }}]
+{{ end }}
+  }
+{{ end }}{{ end }}
+{{ if gt (len .DirectMethods) 0 }}{{ range $method := .DirectMethods }}
+  direct {
+{{ if gt (len $method.Include) 0 }}
+    include = [{{ range $index, $include := $method.Include }}{{ if $index }},{{ end }}"{{ $include }}"{{ end }}]
+{{ end }}{{ if gt (len $method.Exclude) 0 }}
+    include = [{{ range $index, $exclude := $method.Exclude }}{{ if $index }},{{ end }}"{{ $exclude }}"{{ end }}]
+{{ end }}
+  }
+{{ end }}{{ end }}
+}
+{{ end }}
+`
+
+type CLIConfigProviderInstallationFilesystemMirror struct {
+	Path             string
+	Include, Exclude []string
+}
+
+type CLIConfigProviderInstallationNetworkMirror struct {
+	URL              string
+	Include, Exclude []string
+}
+
+type CLIConfigProviderInstallationDirect struct {
+	Include, Exclude []string
+}
+
+type CLIConfigSettings struct {
+	FilesystemMirrorMethods []CLIConfigProviderInstallationFilesystemMirror
+	NetworkMirrorMethods    []CLIConfigProviderInstallationNetworkMirror
+	DirectMethods           []CLIConfigProviderInstallationDirect
+}
+
+func createCLIConfig(t *testing.T, file *os.File, settings *CLIConfigSettings) {
+	tmp, err := template.New("cliconfig").Parse(testCLIConfigTemplate)
+	require.NoError(t, err)
+
+	err = tmp.Execute(file, settings)
+	require.NoError(t, err)
+}
diff --git a/test/fixture-provider-cache/first/app/main.tf b/test/fixture-provider-cache/direct/first/app/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app/main.tf
rename to test/fixture-provider-cache/direct/first/app/main.tf
diff --git a/test/fixture-provider-cache/first/app/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app1/main.tf b/test/fixture-provider-cache/direct/first/app1/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app1/main.tf
rename to test/fixture-provider-cache/direct/first/app1/main.tf
diff --git a/test/fixture-provider-cache/first/app1/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app1/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app1/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app1/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app2/main.tf b/test/fixture-provider-cache/direct/first/app2/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app2/main.tf
rename to test/fixture-provider-cache/direct/first/app2/main.tf
diff --git a/test/fixture-provider-cache/first/app2/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app2/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app2/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app2/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app3/main.tf b/test/fixture-provider-cache/direct/first/app3/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app3/main.tf
rename to test/fixture-provider-cache/direct/first/app3/main.tf
diff --git a/test/fixture-provider-cache/first/app3/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app3/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app3/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app3/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app4/main.tf b/test/fixture-provider-cache/direct/first/app4/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app4/main.tf
rename to test/fixture-provider-cache/direct/first/app4/main.tf
diff --git a/test/fixture-provider-cache/first/app4/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app4/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app4/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app4/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app5/main.tf b/test/fixture-provider-cache/direct/first/app5/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app5/main.tf
rename to test/fixture-provider-cache/direct/first/app5/main.tf
diff --git a/test/fixture-provider-cache/first/app5/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app5/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app5/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app5/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app6/main.tf b/test/fixture-provider-cache/direct/first/app6/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app6/main.tf
rename to test/fixture-provider-cache/direct/first/app6/main.tf
diff --git a/test/fixture-provider-cache/first/app6/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app6/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app6/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app6/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app7/main.tf b/test/fixture-provider-cache/direct/first/app7/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app7/main.tf
rename to test/fixture-provider-cache/direct/first/app7/main.tf
diff --git a/test/fixture-provider-cache/first/app7/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app7/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app7/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app7/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app8/main.tf b/test/fixture-provider-cache/direct/first/app8/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app8/main.tf
rename to test/fixture-provider-cache/direct/first/app8/main.tf
diff --git a/test/fixture-provider-cache/first/app8/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app8/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app8/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app8/terragrunt.hcl
diff --git a/test/fixture-provider-cache/first/app9/main.tf b/test/fixture-provider-cache/direct/first/app9/main.tf
similarity index 100%
rename from test/fixture-provider-cache/first/app9/main.tf
rename to test/fixture-provider-cache/direct/first/app9/main.tf
diff --git a/test/fixture-provider-cache/first/app9/terragrunt.hcl b/test/fixture-provider-cache/direct/first/app9/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/first/app9/terragrunt.hcl
rename to test/fixture-provider-cache/direct/first/app9/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app/main.tf b/test/fixture-provider-cache/direct/second/app/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app/main.tf
rename to test/fixture-provider-cache/direct/second/app/main.tf
diff --git a/test/fixture-provider-cache/second/app/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app1/main.tf b/test/fixture-provider-cache/direct/second/app1/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app1/main.tf
rename to test/fixture-provider-cache/direct/second/app1/main.tf
diff --git a/test/fixture-provider-cache/second/app1/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app1/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app1/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app1/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app2/main.tf b/test/fixture-provider-cache/direct/second/app2/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app2/main.tf
rename to test/fixture-provider-cache/direct/second/app2/main.tf
diff --git a/test/fixture-provider-cache/second/app2/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app2/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app2/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app2/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app3/main.tf b/test/fixture-provider-cache/direct/second/app3/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app3/main.tf
rename to test/fixture-provider-cache/direct/second/app3/main.tf
diff --git a/test/fixture-provider-cache/second/app3/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app3/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app3/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app3/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app4/main.tf b/test/fixture-provider-cache/direct/second/app4/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app4/main.tf
rename to test/fixture-provider-cache/direct/second/app4/main.tf
diff --git a/test/fixture-provider-cache/second/app4/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app4/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app4/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app4/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app5/main.tf b/test/fixture-provider-cache/direct/second/app5/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app5/main.tf
rename to test/fixture-provider-cache/direct/second/app5/main.tf
diff --git a/test/fixture-provider-cache/second/app5/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app5/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app5/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app5/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app6/main.tf b/test/fixture-provider-cache/direct/second/app6/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app6/main.tf
rename to test/fixture-provider-cache/direct/second/app6/main.tf
diff --git a/test/fixture-provider-cache/second/app6/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app6/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app6/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app6/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app7/main.tf b/test/fixture-provider-cache/direct/second/app7/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app7/main.tf
rename to test/fixture-provider-cache/direct/second/app7/main.tf
diff --git a/test/fixture-provider-cache/second/app7/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app7/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app7/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app7/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app8/main.tf b/test/fixture-provider-cache/direct/second/app8/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app8/main.tf
rename to test/fixture-provider-cache/direct/second/app8/main.tf
diff --git a/test/fixture-provider-cache/second/app8/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app8/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app8/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app8/terragrunt.hcl
diff --git a/test/fixture-provider-cache/second/app9/main.tf b/test/fixture-provider-cache/direct/second/app9/main.tf
similarity index 100%
rename from test/fixture-provider-cache/second/app9/main.tf
rename to test/fixture-provider-cache/direct/second/app9/main.tf
diff --git a/test/fixture-provider-cache/second/app9/terragrunt.hcl b/test/fixture-provider-cache/direct/second/app9/terragrunt.hcl
similarity index 100%
rename from test/fixture-provider-cache/second/app9/terragrunt.hcl
rename to test/fixture-provider-cache/direct/second/app9/terragrunt.hcl
diff --git a/test/fixture-provider-cache/filesystem-mirror/app/main.tf b/test/fixture-provider-cache/filesystem-mirror/app/main.tf
new file mode 100644
index 0000000000..ac2941102b
--- /dev/null
+++ b/test/fixture-provider-cache/filesystem-mirror/app/main.tf
@@ -0,0 +1,12 @@
+terraform {
+  required_providers {
+    null = {
+      source  = "example.com/hashicorp/null"
+      version = "3.2.2"
+    }
+  }
+}
+
+provider "null" {
+  # Configuration options
+}
diff --git a/test/fixture-provider-cache/filesystem-mirror/app/terragrunt.hcl b/test/fixture-provider-cache/filesystem-mirror/app/terragrunt.hcl
new file mode 100644
index 0000000000..bb7b160deb
--- /dev/null
+++ b/test/fixture-provider-cache/filesystem-mirror/app/terragrunt.hcl
@@ -0,0 +1,1 @@
+# Intentionally empty
diff --git a/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/3.2.2.json b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/3.2.2.json
new file mode 100644
index 0000000000..0b18cdc522
--- /dev/null
+++ b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/3.2.2.json
@@ -0,0 +1,22 @@
+{
+  "archives": {
+    "darwin_arm64": {
+      "hashes": [
+        "h1:IMVAUHKoydFrlPrl9OzasDnw/8ntZFerCC9iXw1rXQY="
+      ],
+      "url": "terraform-provider-null_3.2.2_darwin_arm64.zip"
+    },
+    "linux_amd64": {
+      "hashes": [
+        "h1:zT1ZbegaAYHwQa+QwIFugArWikRJI9dqohj8xb0GY88="
+      ],
+      "url": "terraform-provider-null_3.2.2_linux_amd64.zip"
+    },
+    "linux_arm64": {
+      "hashes": [
+        "h1:Gef5VGfobY5uokA5nV/zFvWeMNR2Pmq79DH94QnNZPM="
+      ],
+      "url": "terraform-provider-null_3.2.2_linux_arm64.zip"
+    }
+  }
+}
\ No newline at end of file
diff --git a/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/index.json b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/index.json
new file mode 100644
index 0000000000..31393958f6
--- /dev/null
+++ b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/index.json
@@ -0,0 +1,5 @@
+{
+  "versions": {
+    "3.2.2": {}
+  }
+}
\ No newline at end of file
diff --git a/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_darwin_arm64.zip b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_darwin_arm64.zip
new file mode 100644
index 0000000000..f5be35078c
Binary files /dev/null and b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_darwin_arm64.zip differ
diff --git a/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_linux_amd64.zip b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_linux_amd64.zip
new file mode 100644
index 0000000000..89c67caca1
Binary files /dev/null and b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_linux_amd64.zip differ
diff --git a/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_linux_arm64.zip b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_linux_arm64.zip
new file mode 100644
index 0000000000..b221934404
Binary files /dev/null and b/test/fixture-provider-cache/filesystem-mirror/providers/example.com/hashicorp/null/terraform-provider-null_3.2.2_linux_arm64.zip differ
diff --git a/test/integration_serial_test.go b/test/integration_serial_test.go
index da783e8c21..86e69bb1c2 100644
--- a/test/integration_serial_test.go
+++ b/test/integration_serial_test.go
@@ -2,6 +2,7 @@ package test
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"fmt"
 	"math"
@@ -18,11 +19,51 @@ import (
 
 	terragruntinfo "github.com/gruntwork-io/terragrunt/cli/commands/terragrunt-info"
 	"github.com/gruntwork-io/terragrunt/config"
+	"github.com/gruntwork-io/terragrunt/terraform"
 	"github.com/gruntwork-io/terragrunt/util"
 )
 
 // NOTE: We don't run these tests in parallel because it modifies the environment variable, so it can affect other tests
 
+func TestTerragruntProviderCacheWithFilesystemMirror(t *testing.T) {
+	// In this test we use os.Setenv to set the Terraform env var TF_CLI_CONFIG_FILE.
+
+	cleanupTerraformFolder(t, TEST_FIXTURE_PROVIDER_CACHE_FILESYSTEM_MIRROR)
+	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_PROVIDER_CACHE_FILESYSTEM_MIRROR)
+	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_PROVIDER_CACHE_FILESYSTEM_MIRROR)
+
+	appPath := filepath.Join(rootPath, "app")
+	filesystemCachePath := filepath.Join(rootPath, "providers")
+
+	cacheDir, err := util.GetCacheDir()
+	require.NoError(t, err)
+	providerCacheDir := filepath.Join(cacheDir, "provider-cache-test-with-filesystem-mirror")
+
+	ctx := context.Background()
+	defer ctx.Done()
+
+	cliConfigFilename, err := os.CreateTemp("", "*")
+	require.NoError(t, err)
+	defer cliConfigFilename.Close()
+
+	err = os.Setenv(terraform.EnvNameTFCLIConfigFile, cliConfigFilename.Name())
+	require.NoError(t, err)
+	defer os.Unsetenv(terraform.EnvNameTFCLIConfigFile)
+
+	t.Logf("%s=%s", terraform.EnvNameTFCLIConfigFile, cliConfigFilename.Name())
+
+	cliConfigSettings := &CLIConfigSettings{
+		FilesystemMirrorMethods: []CLIConfigProviderInstallationFilesystemMirror{
+			{
+				Path: filesystemCachePath,
+			},
+		},
+	}
+	createCLIConfig(t, cliConfigFilename, cliConfigSettings)
+
+	runTerragrunt(t, fmt.Sprintf("terragrunt run-all init --terragrunt-provider-cache --terragrunt-provider-cache-registry-names example.com --terragrunt-provider-cache-dir %s --terragrunt-log-level trace --terragrunt-non-interactive --terragrunt-working-dir %s", providerCacheDir, appPath))
+}
+
 func TestTerragruntInputsFromDependency(t *testing.T) {
 	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_INPUTS_FROM_DEPENDENCY)
 	rootTerragruntPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_INPUTS_FROM_DEPENDENCY)
diff --git a/test/integration_test.go b/test/integration_test.go
index ef15a13fc2..01657554de 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -60,7 +60,8 @@ const (
 	TERRAFORM_REMOTE_STATE_S3_REGION                                         = "us-west-2"
 	TERRAFORM_REMOTE_STATE_GCP_REGION                                        = "eu"
 	TEST_FIXTURE_PATH                                                        = "fixture/"
-	TEST_FIXTURE_PROVIDER_CACHE                                              = "fixture-provider-cache"
+	TEST_FIXTURE_PROVIDER_CACHE_DIRECT                                       = "fixture-provider-cache/direct"
+	TEST_FIXTURE_PROVIDER_CACHE_FILESYSTEM_MIRROR                            = "fixture-provider-cache/filesystem-mirror"
 	TEST_FIXTURE_DESTROY_ORDER                                               = "fixture-destroy-order"
 	TEST_FIXTURE_CODEGEN_PATH                                                = "fixture-codegen"
 	TEST_FIXTURE_GCS_PATH                                                    = "fixture-gcs/"
@@ -208,15 +209,15 @@ const (
 func TestTerragruntProviderCache(t *testing.T) {
 	t.Parallel()
 
-	cleanupTerraformFolder(t, TEST_FIXTURE_PROVIDER_CACHE)
-	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_PROVIDER_CACHE)
-	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_PROVIDER_CACHE)
+	cleanupTerraformFolder(t, TEST_FIXTURE_PROVIDER_CACHE_DIRECT)
+	tmpEnvPath := copyEnvironment(t, TEST_FIXTURE_PROVIDER_CACHE_DIRECT)
+	rootPath := util.JoinPath(tmpEnvPath, TEST_FIXTURE_PROVIDER_CACHE_DIRECT)
 
 	cacheDir, err := util.GetCacheDir()
-	assert.NoError(t, err)
-	providerCacheDir := filepath.Join(cacheDir, "provider-cache-test")
+	require.NoError(t, err)
+	providerCacheDir := filepath.Join(cacheDir, "provider-cache-test-direct")
 
-	runTerragrunt(t, fmt.Sprintf("terragrunt run-all init --terragrunt-provider-cache --terragrunt-provider-cache-dir %s --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-working-dir %s", providerCacheDir, rootPath))
+	runTerragrunt(t, fmt.Sprintf("terragrunt run-all init --terragrunt-provider-cache --terragrunt-provider-cache-dir %s --terragrunt-log-level trace --terragrunt-non-interactive --terragrunt-working-dir %s", providerCacheDir, rootPath))
 
 	providers := map[string][]string{
 		"first": []string{