From 4523adaff871452b548bb932c50c43fd58eb8483 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 10 Aug 2026 08:33:36 +0200 Subject: [PATCH 1/2] feat: add WithHostIP option to bind exposed ports to a host IP By default the exposed ports of a container are bound to all host interfaces (0.0.0.0). Add a WithHostIP option and a matching HostIP field on ContainerRequest so users can restrict the port bindings to a specific IP address, e.g. localhost only. The host IP is applied to all port bindings in the pre-create hook, after the default ephemeral bindings are merged, so it covers both the default bindings and per-port bindings set via WithHostConfigModifier (overriding their HostIP while preserving their HostPort). Invalid IPs are rejected at request customization time and validated again for direct ContainerRequest users. --- container.go | 1 + docs/features/common_functional_options.md | 17 ++ .../common_functional_options_list.md | 1 + host_ip_test.go | 155 ++++++++++++++++++ lifecycle.go | 17 ++ options.go | 14 ++ options_test.go | 26 +++ 7 files changed, 231 insertions(+) create mode 100644 host_ip_test.go diff --git a/container.go b/container.go index b2a1706bfe..04045ad49a 100644 --- a/container.go +++ b/container.go @@ -137,6 +137,7 @@ type ContainerRequest struct { Entrypoint []string Env map[string]string ExposedPorts []string // allow specifying protocol info + HostIP string // bind the exposed ports to this IP address on the host. Defaults to all interfaces (0.0.0.0) Cmd []string Labels map[string]string Mounts ContainerMounts diff --git a/docs/features/common_functional_options.md b/docs/features/common_functional_options.md index c8929197b9..011e882ba2 100644 --- a/docs/features/common_functional_options.md +++ b/docs/features/common_functional_options.md @@ -393,6 +393,23 @@ If you need an advanced configuration for the container, modifying the container This option can be called multiple times; each modifier is chained and applied in order, with the last call applied last. +##### WithHostIP + +- Since :material-tag: main + +By default, the exposed ports of a container are bound to all interfaces of the host (`0.0.0.0`). If you need to restrict the port bindings to a specific IP address, for example to make the container only reachable from `localhost`, you can use the `testcontainers.WithHostIP` option: + +```golang +ctr, err = mymodule.Run(ctx, "docker.io/myservice:1.2.3", + testcontainers.WithHostIP("127.0.0.1"), +) +``` + +It is also available as the `HostIP` field of the `ContainerRequest`. + +!!!note + The host IP is applied to all the exposed ports of the container. It overrides the per-port `HostIP` set with `WithHostConfigModifier`. + ##### WithEndpointSettingsModifier - Since :material-tag: v0.20.0 diff --git a/docs/features/common_functional_options_list.md b/docs/features/common_functional_options_list.md index 52345fc593..e89d9d34b8 100644 --- a/docs/features/common_functional_options_list.md +++ b/docs/features/common_functional_options_list.md @@ -56,6 +56,7 @@ The following options are exposed by the `testcontainers` package. - [`WithHostPortAccess`](/features/creating_container/#withhostportaccess) Since :material-tag: v0.31.0 - [`WithConfigModifier`](/features/creating_container/#withconfigmodifier) Since :material-tag: v0.20.0 · Chainable since :material-tag: main - [`WithHostConfigModifier`](/features/creating_container/#withhostconfigmodifier) Since :material-tag: v0.20.0 · Chainable since :material-tag: main +- [`WithHostIP`](/features/creating_container/#withhostip) Since :material-tag: main - [`WithEndpointSettingsModifier`](/features/creating_container/#withendpointsettingsmodifier) Since :material-tag: v0.20.0 · Chainable since :material-tag: main - [`CustomizeRequest`](/features/creating_container/#customizerequest) Since :material-tag: v0.20.0 - [`WithName`](/features/creating_container/#withname) Since :material-tag: v0.38.0 diff --git a/host_ip_test.go b/host_ip_test.go new file mode 100644 index 0000000000..eadbf4196a --- /dev/null +++ b/host_ip_test.go @@ -0,0 +1,155 @@ +package testcontainers + +import ( + "context" + "net/netip" + "testing" + + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPreCreateContainerHookAppliesHostIP tests that the pre-create hook binds +// all exposed ports to the requested host IP, both for the default ephemeral +// bindings and for bindings set by a HostConfigModifier. +func TestPreCreateContainerHookAppliesHostIP(t *testing.T) { + ctx := context.Background() + p := &DockerProvider{} + + t.Run("applies to default ephemeral bindings", func(t *testing.T) { + dockerInput := &container.Config{} + hostConfig := &container.HostConfig{} + networkingConfig := &network.NetworkingConfig{} + + req := ContainerRequest{ + Image: "nginx:alpine", + ExposedPorts: []string{"80/tcp"}, + HostIP: "127.0.0.1", + } + + err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig) + require.NoError(t, err) + + port := network.MustParsePort("80/tcp") + bindings := hostConfig.PortBindings[port] + require.Len(t, bindings, 1) + assert.Equal(t, "127.0.0.1", bindings[0].HostIP.String()) + assert.Equal(t, "0", bindings[0].HostPort, "HostPort should remain ephemeral") + }) + + t.Run("overrides bindings set by HostConfigModifier", func(t *testing.T) { + dockerInput := &container.Config{} + hostConfig := &container.HostConfig{} + networkingConfig := &network.NetworkingConfig{} + + req := ContainerRequest{ + Image: "nginx:alpine", + ExposedPorts: []string{"80/tcp", "443/tcp"}, + HostIP: "127.0.0.1", + HostConfigModifier: func(hc *container.HostConfig) { + hc.PortBindings = network.PortMap{ + network.MustParsePort("443/tcp"): {{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "8443"}}, + } + }, + } + + err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig) + require.NoError(t, err) + + for _, port := range []network.Port{network.MustParsePort("80/tcp"), network.MustParsePort("443/tcp")} { + bindings := hostConfig.PortBindings[port] + require.Len(t, bindings, 1, "expected a binding for %s", port) + assert.Equal(t, "127.0.0.1", bindings[0].HostIP.String(), "binding for %s should use the requested host IP", port) + } + + bindings := hostConfig.PortBindings[network.MustParsePort("443/tcp")] + assert.Equal(t, "8443", bindings[0].HostPort, "custom HostPort should be preserved") + }) + + t.Run("invalid IP returns error", func(t *testing.T) { + dockerInput := &container.Config{} + hostConfig := &container.HostConfig{} + networkingConfig := &network.NetworkingConfig{} + + req := ContainerRequest{ + Image: "nginx:alpine", + ExposedPorts: []string{"80/tcp"}, + HostIP: "not-an-ip", + } + + err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig) + require.Error(t, err) + }) + + t.Run("no-op when HostIP is empty", func(t *testing.T) { + dockerInput := &container.Config{} + hostConfig := &container.HostConfig{} + networkingConfig := &network.NetworkingConfig{} + + req := ContainerRequest{ + Image: "nginx:alpine", + ExposedPorts: []string{"80/tcp"}, + } + + err := p.preCreateContainerHook(ctx, req, dockerInput, hostConfig, networkingConfig) + require.NoError(t, err) + + bindings := hostConfig.PortBindings[network.MustParsePort("80/tcp")] + require.Len(t, bindings, 1) + assert.Zero(t, bindings[0].HostIP, "HostIP should remain empty (bind all interfaces)") + }) +} + +// TestContainerWithHostIP is an integration test verifying that a container +// started with a host IP binds its exposed ports to that address only. +func TestContainerWithHostIP(t *testing.T) { + ctx := context.Background() + + t.Run("ContainerRequest.HostIP", func(t *testing.T) { + req := ContainerRequest{ + Image: nginxAlpineImage, + ExposedPorts: []string{nginxDefaultPort}, + HostIP: "127.0.0.1", + } + + container, err := GenericContainer(ctx, GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err) + defer func() { + require.NoError(t, container.Terminate(ctx)) + }() + + inspect, err := container.Inspect(ctx) + require.NoError(t, err) + + port := network.MustParsePort(nginxDefaultPort) + bindings, ok := inspect.NetworkSettings.Ports[port] + require.True(t, ok, "expected a binding for %s", nginxDefaultPort) + require.Len(t, bindings, 1) + require.Equal(t, "127.0.0.1", bindings[0].HostIP.String()) + }) + + t.Run("WithHostIP option", func(t *testing.T) { + container, err := Run(ctx, nginxAlpineImage, + WithExposedPorts(nginxDefaultPort), + WithHostIP("127.0.0.1"), + ) + require.NoError(t, err) + defer func() { + require.NoError(t, container.Terminate(ctx)) + }() + + inspect, err := container.Inspect(ctx) + require.NoError(t, err) + + port := network.MustParsePort(nginxDefaultPort) + bindings, ok := inspect.NetworkSettings.Ports[port] + require.True(t, ok, "expected a binding for %s", nginxDefaultPort) + require.Len(t, bindings, 1) + require.Equal(t, "127.0.0.1", bindings[0].HostIP.String()) + }) +} diff --git a/lifecycle.go b/lifecycle.go index 90516df78e..dc0802d12f 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/netip" "reflect" "strings" "time" @@ -544,6 +545,22 @@ func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req Contain dockerInput.ExposedPorts = exposedPortSet hostConfig.PortBindings = mergePortBindings(hostConfig.PortBindings, exposedPortSet) + + // Bind all exposed ports to the requested host IP address. + // The binding happens after the port bindings are merged, so it applies to both + // the default ephemeral bindings and the ones set by the HostConfigModifier. + if req.HostIP != "" { + ip, err := netip.ParseAddr(req.HostIP) + if err != nil { + return fmt.Errorf("invalid host IP %q: %w", req.HostIP, err) + } + + for _, bindings := range hostConfig.PortBindings { + for i := range bindings { + bindings[i].HostIP = ip + } + } + } return nil } diff --git a/options.go b/options.go index c1e9ac3fe6..ce7e8e31bc 100644 --- a/options.go +++ b/options.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "maps" + "net/netip" "path" "time" @@ -122,6 +123,19 @@ func WithHostConfigModifier(modifier func(hostConfig *container.HostConfig)) Cus } } +// WithHostIP allows to set the IP address to which the exposed ports will be bound on the host. +// By default, the exposed ports are bound to all interfaces (0.0.0.0). +func WithHostIP(ip string) CustomizeRequestOption { + return func(req *GenericContainerRequest) error { + addr, err := netip.ParseAddr(ip) + if err != nil { + return fmt.Errorf("invalid host IP %q: %w", ip, err) + } + req.HostIP = addr.String() + return nil + } +} + // WithHostPortAccess allows to expose the host ports to the container func WithHostPortAccess(ports ...int) CustomizeRequestOption { return func(req *GenericContainerRequest) error { diff --git a/options_test.go b/options_test.go index c5baa68ad4..bd7d7bf2d7 100644 --- a/options_test.go +++ b/options_test.go @@ -909,6 +909,32 @@ func TestWithProvider(t *testing.T) { }) } +func TestWithHostIP(t *testing.T) { + t.Run("valid IP", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{} + + opt := testcontainers.WithHostIP("127.0.0.1") + require.NoError(t, opt.Customize(&req)) + require.Equal(t, "127.0.0.1", req.HostIP) + }) + + t.Run("valid IPv6", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{} + + opt := testcontainers.WithHostIP("::1") + require.NoError(t, opt.Customize(&req)) + require.Equal(t, "::1", req.HostIP) + }) + + t.Run("invalid IP", func(t *testing.T) { + req := testcontainers.GenericContainerRequest{} + + opt := testcontainers.WithHostIP("not-an-ip") + require.Error(t, opt.Customize(&req)) + require.Empty(t, req.HostIP) + }) +} + func TestWithHostConfigModifier(t *testing.T) { t.Run("simple", func(t *testing.T) { c, err := testcontainers.Run( From b045b48ecf4eb272262bf4ace3f9a42bb352c836 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 10 Aug 2026 08:37:41 +0200 Subject: [PATCH 2/2] test: prove HostIP overrides modifier-provided bind address The precedence test used the same address (127.0.0.1) for both req.HostIP and the HostConfigModifier binding, so it passed even if the override logic was removed. Use 0.0.0.0 in the modifier binding while keeping req.HostIP and the assertion at 127.0.0.1, making the test fail when the override is absent. --- host_ip_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/host_ip_test.go b/host_ip_test.go index eadbf4196a..7c31e5a3f2 100644 --- a/host_ip_test.go +++ b/host_ip_test.go @@ -50,7 +50,9 @@ func TestPreCreateContainerHookAppliesHostIP(t *testing.T) { HostIP: "127.0.0.1", HostConfigModifier: func(hc *container.HostConfig) { hc.PortBindings = network.PortMap{ - network.MustParsePort("443/tcp"): {{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: "8443"}}, + // Use a different address than req.HostIP so the test proves + // that req.HostIP overrides the modifier-provided HostIP. + network.MustParsePort("443/tcp"): {{HostIP: netip.MustParseAddr("0.0.0.0"), HostPort: "8443"}}, } }, }