Repository: coder/envbuilder (go)

Issue:
Allow mounting in DOCKER_CONFIG again
# Motivation

In https://github.com/coder/envbuilder/pull/336 we temporarily override `DOCKER_CONFIG` to set the config specified by `ENVBUILDER_DOCKER_CONFIG_BASE64`. 

This breaks some existing users who were mounting in a docker config and specifying the `DOCKER_CONFIG` environment variable in their image.

# Proposed solution

Only override `DOCKER_CONFIG` if `ENVBUILDER_DOCKER_CONFIG_BASE64` is set. Otherwise, leave it as-is.

Pull request description:
fix: search `$DOCKER_CONFIG` if no base64 config is provided
Fixes #392


Patch:
diff --git a/Makefile b/Makefile
index ca4c0e6..ce07901 100644
--- a/Makefile
+++ b/Makefile
@@ -29,6 +29,9 @@ develop:
 build: scripts/envbuilder-$(GOARCH)
 	./scripts/build.sh
 
+.PHONY: gen
+gen: docs/env-variables.md update-golden-files
+
 .PHONY: update-golden-files
 update-golden-files: .gen-golden
 
@@ -85,4 +88,4 @@ test-images-pull:
 	docker push localhost:5000/envbuilder-test-ubuntu:latest
 
 .registry-cache/docker/registry/v2/repositories/envbuilder-test-codercom-code-server:
-	docker push localhost:5000/envbuilder-test-codercom-code-server:latest
\ No newline at end of file
+	docker push localhost:5000/envbuilder-test-codercom-code-server:latest
diff --git a/docs/container-registry-auth.md b/docs/container-registry-auth.md
index e0d7663..f14a66d 100644
--- a/docs/container-registry-auth.md
+++ b/docs/container-registry-auth.md
@@ -14,9 +14,19 @@ After you have a configuration that resembles the following:
 }
 ```
 
-`base64` encode the JSON and provide it to envbuilder as the `ENVBUILDER_DOCKER_CONFIG_BASE64` environment variable.
+`base64` encode the JSON and provide it to envbuilder as the
+`ENVBUILDER_DOCKER_CONFIG_BASE64` environment variable.
 
-Alternatively, if running `envbuilder` in Kubernetes, you can create an `ImagePullSecret` and
+Alternatively, the configuration file can be placed in `/.envbuilder/config.json`.
+The `DOCKER_CONFIG` environment variable can be used to define a custom path. The
+path must either be the path to a directory containing `config.json` or the full
+path to the JSON file itself.
+
+> [!NOTE] Providing the docker configuration through other means than the
+> `ENVBUILDER_DOCKER_CONFIG_BASE64` environment variable will leave the
+> configuration file in the container filesystem. This may be a security risk.
+
+When running `envbuilder` in Kubernetes, you can create an `ImagePullSecret` and
 pass it into the pod as a volume mount. This example will work for all registries.
 
 ```shell
diff --git a/docs/env-variables.md b/docs/env-variables.md
index 5513fd5..cb7054f 100644
--- a/docs/env-variables.md
+++ b/docs/env-variables.md
@@ -15,7 +15,7 @@
 | `--dockerfile-path` | `ENVBUILDER_DOCKERFILE_PATH` |  | The relative path to the Dockerfile that will be used to build the workspace. This is an alternative to using a devcontainer that some might find simpler. |
 | `--build-context-path` | `ENVBUILDER_BUILD_CONTEXT_PATH` |  | Can be specified when a DockerfilePath is specified outside the base WorkspaceFolder. This path MUST be relative to the WorkspaceFolder path into which the repo is cloned. |
 | `--cache-ttl-days` | `ENVBUILDER_CACHE_TTL_DAYS` |  | The number of days to use cached layers before expiring them. Defaults to 7 days. |
