diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java
new file mode 100644
index 00000000000..4668ebaaabd
--- /dev/null
+++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategy.java
@@ -0,0 +1,153 @@
+package org.testcontainers.dockerclient;
+
+import com.github.dockerjava.core.LocalDirectorySSLConfig;
+import com.github.dockerjava.transport.SSLConfig;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.jetbrains.annotations.Nullable;
+
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Picks the Docker endpoint declared by the active Docker CLI context.
+ *
+ * Resolution order matches {@code docker(1)}: {@code DOCKER_HOST} → {@code DOCKER_CONTEXT} →
+ * {@code currentContext} in {@code $DOCKER_CONFIG/config.json}. When {@code DOCKER_HOST} is set
+ * the CLI bypasses named contexts, and so does this strategy — the
+ * {@link EnvironmentAndSystemPropertyClientProviderStrategy} owns that case. When no context
+ * resolves to anything other than the built-in {@code default}, this strategy steps aside and lets
+ * the local-socket strategies handle the platform default.
+ *
+ * An explicit context can also be requested by name via
+ * {@link #DockerContextClientProviderStrategy(String)}; the SPI uses the no-arg constructor and
+ * resolves the active context dynamically.
+ *
+ * @deprecated this class is used by the SPI and should not be used directly
+ */
+@Slf4j
+@Deprecated
+public final class DockerContextClientProviderStrategy extends DockerClientProviderStrategy {
+
+ public static final int PRIORITY = EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY - 10;
+
+ private final Path dockerConfigDir;
+
+ @Nullable
+ private final String requestedContextName;
+
+ @Getter(lazy = true)
+ @Nullable
+ private final DockerContextResolver.DockerContextEndpoint endpoint = resolveEndpoint();
+
+ public DockerContextClientProviderStrategy() {
+ this(DockerContextResolver.defaultDockerConfigDir(), null);
+ }
+
+ /**
+ * Resolves the Docker endpoint for the supplied context name, bypassing the
+ * {@code DOCKER_HOST}/{@code DOCKER_CONTEXT}/{@code currentContext} fallback chain.
+ */
+ public DockerContextClientProviderStrategy(String contextName) {
+ this(DockerContextResolver.defaultDockerConfigDir(), contextName);
+ }
+
+ DockerContextClientProviderStrategy(Path dockerConfigDir, @Nullable String requestedContextName) {
+ this.dockerConfigDir = dockerConfigDir;
+ this.requestedContextName = requestedContextName;
+ }
+
+ @Nullable
+ private DockerContextResolver.DockerContextEndpoint resolveEndpoint() {
+ String contextName = requestedContextName != null
+ ? requestedContextName
+ : DockerContextResolver.resolveCurrentContextName(dockerConfigDir);
+ if (contextName == null) {
+ return null;
+ }
+ return DockerContextResolver.resolveEndpoint(dockerConfigDir, contextName);
+ }
+
+ @Override
+ protected boolean isApplicable() {
+ DockerContextResolver.DockerContextEndpoint endpoint = getEndpoint();
+ if (endpoint == null) {
+ return false;
+ }
+ String scheme = endpoint.getHost().getScheme();
+ if (scheme == null) {
+ return false;
+ }
+ switch (scheme) {
+ case "unix":
+ case "npipe":
+ case "tcp":
+ case "http":
+ case "https":
+ return true;
+ default:
+ log.debug(
+ "Docker context '{}' uses unsupported scheme '{}'; skipping",
+ endpoint.getContextName(),
+ scheme
+ );
+ return false;
+ }
+ }
+
+ @Override
+ public TransportConfig getTransportConfig() throws InvalidConfigurationException {
+ DockerContextResolver.DockerContextEndpoint endpoint = getEndpoint();
+ if (endpoint == null) {
+ throw new InvalidConfigurationException("No Docker context endpoint resolved");
+ }
+ URI host = endpoint.getHost();
+ if ("unix".equals(host.getScheme())) {
+ Path socketPath = java.nio.file.Paths.get(host.getPath());
+ if (!Files.exists(socketPath)) {
+ throw new InvalidConfigurationException(
+ "Docker context '" +
+ endpoint.getContextName() +
+ "' points at " +
+ socketPath +
+ " but the socket does not exist"
+ );
+ }
+ }
+ TransportConfig.TransportConfigBuilder builder = TransportConfig.builder().dockerHost(host);
+ SSLConfig sslConfig = buildSslConfig(endpoint);
+ if (sslConfig != null) {
+ builder.sslConfig(sslConfig);
+ }
+ return builder.build();
+ }
+
+ @Nullable
+ private SSLConfig buildSslConfig(DockerContextResolver.DockerContextEndpoint endpoint) {
+ Path tlsDir = endpoint.getTlsMaterialDir();
+ if (tlsDir == null) {
+ return null;
+ }
+ return new LocalDirectorySSLConfig(tlsDir.toString());
+ }
+
+ @Override
+ protected int getPriority() {
+ return PRIORITY;
+ }
+
+ @Override
+ public String getDescription() {
+ DockerContextResolver.DockerContextEndpoint endpoint = getEndpoint();
+ if (endpoint == null) {
+ return "Docker CLI context (none)";
+ }
+ return "Docker CLI context '" + endpoint.getContextName() + "' (" + endpoint.getHost() + ")";
+ }
+
+ @Override
+ protected boolean isPersistable() {
+ return false;
+ }
+}
diff --git a/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java
new file mode 100644
index 00000000000..153699bd872
--- /dev/null
+++ b/core/src/main/java/org/testcontainers/dockerclient/DockerContextResolver.java
@@ -0,0 +1,188 @@
+package org.testcontainers.dockerclient;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.Value;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.Nullable;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Resolves the Docker endpoint for a Docker CLI context.
+ *
+ * Mirrors the resolution order used by the Docker CLI (see {@code cli/command/cli.go}):
+ *
+ * - {@code DOCKER_HOST} env var — when set, the CLI forces the {@code default} context and
+ * does not consult any named context. This class therefore returns no endpoint in that case.
+ * - {@code DOCKER_CONTEXT} env var.
+ * - {@code currentContext} in {@code $DOCKER_CONFIG/config.json} (default
+ * {@code ~/.docker/config.json}).
+ * - The built-in {@code default} context (no metadata file).
+ *
+ *
+ * Named context metadata lives at
+ * {@code $DOCKER_CONFIG/contexts/meta//meta.json}; the per-endpoint TLS material,
+ * when present, lives under {@code $DOCKER_CONFIG/contexts/tls//docker/}.
+ */
+@Slf4j
+public final class DockerContextResolver {
+
+ public static final String DEFAULT_CONTEXT_NAME = "default";
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private DockerContextResolver() {}
+
+ /**
+ * @return the user's Docker config directory, honouring {@code DOCKER_CONFIG} if set, else
+ * {@code ~/.docker}.
+ */
+ public static Path defaultDockerConfigDir() {
+ String override = System.getenv("DOCKER_CONFIG");
+ if (!StringUtils.isBlank(override)) {
+ return Paths.get(override);
+ }
+ return Paths.get(System.getProperty("user.home"), ".docker");
+ }
+
+ /**
+ * Resolves the context name that the Docker CLI would pick, given the current process
+ * environment and the supplied Docker config directory.
+ *
+ * @return the context name, or {@code null} if {@code DOCKER_HOST} is set (in which case the
+ * CLI bypasses named contexts entirely).
+ */
+ @Nullable
+ public static String resolveCurrentContextName(Path dockerConfigDir) {
+ return resolveCurrentContextName(dockerConfigDir, System::getenv);
+ }
+
+ @Nullable
+ static String resolveCurrentContextName(Path dockerConfigDir, java.util.function.Function env) {
+ if (!StringUtils.isBlank(env.apply("DOCKER_HOST"))) {
+ return null;
+ }
+ String fromEnv = env.apply("DOCKER_CONTEXT");
+ if (!StringUtils.isBlank(fromEnv)) {
+ return fromEnv;
+ }
+ String fromConfig = readCurrentContextFromConfig(dockerConfigDir);
+ if (!StringUtils.isBlank(fromConfig)) {
+ return fromConfig;
+ }
+ return DEFAULT_CONTEXT_NAME;
+ }
+
+ @Nullable
+ private static String readCurrentContextFromConfig(Path dockerConfigDir) {
+ Path configFile = dockerConfigDir.resolve("config.json");
+ if (!Files.exists(configFile)) {
+ return null;
+ }
+ try {
+ JsonNode root = OBJECT_MAPPER.readTree(configFile.toFile());
+ JsonNode current = root.get("currentContext");
+ if (current == null || !current.isTextual()) {
+ return null;
+ }
+ return current.asText();
+ } catch (IOException e) {
+ log.debug("Failed to read currentContext from {}", configFile, e);
+ return null;
+ }
+ }
+
+ /**
+ * Reads the Docker endpoint for the named context.
+ *
+ * @param dockerConfigDir the Docker config directory (typically {@code ~/.docker}).
+ * @param contextName the context name. The built-in {@code default} context has no metadata
+ * file and returns {@code null}.
+ * @return the resolved endpoint, or {@code null} for the {@code default} context.
+ * @throws InvalidConfigurationException if the context is not the default one and its metadata
+ * cannot be read or is malformed.
+ */
+ @Nullable
+ public static DockerContextEndpoint resolveEndpoint(Path dockerConfigDir, String contextName) {
+ if (DEFAULT_CONTEXT_NAME.equals(contextName)) {
+ return null;
+ }
+ Path metaFile = contextMetaFile(dockerConfigDir, contextName);
+ if (!Files.exists(metaFile)) {
+ throw new InvalidConfigurationException(
+ "Docker context '" + contextName + "' has no metadata at " + metaFile
+ );
+ }
+ JsonNode root;
+ try {
+ root = OBJECT_MAPPER.readTree(metaFile.toFile());
+ } catch (IOException e) {
+ throw new InvalidConfigurationException("Failed to read Docker context metadata at " + metaFile, e);
+ }
+ JsonNode dockerEndpoint = root.path("Endpoints").path("docker");
+ JsonNode hostNode = dockerEndpoint.get("Host");
+ if (hostNode == null || !hostNode.isTextual() || StringUtils.isBlank(hostNode.asText())) {
+ throw new InvalidConfigurationException(
+ "Docker context '" + contextName + "' does not declare a docker endpoint host"
+ );
+ }
+ URI host;
+ try {
+ host = new URI(hostNode.asText());
+ } catch (URISyntaxException e) {
+ throw new InvalidConfigurationException(
+ "Docker context '" + contextName + "' has an invalid host URI: " + hostNode.asText(),
+ e
+ );
+ }
+ boolean skipTlsVerify = dockerEndpoint.path("SkipTLSVerify").asBoolean(false);
+ Path tlsDir = contextTlsDir(dockerConfigDir, contextName);
+ Path resolvedTlsDir = Files.isDirectory(tlsDir) ? tlsDir : null;
+ return new DockerContextEndpoint(contextName, host, resolvedTlsDir, skipTlsVerify);
+ }
+
+ static Path contextMetaFile(Path dockerConfigDir, String contextName) {
+ return dockerConfigDir.resolve("contexts").resolve("meta").resolve(sha256(contextName)).resolve("meta.json");
+ }
+
+ static Path contextTlsDir(Path dockerConfigDir, String contextName) {
+ return dockerConfigDir.resolve("contexts").resolve("tls").resolve(sha256(contextName)).resolve("docker");
+ }
+
+ private static String sha256(String input) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ @Value
+ public static class DockerContextEndpoint {
+
+ String contextName;
+
+ URI host;
+
+ @Nullable
+ Path tlsMaterialDir;
+
+ boolean skipTlsVerify;
+ }
+}
diff --git a/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy
index cae8bf53041..2a7d4d7c3f4 100644
--- a/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy
+++ b/core/src/main/resources/META-INF/services/org.testcontainers.dockerclient.DockerClientProviderStrategy
@@ -1,5 +1,6 @@
org.testcontainers.dockerclient.TestcontainersHostPropertyClientProviderStrategy
org.testcontainers.dockerclient.EnvironmentAndSystemPropertyClientProviderStrategy
+org.testcontainers.dockerclient.DockerContextClientProviderStrategy
org.testcontainers.dockerclient.UnixSocketClientProviderStrategy
org.testcontainers.dockerclient.DockerMachineClientProviderStrategy
org.testcontainers.dockerclient.NpipeSocketClientProviderStrategy
diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java
new file mode 100644
index 00000000000..a32a7b6c626
--- /dev/null
+++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextClientProviderStrategyTest.java
@@ -0,0 +1,204 @@
+package org.testcontainers.dockerclient;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assumptions.assumeThat;
+
+class DockerContextClientProviderStrategyTest {
+
+ @Test
+ void notApplicableWhenContextResolvesToDefault(@TempDir Path dockerConfigDir) {
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, null);
+
+ assertThat(strategy.isApplicable()).isFalse();
+ }
+
+ @Test
+ void notApplicableForSshContext(@TempDir Path dockerConfigDir) throws IOException {
+ writeMeta(
+ dockerConfigDir,
+ "rpi",
+ "{\"Name\":\"rpi\",\"Endpoints\":{\"docker\":{\"Host\":\"ssh://user@192.168.1.10\"}}}"
+ );
+
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, "rpi");
+
+ assertThat(strategy.isApplicable()).isFalse();
+ }
+
+ @Test
+ void resolvesNamedContextThroughExplicitConstructor(@TempDir Path dockerConfigDir) throws IOException {
+ String host = fakeEndpointHost(dockerConfigDir, "fake");
+ writeMeta(
+ dockerConfigDir,
+ "my-ctx",
+ "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}"
+ );
+
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(
+ dockerConfigDir,
+ "my-ctx"
+ );
+
+ assertThat(strategy.isApplicable()).isTrue();
+ TransportConfig config = strategy.getTransportConfig();
+ assertThat(config.getDockerHost().toString()).isEqualTo(host);
+ assertThat(config.getSslConfig()).isNull();
+ assertThat(strategy.getDescription()).contains("my-ctx").contains(host);
+ }
+
+ @Test
+ void usesCurrentContextFromConfigWhenNoExplicitName(@TempDir Path dockerConfigDir) throws IOException {
+ String host = fakeEndpointHost(dockerConfigDir, "current");
+ writeConfig(dockerConfigDir, "{\"currentContext\":\"picked\"}");
+ writeMeta(
+ dockerConfigDir,
+ "picked",
+ "{\"Name\":\"picked\",\"Endpoints\":{\"docker\":{\"Host\":\"" + host + "\"}}}"
+ );
+
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(dockerConfigDir, null);
+
+ assertThat(strategy.isApplicable()).isTrue();
+ assertThat(strategy.getTransportConfig().getDockerHost().toString()).isEqualTo(host);
+ }
+
+ @Test
+ void getTransportConfigFailsWhenUnixSocketMissing(@TempDir Path dockerConfigDir) throws IOException {
+ writeMeta(
+ dockerConfigDir,
+ "ghost",
+ "{\"Name\":\"ghost\",\"Endpoints\":{\"docker\":{\"Host\":\"unix:///definitely/not/here.sock\"}}}"
+ );
+
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(
+ dockerConfigDir,
+ "ghost"
+ );
+
+ assertThatThrownBy(strategy::getTransportConfig)
+ .isInstanceOf(InvalidConfigurationException.class)
+ .hasMessageContaining("does not exist");
+ }
+
+ @Test
+ void exposesTlsMaterialWhenPresent(@TempDir Path dockerConfigDir) throws IOException {
+ writeMeta(
+ dockerConfigDir,
+ "remote",
+ "{\"Name\":\"remote\",\"Endpoints\":{\"docker\":{\"Host\":\"tcp://10.0.0.5:2376\"}}}"
+ );
+ Path tlsDir = DockerContextResolver.contextTlsDir(dockerConfigDir, "remote");
+ Files.createDirectories(tlsDir);
+ Files.write(tlsDir.resolve("ca.pem"), "ca".getBytes(StandardCharsets.UTF_8));
+ Files.write(tlsDir.resolve("cert.pem"), "cert".getBytes(StandardCharsets.UTF_8));
+ Files.write(tlsDir.resolve("key.pem"), "key".getBytes(StandardCharsets.UTF_8));
+
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy(
+ dockerConfigDir,
+ "remote"
+ );
+
+ TransportConfig config = strategy.getTransportConfig();
+ assertThat(config.getDockerHost().toString()).isEqualTo("tcp://10.0.0.5:2376");
+ assertThat(config.getSslConfig()).isNotNull();
+ // docker-java is relocated under org.testcontainers.shaded.* in the produced jar, so we
+ // can't depend on the class identity — match by simple name and extract the path via the
+ // public getter.
+ assertThat(config.getSslConfig().getClass().getSimpleName()).isEqualTo("LocalDirectorySSLConfig");
+ assertThat(config.getSslConfig()).extracting("dockerCertPath").isEqualTo(tlsDir.toString());
+ }
+
+ /**
+ * Live smoke test against the local Docker installation: skipped unless an active context
+ * resolves to a unix socket that actually exists. With Docker Desktop running, this confirms
+ * end-to-end that the strategy can reach the daemon — including setups where Docker is only
+ * reachable through the configured context (no {@code /var/run/docker.sock} symlink).
+ */
+ @Test
+ void connectsToLocalDockerThroughActiveContext() {
+ Path dockerConfigDir = DockerContextResolver.defaultDockerConfigDir();
+ assumeThat(Files.exists(dockerConfigDir)).as("Docker config dir exists").isTrue();
+ assumeThat(System.getenv("DOCKER_HOST")).as("DOCKER_HOST is unset").isNull();
+
+ DockerContextClientProviderStrategy strategy = new DockerContextClientProviderStrategy();
+ assumeThat(strategy.isApplicable()).as("strategy is applicable").isTrue();
+
+ // Resolve config and exercise the daemon under a single guard. A stopped daemon shows up
+ // as a missing socket (InvalidConfigurationException from getTransportConfig) or a refused
+ // connection (from pingCmd) — both should skip rather than fail.
+ try {
+ TransportConfig config = strategy.getTransportConfig();
+ assumeThat(config.getDockerHost().getScheme()).as("scheme").isIn("unix", "tcp", "http", "https", "npipe");
+ try (com.github.dockerjava.api.DockerClient client = strategy.getDockerClient()) {
+ client.pingCmd().exec();
+ assertThat(client.infoCmd().exec().getOsType()).isEqualTo("linux");
+ }
+ } catch (Exception e) {
+ assumeThat(e).as("docker daemon reachable via active context").isNull();
+ }
+ }
+
+ @Test
+ void priorityIsBetweenEnvVarAndUnixSocket() {
+ assertThat(DockerContextClientProviderStrategy.PRIORITY)
+ .isGreaterThan(UnixSocketClientProviderStrategy.PRIORITY)
+ .isLessThan(EnvironmentAndSystemPropertyClientProviderStrategy.PRIORITY);
+ }
+
+ /**
+ * End-to-end check that the SPI flow in {@link org.testcontainers.DockerClientFactory} picks
+ * this strategy when the active context resolves to a working endpoint.
+ */
+ @Test
+ void factoryPicksContextStrategyForActiveContext() {
+ assumeThat(Files.exists(DockerContextResolver.defaultDockerConfigDir())).isTrue();
+ assumeThat(System.getenv("DOCKER_HOST")).isNull();
+ assumeThat(new DockerContextClientProviderStrategy().isApplicable())
+ .as("active context resolves to a non-default endpoint")
+ .isTrue();
+
+ try {
+ org.testcontainers.DockerClientFactory.instance().client();
+ } catch (Exception e) {
+ assumeThat(e).as("DockerClientFactory can initialize").isNull();
+ }
+
+ assertThat(org.testcontainers.DockerClientFactory.instance().isUsing(DockerContextClientProviderStrategy.class))
+ .isTrue();
+ }
+
+ private static void writeConfig(Path dockerConfigDir, String json) throws IOException {
+ Files.createDirectories(dockerConfigDir);
+ Files.write(dockerConfigDir.resolve("config.json"), json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void writeMeta(Path dockerConfigDir, String contextName, String json) throws IOException {
+ Path metaFile = DockerContextResolver.contextMetaFile(dockerConfigDir, contextName);
+ Files.createDirectories(metaFile.getParent());
+ Files.write(metaFile, json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Builds a Docker endpoint host suitable for the current OS: a real unix socket on POSIX (whose
+ * existence the strategy verifies) and an npipe address on Windows (which needs no backing
+ * file). Both forms use forward slashes, so they embed safely into the JSON meta fixtures.
+ */
+ private static String fakeEndpointHost(Path dir, String name) throws IOException {
+ if (SystemUtils.IS_OS_WINDOWS) {
+ return "npipe:////./pipe/" + name;
+ }
+ Path socket = dir.resolve(name + ".sock");
+ Files.write(socket, new byte[0]);
+ return "unix://" + socket.toAbsolutePath();
+ }
+}
diff --git a/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java
new file mode 100644
index 00000000000..2818b40b41d
--- /dev/null
+++ b/core/src/test/java/org/testcontainers/dockerclient/DockerContextResolverTest.java
@@ -0,0 +1,190 @@
+package org.testcontainers.dockerclient;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class DockerContextResolverTest {
+
+ @Test
+ void resolvesDefaultWhenConfigAbsent(@TempDir Path dockerConfigDir) {
+ String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, k -> null);
+ assertThat(name).isEqualTo(DockerContextResolver.DEFAULT_CONTEXT_NAME);
+ }
+
+ @Test
+ void resolvesCurrentContextFromConfig(@TempDir Path dockerConfigDir) throws IOException {
+ writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}");
+
+ String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, k -> null);
+
+ assertThat(name).isEqualTo("desktop-linux");
+ }
+
+ @Test
+ void dockerContextEnvOverridesConfig(@TempDir Path dockerConfigDir) throws IOException {
+ writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}");
+
+ String name = DockerContextResolver.resolveCurrentContextName(
+ dockerConfigDir,
+ mapEnv("DOCKER_CONTEXT", "orbstack")
+ );
+
+ assertThat(name).isEqualTo("orbstack");
+ }
+
+ @Test
+ void dockerHostEnvBypassesContexts(@TempDir Path dockerConfigDir) throws IOException {
+ writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}");
+
+ String name = DockerContextResolver.resolveCurrentContextName(
+ dockerConfigDir,
+ mapEnv("DOCKER_HOST", "tcp://1.2.3.4:2375", "DOCKER_CONTEXT", "orbstack")
+ );
+
+ assertThat(name).isNull();
+ }
+
+ @Test
+ void blankDockerContextEnvIsIgnored(@TempDir Path dockerConfigDir) throws IOException {
+ writeConfig(dockerConfigDir, "{\"currentContext\":\"desktop-linux\"}");
+
+ String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, mapEnv("DOCKER_CONTEXT", " "));
+
+ assertThat(name).isEqualTo("desktop-linux");
+ }
+
+ @Test
+ void resolvesDefaultWhenConfigHasNoCurrentContext(@TempDir Path dockerConfigDir) throws IOException {
+ writeConfig(dockerConfigDir, "{\"auths\":{}}");
+
+ String name = DockerContextResolver.resolveCurrentContextName(dockerConfigDir, k -> null);
+
+ assertThat(name).isEqualTo(DockerContextResolver.DEFAULT_CONTEXT_NAME);
+ }
+
+ @Test
+ void resolveEndpointReturnsNullForDefaultContext(@TempDir Path dockerConfigDir) {
+ DockerContextResolver.DockerContextEndpoint endpoint = DockerContextResolver.resolveEndpoint(
+ dockerConfigDir,
+ DockerContextResolver.DEFAULT_CONTEXT_NAME
+ );
+
+ assertThat(endpoint).isNull();
+ }
+
+ @Test
+ void resolveEndpointParsesHost(@TempDir Path dockerConfigDir) throws IOException {
+ writeMeta(
+ dockerConfigDir,
+ "my-ctx",
+ "{\"Name\":\"my-ctx\",\"Endpoints\":{\"docker\":{\"Host\":\"unix:///var/run/foo.sock\",\"SkipTLSVerify\":false}}}"
+ );
+
+ DockerContextResolver.DockerContextEndpoint endpoint = DockerContextResolver.resolveEndpoint(
+ dockerConfigDir,
+ "my-ctx"
+ );
+
+ assertThat(endpoint).isNotNull();
+ assertThat(endpoint.getContextName()).isEqualTo("my-ctx");
+ assertThat(endpoint.getHost().toString()).isEqualTo("unix:///var/run/foo.sock");
+ assertThat(endpoint.isSkipTlsVerify()).isFalse();
+ assertThat(endpoint.getTlsMaterialDir()).isNull();
+ }
+
+ @Test
+ void resolveEndpointDiscoversTlsMaterial(@TempDir Path dockerConfigDir) throws IOException {
+ writeMeta(
+ dockerConfigDir,
+ "remote",
+ "{\"Name\":\"remote\",\"Endpoints\":{\"docker\":{\"Host\":\"tcp://10.0.0.5:2376\"}}}"
+ );
+ Path tlsDir = DockerContextResolver.contextTlsDir(dockerConfigDir, "remote");
+ Files.createDirectories(tlsDir);
+ Files.write(tlsDir.resolve("ca.pem"), "ca".getBytes(StandardCharsets.UTF_8));
+
+ DockerContextResolver.DockerContextEndpoint endpoint = DockerContextResolver.resolveEndpoint(
+ dockerConfigDir,
+ "remote"
+ );
+
+ assertThat(endpoint.getTlsMaterialDir()).isEqualTo(tlsDir);
+ }
+
+ @Test
+ void resolveEndpointThrowsWhenContextMissing(@TempDir Path dockerConfigDir) {
+ assertThatThrownBy(() -> DockerContextResolver.resolveEndpoint(dockerConfigDir, "ghost"))
+ .isInstanceOf(InvalidConfigurationException.class)
+ .hasMessageContaining("ghost");
+ }
+
+ @Test
+ void resolveEndpointThrowsWhenHostMissing(@TempDir Path dockerConfigDir) throws IOException {
+ writeMeta(dockerConfigDir, "broken", "{\"Name\":\"broken\",\"Endpoints\":{\"docker\":{}}}");
+
+ assertThatThrownBy(() -> DockerContextResolver.resolveEndpoint(dockerConfigDir, "broken"))
+ .isInstanceOf(InvalidConfigurationException.class)
+ .hasMessageContaining("docker endpoint host");
+ }
+
+ @Test
+ void contextDirectoryNameMatchesDockerCliHash(@TempDir Path dockerConfigDir) {
+ // Hashes captured from a real `~/.docker/contexts/meta/` directory created by Docker Desktop.
+ assertThat(DockerContextResolver.contextMetaFile(dockerConfigDir, "desktop-linux"))
+ .isEqualTo(
+ dockerConfigDir
+ .resolve("contexts")
+ .resolve("meta")
+ .resolve(sha256("desktop-linux"))
+ .resolve("meta.json")
+ );
+ assertThat(sha256("desktop-linux"))
+ .isEqualTo("fe9c6bd7a66301f49ca9b6a70b217107cd1284598bfc254700c989b916da791e");
+ }
+
+ private static void writeConfig(Path dockerConfigDir, String json) throws IOException {
+ Files.createDirectories(dockerConfigDir);
+ Files.write(dockerConfigDir.resolve("config.json"), json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void writeMeta(Path dockerConfigDir, String contextName, String json) throws IOException {
+ Path metaFile = DockerContextResolver.contextMetaFile(dockerConfigDir, contextName);
+ Files.createDirectories(metaFile.getParent());
+ Files.write(metaFile, json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static java.util.function.Function mapEnv(String... pairs) {
+ Map map = new HashMap<>();
+ for (int i = 0; i < pairs.length; i += 2) {
+ map.put(pairs[i], pairs[i + 1]);
+ }
+ return Collections.unmodifiableMap(map)::get;
+ }
+
+ private static String sha256(String input) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/docs/features/configuration.md b/docs/features/configuration.md
index c13c0a659e3..eae25c0c627 100644
--- a/docs/features/configuration.md
+++ b/docs/features/configuration.md
@@ -110,6 +110,12 @@ However, sometimes customization is required. Testcontainers will respect the fo
> **DOCKER_HOST** = unix:///var/run/docker.sock
> See [Docker environment variables](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables)
>
+> **DOCKER_CONTEXT**
+> Name of a Docker CLI context to use (e.g. `desktop-linux`, `orbstack`). When set — and `DOCKER_HOST` is not — Testcontainers resolves the endpoint from `$DOCKER_CONFIG/contexts/meta//meta.json` just like `docker(1)`. If neither variable is set, the `currentContext` field in `$DOCKER_CONFIG/config.json` (default `~/.docker/config.json`) is used.
+>
+> **DOCKER_CONFIG**
+> Overrides the Docker config directory (default `~/.docker`) where `config.json` and the `contexts/` subtree are read from.
+>
> **TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE**
> Path to Docker's socket. Used by Ryuk, Docker Compose, and a few other containers that need to perform Docker actions.
> Example: `/var/run/docker-alt.sock`
diff --git a/docs/supported_docker_environment/index.md b/docs/supported_docker_environment/index.md
index 0bdac109245..f1f8d7887ef 100644
--- a/docs/supported_docker_environment/index.md
+++ b/docs/supported_docker_environment/index.md
@@ -99,6 +99,7 @@ Testcontainers will try to connect to a Docker daemon using the following strate
* `DOCKER_HOST=https://localhost:2376`
* `DOCKER_TLS_VERIFY=1`
* `DOCKER_CERT_PATH=~/.docker`
+* The active Docker CLI context, resolved the same way `docker(1)` does — from `DOCKER_CONTEXT`, falling back to `currentContext` in `$DOCKER_CONFIG/config.json` (default `~/.docker/config.json`). The endpoint host is read from `$DOCKER_CONFIG/contexts/meta//meta.json`, and per-context TLS material under `contexts/tls//docker/` is picked up automatically when present. `DOCKER_HOST` overrides this, matching the CLI's behaviour.
* If Docker Machine is installed, the docker machine environment for the *first* machine found. Docker Machine needs to be on the PATH for this to succeed.
* If you're going to run your tests inside a container, please read [Patterns for running tests inside a docker container](continuous_integration/dind_patterns.md) first.