From 6287015bb2d954d69fdd19d7312d32562a188797 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 20 May 2026 12:21:12 +0200 Subject: [PATCH 1/4] Resolve default endpoint from docker CLI context Match docker CLI lookup when neither DOCKER_URL nor DOCKER_HOST is set: read DOCKER_CONTEXT (or currentContext from $DOCKER_CONFIG/config.json, defaulting to ~/.docker/config.json) and pull Endpoints.docker.Host from the context's meta.json. Falls back to the default unix socket when no context is configured, the context is named "default", or any file is missing or unparseable. Set DOCKER_API_SKIP_CONTEXT=1 to disable. TLS material is still sourced from DOCKER_CERT_PATH; only the endpoint URL is read from the context. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/docker.rb | 41 ++++++++++++++++- spec/docker_spec.rb | 105 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/lib/docker.rb b/lib/docker.rb index 973e0d5e..be5e8bbb 100644 --- a/lib/docker.rb +++ b/lib/docker.rb @@ -5,6 +5,7 @@ require 'excon' require 'tempfile' require 'base64' +require 'digest' require 'find' require 'rubygems/package' require 'uri' @@ -46,6 +47,40 @@ def env_url ENV['DOCKER_URL'] || ENV['DOCKER_HOST'] end + # Resolve the docker endpoint from the docker CLI config, mirroring the CLI + # lookup: DOCKER_CONTEXT env first, then `currentContext` from config.json + # under $DOCKER_CONFIG (or ~/.docker). Returns nil when no usable context is + # found or when disabled via DOCKER_API_SKIP_CONTEXT=1. + def context_url + return nil if ENV['DOCKER_API_SKIP_CONTEXT'] == '1' + + name = ENV['DOCKER_CONTEXT'] || current_context_from_config + return nil if name.nil? || name.empty? || name == 'default' + + endpoint_from_context(name) + end + + def config_dir + ENV['DOCKER_CONFIG'] || File.join(Dir.home, '.docker') + end + + def current_context_from_config + config_path = File.join(config_dir, 'config.json') + return nil unless File.exist?(config_path) + MultiJson.load(File.read(config_path))['currentContext'] + rescue StandardError + nil + end + + def endpoint_from_context(name) + id = Digest::SHA256.hexdigest(name) + meta_path = File.join(config_dir, 'contexts', 'meta', id, 'meta.json') + return nil unless File.exist?(meta_path) + MultiJson.load(File.read(meta_path)).dig('Endpoints', 'docker', 'Host') + rescue StandardError + nil + end + def env_options if cert_path = ENV['DOCKER_CERT_PATH'] { @@ -70,7 +105,7 @@ def ssl_options end def url - @url ||= env_url || default_socket_url + @url ||= env_url || context_url || default_socket_url # docker uses a default notation tcp:// which means tcp://localhost:2375 if @url == 'tcp://' @url = 'tcp://localhost:2375' @@ -141,7 +176,9 @@ def authenticate!(options = {}, connection = self.connection) raise Docker::Error::AuthenticationError end - module_function :default_socket_url, :env_url, :url, :url=, :env_options, + module_function :default_socket_url, :env_url, :context_url, :config_dir, + :current_context_from_config, :endpoint_from_context, + :url, :url=, :env_options, :options, :options=, :creds, :creds=, :logger, :logger=, :connection, :reset!, :reset_connection!, :version, :info, :ping, :podman?, :rootless?, :authenticate!, :ssl_options diff --git a/spec/docker_spec.rb b/spec/docker_spec.rb index b6656d13..f13fa6f0 100644 --- a/spec/docker_spec.rb +++ b/spec/docker_spec.rb @@ -15,6 +15,7 @@ allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) + allow(Docker).to receive(:context_url).and_return(nil) Docker.reset! end after { Docker.reset! } @@ -136,6 +137,110 @@ end + context 'docker CLI context resolution' do + let(:config_dir) { '/tmp/fake-docker-config' } + let(:config_path) { File.join(config_dir, 'config.json') } + let(:context_name) { 'remote' } + let(:context_id) { Digest::SHA256.hexdigest(context_name) } + let(:meta_path) { File.join(config_dir, 'contexts', 'meta', context_id, 'meta.json') } + let(:context_host) { 'tcp://remote.example.com:2376' } + + before do + allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_API_SKIP_CONTEXT').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CONTEXT').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CONFIG').and_return(config_dir) + Docker.reset! + end + after { Docker.reset! } + + context 'when currentContext in config.json points to a non-default context' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + allow(File).to receive(:exist?).with(meta_path).and_return(true) + allow(File).to receive(:read).with(meta_path) + .and_return(MultiJson.dump('Endpoints' => { 'docker' => { 'Host' => context_host } })) + end + + its(:url) { should == context_host } + end + + context 'when DOCKER_CONTEXT env overrides currentContext' do + let(:context_name) { 'staging' } + let(:other_host) { 'tcp://staging.example.com:2376' } + + before do + allow(ENV).to receive(:[]).with('DOCKER_CONTEXT').and_return(context_name) + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => 'something-else')) + allow(File).to receive(:exist?).with(meta_path).and_return(true) + allow(File).to receive(:read).with(meta_path) + .and_return(MultiJson.dump('Endpoints' => { 'docker' => { 'Host' => other_host } })) + end + + its(:url) { should == other_host } + end + + context 'when currentContext is "default"' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => 'default')) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + + context 'when config.json is absent' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(false) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + + context 'when context meta is absent' do + before do + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + allow(File).to receive(:exist?).with(meta_path).and_return(false) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + + context 'when DOCKER_HOST is set, env takes precedence over context' do + before do + allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return('tcp://from-env:2375') + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + allow(File).to receive(:exist?).with(meta_path).and_return(true) + allow(File).to receive(:read).with(meta_path) + .and_return(MultiJson.dump('Endpoints' => { 'docker' => { 'Host' => context_host } })) + end + + its(:url) { should == 'tcp://from-env:2375' } + end + + context 'when DOCKER_API_SKIP_CONTEXT=1, context lookup is disabled' do + before do + allow(ENV).to receive(:[]).with('DOCKER_API_SKIP_CONTEXT').and_return('1') + allow(File).to receive(:exist?).with(config_path).and_return(true) + allow(File).to receive(:read).with(config_path) + .and_return(MultiJson.dump('currentContext' => context_name)) + end + + its(:url) { should == 'unix:///var/run/docker.sock' } + end + end + describe '#reset_connection!' do before { subject.connection } it 'sets the @connection to nil' do From 3012f3888878bd0e266f7f37609a68e413e3446c Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 20 May 2026 12:21:16 +0200 Subject: [PATCH 2/4] Document endpoint resolution order in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain the DOCKER_URL → DOCKER_HOST → docker CLI context → default socket lookup, the DOCKER_CONFIG / DOCKER_CONTEXT overrides, and the DOCKER_API_SKIP_CONTEXT kill switch. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index dea7f7bb..240c86f7 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,23 @@ irb(main):004:0> Docker.options => {} ``` +#### Endpoint resolution + +When `Docker.url` is not explicitly set, the gem resolves the endpoint the same way the `docker` CLI does, in this order: + +1. `DOCKER_URL` environment variable (gem-specific, takes precedence over `DOCKER_HOST`). +2. `DOCKER_HOST` environment variable. +3. The current docker CLI context: + - `DOCKER_CONTEXT` environment variable, if set, names the context. + - Otherwise the `currentContext` field of `$DOCKER_CONFIG/config.json` (defaults to `~/.docker/config.json`) is used. + - The context's `Endpoints.docker.Host` is read from `$DOCKER_CONFIG/contexts/meta//meta.json`. + - A context named `default`, a missing config file, or a missing meta file all fall through to the next step. +4. The default socket `unix:///var/run/docker.sock`. + +Set `DOCKER_API_SKIP_CONTEXT=1` to disable step 3 entirely and keep the pre-context behavior (env vars only, then default socket). + +Only the endpoint URL is read from the context; TLS settings still come from `DOCKER_CERT_PATH` / `DOCKER_SSL_VERIFY` (see [SSL](#ssl) below) or from `Docker.options`. + ### SSL When running docker using SSL, setting the DOCKER_CERT_PATH will configure docker-api to use SSL. From 194ede88dddcec71b20fd50de50fdb4b01da8da1 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 20 May 2026 18:26:15 +0200 Subject: [PATCH 3/4] Add npipe transport for Windows Recognize the npipe:// URL scheme so docker-api can talk to the Docker engine over a Windows named pipe. Docker::Connection parses npipe:// URLs into a :socket path, lazy-loads a small Excon::Connection patch that dispatches the scheme to a Docker::NPipeSocket, and the socket itself opens the pipe through CreateFile/ReadFile/WriteFile via ffi. Also defaults the endpoint to npipe:////./pipe/docker_engine on Windows when neither DOCKER_URL/DOCKER_HOST nor a docker CLI context resolves an endpoint -- matching the docker CLI default on that platform. The ffi dependency is only loaded when an npipe URL is actually used, so non-Windows users pay no runtime cost. Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-api.gemspec | 2 + lib/docker.rb | 6 +- lib/docker/connection.rb | 3 + lib/docker/excon_npipe.rb | 27 +++++ lib/docker/npipe_socket.rb | 171 +++++++++++++++++++++++++++++++ spec/docker/connection_spec.rb | 11 +- spec/docker/npipe_socket_spec.rb | 38 +++++++ spec/docker_spec.rb | 16 +++ 8 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 lib/docker/excon_npipe.rb create mode 100644 lib/docker/npipe_socket.rb create mode 100644 spec/docker/npipe_socket_spec.rb diff --git a/docker-api.gemspec b/docker-api.gemspec index 1a131c76..02dd9d5d 100644 --- a/docker-api.gemspec +++ b/docker-api.gemspec @@ -14,6 +14,8 @@ Gem::Specification.new do |gem| gem.version = Docker::VERSION gem.add_dependency 'excon', '>= 0.64.0' gem.add_dependency 'multi_json' + # Required only on Windows to talk to the Docker engine over npipe. + gem.add_dependency 'ffi' gem.add_development_dependency 'rake' gem.add_development_dependency 'rspec', '~> 3.0' gem.add_development_dependency 'rspec-its' diff --git a/lib/docker.rb b/lib/docker.rb index be5e8bbb..a0857238 100644 --- a/lib/docker.rb +++ b/lib/docker.rb @@ -40,7 +40,11 @@ module Docker require 'docker/rake_task' if defined?(Rake::Task) def default_socket_url - 'unix:///var/run/docker.sock' + if Gem.win_platform? + 'npipe:////./pipe/docker_engine' + else + 'unix:///var/run/docker.sock' + end end def env_url diff --git a/lib/docker/connection.rb b/lib/docker/connection.rb index d678d243..ea3530d4 100644 --- a/lib/docker/connection.rb +++ b/lib/docker/connection.rb @@ -23,6 +23,9 @@ def initialize(url, opts) uri = URI.parse(url) if uri.scheme == "unix" @url, @options = 'unix:///', {:socket => uri.path}.merge(opts) + elsif uri.scheme == "npipe" + require 'docker/excon_npipe' + @url, @options = 'npipe:///', {:socket => uri.path}.merge(opts) elsif uri.scheme =~ /^(https?|tcp)$/ @url, @options = url, opts else diff --git a/lib/docker/excon_npipe.rb b/lib/docker/excon_npipe.rb new file mode 100644 index 00000000..60ca25bd --- /dev/null +++ b/lib/docker/excon_npipe.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'docker/npipe_socket' + +# Teaches Excon::Connection to recognize the `npipe://` scheme by dispatching +# to Docker::NPipeSocket and computing a unix-style socket key. Loaded only +# when a Docker::Connection is constructed with an `npipe://` URL. +module Docker::ExconNPipe + NPIPE = 'npipe' + + def initialize(params = {}) + super + if @data[:scheme] == NPIPE + @socket_key = "#{@data[:scheme]}://#{@data[:socket]}" + end + end + + def socket(datum = @data) + if datum[:scheme] == NPIPE + sockets[@socket_key] ||= Docker::NPipeSocket.new(datum) + else + super + end + end +end + +Excon::Connection.prepend(Docker::ExconNPipe) unless Excon::Connection.include?(Docker::ExconNPipe) diff --git a/lib/docker/npipe_socket.rb b/lib/docker/npipe_socket.rb new file mode 100644 index 00000000..3cbddd60 --- /dev/null +++ b/lib/docker/npipe_socket.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'ffi' + +# Excon-compatible socket backed by a Windows named pipe. Used to talk to the +# Docker engine via npipe:////./pipe/docker_engine on Windows. Implements the +# subset of the Excon::Socket interface that Excon::Connection actually drives. +class Docker::NPipeSocket < Excon::Socket + module Win32 + extend FFI::Library + + ffi_lib 'kernel32' + ffi_convention :stdcall + + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + OPEN_EXISTING = 3 + INVALID_HANDLE_VALUE = FFI::Pointer.new(-1).address + + ERROR_BROKEN_PIPE = 109 + ERROR_PIPE_NOT_CONNECTED = 233 + + NMPWAIT_USE_DEFAULT_WAIT = 0 + + attach_function :CreateFileA, [:string, :ulong, :ulong, :pointer, :ulong, :ulong, :pointer], :pointer, save_errno: true + attach_function :ReadFile, [:pointer, :pointer, :ulong, :pointer, :pointer], :int, save_errno: true + attach_function :WriteFile, [:pointer, :pointer, :ulong, :pointer, :pointer], :int, save_errno: true + attach_function :CloseHandle, [:pointer], :int + attach_function :WaitNamedPipeA, [:string, :ulong], :int, save_errno: true + end + + def initialize(data = {}) + # Windows named pipe IO from Ruby doesn't behave well in non-blocking mode, + # so always use the blocking read/write paths from Excon::Socket. + super(data.merge(nonblock: false)) + end + + def read(max_length = nil) + return '' if @eof && max_length.nil? + return nil if @eof + + buffer_size = max_length || @data[:chunk_size] || 16_384 + buf = FFI::MemoryPointer.new(:char, buffer_size) + bytes_read = FFI::MemoryPointer.new(:ulong) + + result = with_timeout(:read_timeout) do + Win32.ReadFile(@handle, buf, buffer_size, bytes_read, nil) + end + + n = bytes_read.read_ulong + if result == 0 + err = FFI.errno + if err == Win32::ERROR_BROKEN_PIPE || err == Win32::ERROR_PIPE_NOT_CONNECTED + @eof = true + return max_length ? nil : '' + end + raise Excon::Errors::SocketError.new(IOError.new("ReadFile failed (err=#{err})")) + end + + if n.zero? + @eof = true + return max_length ? nil : '' + end + + buf.read_string(n) + end + + def readline + line = String.new + until (idx = line.index("\n")) + chunk = read(1) + raise EOFError if chunk.nil? || chunk.empty? + line << chunk + end + line + end + + def write(data) + data = data.b + offset = 0 + total = data.bytesize + written_out = FFI::MemoryPointer.new(:ulong) + buf = FFI::MemoryPointer.new(:char, total) + buf.write_string(data) + + while offset < total + result = with_timeout(:write_timeout) do + Win32.WriteFile( + @handle, + buf + offset, + total - offset, + written_out, + nil + ) + end + + if result == 0 + err = FFI.errno + raise Excon::Errors::SocketError.new(IOError.new("WriteFile failed (err=#{err})")) + end + + n = written_out.read_ulong + raise Excon::Errors::SocketError.new(IOError.new('WriteFile wrote 0 bytes')) if n.zero? + offset += n + end + + total + end + + def close + return if @handle.nil? || @handle.address == Win32::INVALID_HANDLE_VALUE + Win32.CloseHandle(@handle) + @handle = nil + end + + def local_address + nil + end + + def local_port + nil + end + + private + + def connect + pipe_path = self.class.normalize_pipe_path(@data[:socket] || @data[:path]) + raise ArgumentError, 'npipe socket requires a pipe path' if pipe_path.nil? || pipe_path.empty? + + # Wait for the pipe to be available if a previous connection holds it. + Win32.WaitNamedPipeA(pipe_path, Win32::NMPWAIT_USE_DEFAULT_WAIT) + + @handle = Win32.CreateFileA( + pipe_path, + Win32::GENERIC_READ | Win32::GENERIC_WRITE, + 0, + nil, + Win32::OPEN_EXISTING, + 0, + nil + ) + + if @handle.nil? || @handle.address == Win32::INVALID_HANDLE_VALUE + err = FFI.errno + raise Excon::Errors::SocketError.new( + IOError.new("Could not open npipe '#{pipe_path}' (err=#{err})") + ) + end + end + + def with_timeout(kind) + timeout = @data[kind] + return yield if timeout.nil? || timeout.zero? + + Timeout.timeout(timeout) { yield } + rescue Timeout::Error + raise Excon::Errors::Timeout.new("#{kind} reached") + end + + # Accepts any of: '\\.\pipe\docker_engine', '//./pipe/docker_engine', + # '////./pipe/docker_engine', '/pipe/docker_engine'. Returns the canonical + # Windows form '\\.\pipe\'. + def self.normalize_pipe_path(path) + return nil if path.nil? || path.empty? + + path = path.tr('/', "\\") + path = path.sub(/\A\\+/, '') # drop any leading backslashes + path = path.sub(/\A\.\\/, '') # drop leading ".\" if present + "\\\\.\\#{path}" + end +end diff --git a/spec/docker/connection_spec.rb b/spec/docker/connection_spec.rb index 86291298..bc7fe57c 100644 --- a/spec/docker/connection_spec.rb +++ b/spec/docker/connection_spec.rb @@ -22,7 +22,7 @@ context 'when the first argument is a String' do context 'and the url is a unix socket' do - let(:url) { ::Docker.env_url || ::Docker.default_socket_url } + let(:url) { 'unix:///var/run/docker.sock' } it 'sets the socket path in the options' do expect(subject.url).to eq('unix:///') @@ -30,6 +30,15 @@ end end + context 'and the url is a Windows named pipe' do + let(:url) { 'npipe:////./pipe/docker_engine' } + + it 'extracts the pipe path into :socket and normalizes the scheme url' do + expect(subject.url).to eq('npipe:///') + expect(subject.options).to include(:socket => '//./pipe/docker_engine') + end + end + context 'but the second argument is not a Hash' do let(:options) { :lol_not_a_hash } diff --git a/spec/docker/npipe_socket_spec.rb b/spec/docker/npipe_socket_spec.rb new file mode 100644 index 00000000..6f79d0ba --- /dev/null +++ b/spec/docker/npipe_socket_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Skip on non-Windows since the file itself requires `ffi` and Win32 libs. +if Gem.win_platform? + require 'docker/npipe_socket' + + describe Docker::NPipeSocket do + describe '.normalize_pipe_path' do + subject { described_class.method(:normalize_pipe_path) } + + it 'returns nil for nil input' do + expect(subject.call(nil)).to be_nil + end + + it 'returns nil for empty input' do + expect(subject.call('')).to be_nil + end + + it 'normalizes a URI-style path' do + expect(subject.call('//./pipe/docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + + it 'normalizes a path with extra leading slashes (from npipe://// URL)' do + expect(subject.call('////./pipe/docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + + it 'is a no-op for a canonical Windows path' do + expect(subject.call('\\\\.\\pipe\\docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + + it 'prefixes \\\\.\\ when missing' do + expect(subject.call('/pipe/docker_engine')).to eq('\\\\.\\pipe\\docker_engine') + end + end + end +end diff --git a/spec/docker_spec.rb b/spec/docker_spec.rb index f13fa6f0..61512f3c 100644 --- a/spec/docker_spec.rb +++ b/spec/docker_spec.rb @@ -16,6 +16,7 @@ allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) allow(Docker).to receive(:context_url).and_return(nil) + allow(Gem).to receive(:win_platform?).and_return(false) Docker.reset! end after { Docker.reset! } @@ -25,6 +26,21 @@ its(:connection) { should be_a Docker::Connection } end + context "when on Windows with no DOCKER_* ENV variables set" do + before do + allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) + allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) + allow(Docker).to receive(:context_url).and_return(nil) + allow(Gem).to receive(:win_platform?).and_return(true) + Docker.reset! + end + after { Docker.reset! } + + its(:url) { should == 'npipe:////./pipe/docker_engine' } + its(:connection) { should be_a Docker::Connection } + end + context "when the DOCKER_* ENV variables are set" do before do allow(ENV).to receive(:[]).with('DOCKER_URL') From f91e5f5c1dde591fee18c1e51179425e50563866 Mon Sep 17 00:00:00 2001 From: Emmanuel Briney Date: Wed, 20 May 2026 18:26:20 +0200 Subject: [PATCH 4/4] Document npipe support in README Note the platform-conditional default endpoint and the npipe:// URL form callers can use to point at a custom pipe. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 240c86f7..bfc21af7 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,18 @@ When `Docker.url` is not explicitly set, the gem resolves the endpoint the same - Otherwise the `currentContext` field of `$DOCKER_CONFIG/config.json` (defaults to `~/.docker/config.json`) is used. - The context's `Endpoints.docker.Host` is read from `$DOCKER_CONFIG/contexts/meta//meta.json`. - A context named `default`, a missing config file, or a missing meta file all fall through to the next step. -4. The default socket `unix:///var/run/docker.sock`. +4. The default socket: `unix:///var/run/docker.sock` on Linux/macOS, or `npipe:////./pipe/docker_engine` on Windows. Set `DOCKER_API_SKIP_CONTEXT=1` to disable step 3 entirely and keep the pre-context behavior (env vars only, then default socket). +#### Windows named pipes (npipe) + +On Windows the default Docker endpoint is the named pipe `\\.\pipe\docker_engine`, expressed as a URL like `npipe:////./pipe/docker_engine`. The gem talks to it directly through the Win32 API (via the `ffi` gem), so no extra setup is needed when Docker Desktop is running. You can also point the gem at a non-default pipe: + +```ruby +Docker.url = 'npipe:////./pipe/some_other_engine' +``` + Only the endpoint URL is read from the context; TLS settings still come from `DOCKER_CERT_PATH` / `DOCKER_SSL_VERIFY` (see [SSL](#ssl) below) or from `Docker.options`. ### SSL