-| `--docker-config-base64` | `ENVBUILDER_DOCKER_CONFIG_BASE64` |  | The base64 encoded Docker config file that will be used to pull images from private container registries. |
+| `--docker-config-base64` | `ENVBUILDER_DOCKER_CONFIG_BASE64` |  | The base64 encoded Docker config file that will be used to pull images from private container registries. When this is set, Docker configuration set via the DOCKER_CONFIG environment variable is ignored. |
 | `--fallback-image` | `ENVBUILDER_FALLBACK_IMAGE` |  | Specifies an alternative image to use when neither an image is declared in the devcontainer.json file nor a Dockerfile is present. If there's a build failure (from a faulty Dockerfile) or a misconfiguration, this image will be the substitute. Set ExitOnBuildFailure to true to halt the container if the build faces an issue. |
 | `--exit-on-build-failure` | `ENVBUILDER_EXIT_ON_BUILD_FAILURE` |  | Terminates the container upon a build failure. This is handy when preferring the FALLBACK_IMAGE in cases where no devcontainer.json or image is provided. However, it ensures that the container stops if the build process encounters an error. |
 | `--force-safe` | `ENVBUILDER_FORCE_SAFE` |  | Ignores any filesystem safety checks. This could cause serious harm to your system! This is used in cases where bypass is needed to unblock customers. |
diff --git a/envbuilder.go b/envbuilder.go
index 14fd004..b621a85 100644
--- a/envbuilder.go
+++ b/envbuilder.go
@@ -41,6 +41,7 @@ import (
 	"github.com/distribution/distribution/v3/configuration"
 	"github.com/distribution/distribution/v3/registry/handlers"
 	_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
+	dockerconfig "github.com/docker/cli/cli/config"
 	"github.com/docker/cli/cli/config/configfile"
 	"github.com/fatih/color"
 	v1 "github.com/google/go-containerregistry/pkg/v1"
@@ -56,7 +57,7 @@ import (
 var ErrNoFallbackImage = errors.New("no fallback image has been specified")
 
 // DockerConfig represents the Docker configuration file.
-type DockerConfig configfile.ConfigFile
+type DockerConfig = configfile.ConfigFile
 
 type runtimeDataStore struct {
 	// Runtime data.
@@ -154,13 +155,13 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
 
 	opts.Logger(log.LevelInfo, "%s %s - Build development environments from repositories in a container", newColor(color.Bold).Sprintf("envbuilder"), buildinfo.Version())
 
-	cleanupDockerConfigJSON, err := initDockerConfigJSON(opts.Logger, workingDir, opts.DockerConfigBase64)
+	cleanupDockerConfigOverride, err := initDockerConfigOverride(opts.Filesystem, opts.Logger, workingDir, opts.DockerConfigBase64)
 	if err != nil {
 		return err
 	}
 	defer func() {
-		if err := cleanupDockerConfigJSON(); err != nil {
-			opts.Logger(log.LevelError, "failed to cleanup docker config JSON: %w", err)
+		if err := cleanupDockerConfigOverride(); err != nil {
+			opts.Logger(log.LevelError, "failed to cleanup docker config override: %w", err)
 		}
 	}() // best effort
 
@@ -717,6 +718,11 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
 	// Sanitize the environment of any opts!
 	options.UnsetEnv()
 
+	// Remove the Docker config secret file!
+	if err := cleanupDockerConfigOverride(); err != nil {
+		return err
+	}
+
 	// Set the environment from /etc/environment first, so it can be
 	// overridden by the image and devcontainer settings.
 	err = setEnvFromEtcEnvironment(opts.Logger)
@@ -776,11 +782,6 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
 		exportEnvFile.Close()
 	}
 
-	// Remove the Docker config secret file!
-	if err := cleanupDockerConfigJSON(); err != nil {
-		return err
-	}
-
 	if runtimeData.ContainerUser == "" {
 		opts.Logger(log.LevelWarn, "#%d: no user specified, using root", stageNumber)
 	}
@@ -984,13 +985,13 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 
 	opts.Logger(log.LevelInfo, "%s %s - Build development environments from repositories in a container", newColor(color.Bold).Sprintf("envbuilder"), buildinfo.Version())
 
-	cleanupDockerConfigJSON, err := initDockerConfigJSON(opts.Logger, workingDir, opts.DockerConfigBase64)
+	cleanupDockerConfigOverride, err := initDockerConfigOverride(opts.Filesystem, opts.Logger, workingDir, opts.DockerConfigBase64)
 	if err != nil {
 		return nil, err
 	}
 	defer func() {
-		if err := cleanupDockerConfigJSON(); err != nil {
-			opts.Logger(log.LevelError, "failed to cleanup docker config JSON: %w", err)
+		if err := cleanupDockerConfigOverride(); err != nil {
+			opts.Logger(log.LevelError, "failed to cleanup docker config override: %w", err)
 		}
 	}() // best effort
 
@@ -1321,7 +1322,7 @@ func RunCacheProbe(ctx context.Context, opts options.Options) (v1.Image, error)
 	options.UnsetEnv()
 
 	// Remove the Docker config secret file!
-	if err := cleanupDockerConfigJSON(); err != nil {
+	if err := cleanupDockerConfigOverride(); err != nil {
 		return nil, err
 	}
 
@@ -1573,8 +1574,22 @@ func maybeDeleteFilesystem(logger log.Func, force bool) error {
 }
 
 func fileExists(fs billy.Filesystem, path string) bool {
-	_, err := fs.Stat(path)
-	return err == nil
+	fi, err := fs.Stat(path)
+	return err == nil && !fi.IsDir()
+}
+
+func readFile(fs billy.Filesystem, name string) ([]byte, error) {
+	f, err := fs.Open(name)
+	if err != nil {
+		return nil, fmt.Errorf("open file: %w", err)
+	}
+	defer f.Close()
+
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return nil, fmt.Errorf("read file: %w", err)
+	}
+	return b, nil
 }
 
 func copyFile(fs billy.Filesystem, src, dst string, mode fs.FileMode) error {
@@ -1601,6 +1616,21 @@ func copyFile(fs billy.Filesystem, src, dst string, mode fs.FileMode) error {
 	return nil
 }
 
+func writeFile(fs billy.Filesystem, name string, data []byte, perm fs.FileMode) error {
+	f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
+	if err != nil {
+		return fmt.Errorf("create file: %w", err)
+	}
+	_, err = f.Write(data)
+	if err != nil {
+		err = fmt.Errorf("write file: %w", err)
+	}
+	if err2 := f.Close(); err2 != nil && err == nil {
+		err = fmt.Errorf("close file: %w", err2)
+	}
+	return err
+}
+
 func writeMagicImageFile(fs billy.Filesystem, path string, v any) error {
 	file, err := fs.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
 	if err != nil {
@@ -1633,55 +1663,161 @@ func parseMagicImageFile(fs billy.Filesystem, path string, v any) error {
 	return nil
 }
 
-func initDockerConfigJSON(logf log.Func, workingDir workingdir.WorkingDir, dockerConfigBase64 string) (func() error, error) {
-	var cleanupOnce sync.Once
-	noop := func() error { return nil }
-	if dockerConfigBase64 == "" {
-		return noop, nil
+const (
+	dockerConfigFile   = dockerconfig.ConfigFileName
+	dockerConfigEnvKey = dockerconfig.EnvOverrideConfigDir
+)
+
+// initDockerConfigOverride sets the DOCKER_CONFIG environment variable
+// to a path within the working directory. If a base64 encoded Docker
+// config is provided, it is written to the path/config.json and the
+// DOCKER_CONFIG environment variable is set to the path. If no base64
+// encoded Docker config is provided, the following paths are checked in
+// order:
+//
+// 1. $DOCKER_CONFIG/config.json
+// 2. $DOCKER_CONFIG
+// 3. /.envbuilder/config.json
+//
+// If a Docker config file is found, its path is set as DOCKER_CONFIG.
+func initDockerConfigOverride(bfs billy.Filesystem, logf log.Func, workingDir workingdir.WorkingDir, dockerConfigBase64 string) (func() error, error) {
+	// If dockerConfigBase64 is set, it will have priority over file
+	// detection.
+	var dockerConfigJSON []byte
+	var err error
+	if dockerConfigBase64 != "" {
+		logf(log.LevelInfo, "Using base64 encoded Docker config")
+
+		dockerConfigJSON, err = base64.StdEncoding.DecodeString(dockerConfigBase64)
+		if err != nil {
+			return nil, fmt.Errorf("decode docker config: %w", err)
+		}
+	}
+
+	oldDockerConfig := os.Getenv(dockerConfigEnvKey)
+	var oldDockerConfigFile string
+	if oldDockerConfig != "" {
+		oldDockerConfigFile = filepath.Join(oldDockerConfig, dockerConfigFile)
+	}
+	for _, path := range []string{
+		oldDockerConfigFile,               // $DOCKER_CONFIG/config.json
+		oldDockerConfig,                   // $DOCKER_CONFIG
+		workingDir.Join(dockerConfigFile), // /.envbuilder/config.json
+	} {
+		if path == "" || !fileExists(bfs, path) {
+			continue
+		}
+
+		logf(log.LevelWarn, "Found Docker config at %s, this file will remain after the build", path)
+
+		if dockerConfigJSON == nil {
+			logf(log.LevelInfo, "Using Docker config at %s", path)
+
+			dockerConfigJSON, err = readFile(bfs, path)
+			if err != nil {
+				return nil, fmt.Errorf("read docker config: %w", err)
+			}
+		} else {
+			logf(log.LevelWarn, "Ignoring Docker config at %s, using base64 encoded Docker config instead", path)
+		}
+		break
+	}
+
+	if dockerConfigJSON == nil {
+		// No user-provided config available.
+		return func() error { return nil }, nil
+	}
+
+	dockerConfigJSON, err = hujson.Standardize(dockerConfigJSON)
+	if err != nil {
+		return nil, fmt.Errorf("humanize json for docker config: %w", err)
 	}
-	cfgPath := workingDir.Join("config.json")
-	decoded, err := base64.StdEncoding.DecodeString(dockerConfigBase64)
+
+	if err = logDockerAuthConfigs(logf, dockerConfigJSON); err != nil {
+		return nil, fmt.Errorf("log docker auth configs: %w", err)
+	}
+
+	// We're going to set the DOCKER_CONFIG environment variable to a
+	// path within the working directory so that Kaniko can pick it up.
+	// A user should not mount a file directly to this path as we will
+	// write to the file.
+	newDockerConfig := workingDir.Join(".docker")
+	newDockerConfigFile := filepath.Join(newDockerConfig, dockerConfigFile)
+	err = bfs.MkdirAll(newDockerConfig, 0o700)
+	if err != nil {
+		return nil, fmt.Errorf("create docker config dir: %w", err)
+	}
+
+	if fileExists(bfs, newDockerConfigFile) {
+		return nil, fmt.Errorf("unable to write Docker config file, file already exists: %s", newDockerConfigFile)
+	}
+
+	restoreEnv, err := setAndRestoreEnv(logf, dockerConfigEnvKey, newDockerConfig)
 	if err != nil {
-		return noop, fmt.Errorf("decode docker config: %w", err)
+		return nil, fmt.Errorf("set docker config override: %w", err)
 	}
-	var configFile DockerConfig
-	decoded, err = hujson.Standardize(decoded)
+
+	err = writeFile(bfs, newDockerConfigFile, dockerConfigJSON, 0o600)
 	if err != nil {
-		return noop, fmt.Errorf("humanize json for docker config: %w", err)
+		_ = restoreEnv() // Best effort.
+		return nil, fmt.Errorf("write docker config: %w", err)
 	}
-	err = json.Unmarshal(decoded, &configFile)
+	logf(log.LevelInfo, "Wrote Docker config JSON to %s", newDockerConfigFile)
+
+	cleanupFile := onceErrFunc(func() error {
+		// Remove the Docker config secret file!
+		if err := bfs.Remove(newDockerConfigFile); err != nil {
+			logf(log.LevelError, "Failed to remove the Docker config secret file: %s", err)
+			return fmt.Errorf("remove docker config: %w", err)
+		}
+		return nil
+	})
+	return func() error { return errors.Join(cleanupFile(), restoreEnv()) }, nil
+}
+
+func logDockerAuthConfigs(logf log.Func, dockerConfigJSON []byte) error {
+	dc := new(DockerConfig)
+	err := dc.LoadFromReader(bytes.NewReader(dockerConfigJSON))
 	if err != nil {
-		return noop, fmt.Errorf("parse docker config: %w", err)
+		return fmt.Errorf("load docker config: %w", err)
 	}
-	for k := range configFile.AuthConfigs {
+	for k := range dc.AuthConfigs {
 		logf(log.LevelInfo, "Docker config contains auth for registry %q", k)
 	}
-	err = os.WriteFile(cfgPath, decoded, 0o644)
+	return nil
+}
+
+func setAndRestoreEnv(logf log.Func, key, value string) (restore func() error, err error) {
+	old := os.Getenv(key)
+	err = os.Setenv(key, value)
 	if err != nil {
-		return noop, fmt.Errorf("write docker config: %w", err)
-	}
-	logf(log.LevelInfo, "Wrote Docker config JSON to %s", cfgPath)
-	oldDockerConfig := os.Getenv("DOCKER_CONFIG")
-	_ = os.Setenv("DOCKER_CONFIG", workingDir.Path())
-	newDockerConfig := os.Getenv("DOCKER_CONFIG")
-	logf(log.LevelInfo, "Set DOCKER_CONFIG to %s", newDockerConfig)
-	cleanup := func() error {
-		var cleanupErr error
-		cleanupOnce.Do(func() {
-			// Restore the old DOCKER_CONFIG value.
-			os.Setenv("DOCKER_CONFIG", oldDockerConfig)
-			logf(log.LevelInfo, "Restored DOCKER_CONFIG to %s", oldDockerConfig)
-			// Remove the Docker config secret file!
-			if cleanupErr = os.Remove(cfgPath); err != nil {
-				if !errors.Is(err, fs.ErrNotExist) {
-					cleanupErr = fmt.Errorf("remove docker config: %w", cleanupErr)
-				}
-				logf(log.LevelError, "Failed to remove the Docker config secret file: %s", cleanupErr)
+		logf(log.LevelError, "Failed to set %s: %s", key, err)
+		return nil, fmt.Errorf("set %s: %w", key, err)
+	}
+	logf(log.LevelInfo, "Set %s to %s", key, value)
+	return onceErrFunc(func() error {
+		if err := func() error {
+			if old == "" {
+				return os.Unsetenv(key)
 			}
+			return os.Setenv(key, old)
+		}(); err != nil {
+			return fmt.Errorf("restore %s: %w", key, err)
+		}
+		logf(log.LevelInfo, "Restored %s to %s", key, old)
+		return nil
+	}), nil
+}
+
+func onceErrFunc(f func() error) func() error {
+	var once sync.Once
+	return func() error {
+		var err error
+		once.Do(func() {
+			err = f()
 		})
-		return cleanupErr
+		return err
 	}
-	return cleanup, err
 }
 
 // Allows quick testing of layer caching using a local directory!
diff --git a/options/options.go b/options/options.go
index ebd5b69..efa61be 100644
--- a/options/options.go
+++ b/options/options.go
@@ -277,7 +277,9 @@ func (o *Options) CLI() serpent.OptionSet {
 			Env:   WithEnvPrefix("DOCKER_CONFIG_BASE64"),
 			Value: serpent.StringOf(&o.DockerConfigBase64),
 			Description: "The base64 encoded Docker config file that " +
-				"will be used to pull images from private container registries.",
+				"will be used to pull images from private container registries. " +
+				"When this is set, Docker configuration set via the DOCKER_CONFIG " +
+				"environment variable is ignored.",
 		},
 		{
 			Flag:  "fallback-image",
diff --git a/scripts/docsgen/main.go b/scripts/docsgen/main.go
index b61de09..deb308f 100644
--- a/scripts/docsgen/main.go
+++ b/scripts/docsgen/main.go
@@ -16,5 +16,5 @@ func main() {
 	if err != nil {
 		panic(err)
 	}
-	fmt.Printf("%s updated successfully with the latest flags!", path)
+	fmt.Printf("%s updated successfully with the latest flags!\n", path)
 }


Test patch:
diff --git a/integration/integration_test.go b/integration/integration_test.go
index 615c646..a576d3e 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -571,27 +571,142 @@ func TestBuildFromDevcontainerWithFeatures(t *testing.T) {
 	require.Equal(t, "hello from test 3!", strings.TrimSpace(test3Output))
 }
 
-func TestBuildFromDockerfile(t *testing.T) {
-	// Ensures that a Git repository with a Dockerfile is cloned and built.
-	srv := gittest.CreateGitServer(t, gittest.Options{
-		Files: map[string]string{
-			"Dockerfile": "FROM " + testImageAlpine,
+func TestBuildFromDockerfileAndConfig(t *testing.T) {
+	t.Parallel()
+
+	type configFile struct {
+		name string
+		data string
+	}
+	type testCase struct {
+		name         string
+		env          []string
+		configFile   configFile
+		configBase64 string
+		validate     func(t *testing.T, tc testCase, ctrID, logs string)
+	}
+
+	validateDockerConfig := func(t *testing.T, tc testCase, ctrID, logs string) {
+		t.Helper()
+
+		// Ensure that the config matches the expected value, base64 is
+		// always prioritized over a file.
+		got := execContainer(t, ctrID, "cat /docker_config_json")
+		got = strings.TrimSpace(got)
+		want := tc.configBase64
+		if want == "" {
+			want = tc.configFile.data
+		}
+		if want != "" {
+			require.Contains(t, logs, "Set DOCKER_CONFIG to /.envbuilder/.docker")
+			require.Equal(t, want, got)
+		}
+
+		// Ensure that a warning message is printed if config secrets
+		// will remain in the container after build.
+		warningMessage := "this file will remain after the build"
+		if tc.configFile.name != "" {
+			require.Contains(t, logs, warningMessage)
+		} else {
+			require.NotContains(t, logs, warningMessage)
+		}
+	}
+
+	configJSONContainerPath := workingdir.Default.Join(".docker", "config.json")
+	defaultConfigJSON := `{"experimental": "enabled"}`
+
+	tests := []testCase{
+		{
+			name: "Plain",
+			validate: func(t *testing.T, tc testCase, ctrID, logs string) {
+				output := execContainer(t, ctrID, "echo hello")
+				require.Equal(t, "hello", strings.TrimSpace(output))
+			},
 		},
-	})
-	ctr, err := runEnvbuilder(t, runOpts{env: []string{
-		envbuilderEnv("GIT_URL", srv.URL),
-		envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
-		envbuilderEnv("DOCKER_CONFIG_BASE64", base64.StdEncoding.EncodeToString([]byte(`{"experimental": "enabled"}`))),
-	}})
-	require.NoError(t, err)
+		{
+			name:         "ConfigBase64",
+			configBase64: defaultConfigJSON,
+			validate:     validateDockerConfig,
+		},
+		{
+			name:       "BindConfigToKnownLocation",
+			configFile: configFile{"/.envbuilder/config.json", defaultConfigJSON},
+			validate:   validateDockerConfig,
+		},
+		{
+			name:       "BindConfigToPath",
+			env:        []string{"DOCKER_CONFIG=/secret"},
+			configFile: configFile{"/secret/config.json", defaultConfigJSON},
+			validate:   validateDockerConfig,
+		},
+		{
+			name:       "BindConfigToCustomFile",
+			env:        []string{"DOCKER_CONFIG=/secret/my.json"},
+			configFile: configFile{"/secret/my.json", defaultConfigJSON},
+			validate:   validateDockerConfig,
+		},
+		{
+			name:         "ConfigBase64AndBindUsesBase64",
+			configFile:   configFile{"/.envbuilder/config.json", `{"experimental": "disabled"}`},
+			configBase64: defaultConfigJSON,
+			validate:     validateDockerConfig,
+		},
+		{
+			name:         "ConfigBase64AndCustomConfigPath",
+			env:          []string{"DOCKER_CONFIG=/secret"},
+			configBase64: defaultConfigJSON,
+			validate:     validateDockerConfig,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
 
-	output := execContainer(t, ctr, "echo hello")
-	require.Equal(t, "hello", strings.TrimSpace(output))
+			// Ensures that a Git repository with a Dockerfile is cloned and built.
+			srv := gittest.CreateGitServer(t, gittest.Options{
+				Files: map[string]string{
+					"Dockerfile": fmt.Sprintf(`
+						FROM %[1]s
+						RUN if [ -f %[2]q ]; then cat %[2]q > /docker_config_json; fi
+					`, testImageAlpine, configJSONContainerPath),
+				},
+			})
+
+			logbuf := new(bytes.Buffer)
+			opts := runOpts{
+				env: []string{
+					envbuilderEnv("GIT_URL", srv.URL),
+					envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
+				},
+				logbuf: logbuf,
+			}
+
+			if tt.configFile.name != "" {
+				dir := t.TempDir()
+				configFile := filepath.Join(dir, filepath.Base(tt.configFile.name))
+				err := os.WriteFile(configFile, []byte(tt.configFile.data), 0o600)
+				require.NoError(t, err, "failed to write config")
+
+				opts.privileged = true
+				opts.binds = []string{fmt.Sprintf("%s:%s:rw", configFile, tt.configFile.name)}
+			}
+			if tt.configBase64 != "" {
+				enc := base64.StdEncoding.EncodeToString([]byte(tt.configBase64))
+				tt.env = append(tt.env, envbuilderEnv("DOCKER_CONFIG_BASE64", enc))
+			}
+
+			opts.env = append(opts.env, tt.env...)
+
+			ctrID, err := runEnvbuilder(t, opts)
+			require.NoError(t, err)
 
-	// Verify that the Docker configuration secret file is removed
-	configJSONContainerPath := workingdir.Default.Join("config.json")
-	output = execContainer(t, ctr, "stat "+configJSONContainerPath)
-	require.Contains(t, output, "No such file or directory")
+			tt.validate(t, tt, ctrID, logbuf.String())
+
+			// Always verify that the Docker configuration secret file is removed.
+			output := execContainer(t, ctrID, "stat "+configJSONContainerPath)
+			require.Contains(t, output, "No such file or directory")
+		})
+	}
 }
 
 func TestBuildPrintBuildOutput(t *testing.T) {
@@ -2412,10 +2527,12 @@ func startContainerFromRef(ctx context.Context, t *testing.T, cli *client.Client
 }
 
 type runOpts struct {
-	image   string
-	binds   []string
-	env     []string
-	volumes map[string]string
+	image      string
+	privileged bool // Required for remounting.
+	binds      []string
+	env        []string
+	volumes    map[string]string
+	logbuf     *bytes.Buffer
 }
 
 // runEnvbuilder starts the envbuilder container with the given environment
@@ -2453,17 +2570,22 @@ func runEnvbuilder(t *testing.T, opts runOpts) (string, error) {
 		require.NoError(t, err, "failed to read image pull response")
 		img = opts.image
 	}
+	hostConfig := &container.HostConfig{
+		NetworkMode: container.NetworkMode("host"),
+		Binds:       opts.binds,
+		Mounts:      mounts,
+	}
+	if opts.privileged {
+		hostConfig.CapAdd = append(hostConfig.CapAdd, "SYS_ADMIN")
+		hostConfig.Privileged = true
+	}
 	ctr, err := cli.ContainerCreate(ctx, &container.Config{
 		Image: img,
 		Env:   opts.env,
 		Labels: map[string]string{
 			testContainerLabel: "true",
 		},
-	}, &container.HostConfig{
-		NetworkMode: container.NetworkMode("host"),
-		Binds:       opts.binds,
-		Mounts:      mounts,
-	}, nil, nil, "")
+	}, hostConfig, nil, nil, "")
 	require.NoError(t, err)
 	t.Cleanup(func() {
 		_ = cli.ContainerRemove(ctx, ctr.ID, container.RemoveOptions{
@@ -2477,6 +2599,9 @@ func runEnvbuilder(t *testing.T, opts runOpts) (string, error) {
 	logChan, errChan := streamContainerLogs(t, cli, ctr.ID)
 	go func() {
 		for log := range logChan {
+			if opts.logbuf != nil {
+				opts.logbuf.WriteString(log + "\n")
+			}
 			if strings.HasPrefix(log, "=== Running init command") {
 				errChan <- nil
 				return
diff --git a/options/testdata/options.golden b/options/testdata/options.golden
index 518f774..397d5e0 100644
--- a/options/testdata/options.golden
+++ b/options/testdata/options.golden
@@ -47,7 +47,9 @@ OPTIONS:
 
       --docker-config-base64 string, $ENVBUILDER_DOCKER_CONFIG_BASE64
           The base64 encoded Docker config file that will be used to pull images
-          from private container registries.
+          from private container registries. When this is set, Docker
+          configuration set via the DOCKER_CONFIG environment variable is
+          ignored.
 
       --dockerfile-path string, $ENVBUILDER_DOCKERFILE_PATH
           The relative path to the Dockerfile that will be used to build the