This release ships the entire post-0.1.0 backlog — 59 commits: a new ecosystem-module layer on top of a core round-out across wait strategies, streaming transfers, exec, networks and volumes, compose, environment configuration, lifecycle, and a pre-release hardening audit.
Ecosystem modules (testcontainers::modules)
Seven prebuilt technology wrappers over GenericImage: Redis (redis:7.2), PostgreSQL (postgres:16-alpine), MySQL (mysql:8.4), MariaDB (mariadb:11), Kafka (apache/kafka:3.9.1, single-node KRaft), RabbitMQ (rabbitmq:3.13-management), MongoDB (mongo:7, single-node replica set — transactions and change streams work). Each is a copyable config builder modules::XxxImage whose start() returns a move-only started handle modules::XxxContainer — the naming mirrors the core GenericImage → Container split: the Image is the reusable recipe, the Container the running instance.
#include "testcontainers/modules.hpp"
const modules::PostgreSQLContainer pg = modules::PostgreSQLImage()
.with_init_script("schema.sql", "CREATE TABLE t(id int);")
.start(); // returns once every init script ran and TCP serves
// pg.connection_string() -> "postgresql://test:test@localhost:<port>/test"- Typed connection getters instead of client drivers:
connection_string()(plus PostgreSQL's libpqconninfo()andexec_sql(), Kafka'sbootstrap_servers()/internal_bootstrap_servers(), RabbitMQ'samqp_url(), MongoDB'smongosh(); MongoDB's DSN pinsdirectConnection=trueso every spec-compliant driver behaves the same). Getters are pure — host and mapped port resolve once atstart(). - Every module pins its image and encodes the readiness probe that actually works there:
pg_isreadyforced onto TCP (the postgres image's first boot runs a temporary socket-only server that log and socket probes misread as ready), Kafka's two-phase KRaft boot (the broker must advertise the mapped host port, which does not exist until after start), RabbitMQ's log-wait-before-any-exec order (an early exec as root bricks the Erlang cookie), MongoDB'srs.initiate+ PRIMARY poll from a started hook. - A uniform curated pass-through surface on all seven —
with_env/with_label/with_network/with_network_alias/with_reuse/with_startup_timeout(a per-phase budget) /with_startup_attempts— plus two escape hatches:with_customizer(fn), applied after the module's own rendering so it wins, andto_generic(). - Opt-in by include + link: umbrella header
testcontainers/modules.hpp, link targettestcontainers::modules(its own Conan component; core-only consumers are untouched). One core rider:GenericImage::with_imagere-points a configured builder at another image.
Per-module contracts and known limits are documented in docs/feature-notes.md.
Core additions
Wait strategies, host resolution, DSNs
wait_for::successful_command({argv...})/wait_for::successful_shell_command("script")— the seventh wait strategy polls a deadline-bounded exec until an attempt exits 0. Runs on every transport (named pipe and TLS included); non-zero exits and daemon 409s mean "not ready yet", a 404 propagates; the timeout error reports the last completed attempt (exit code + output snippet).- One host-address rule behind
Container::host(), the HTTP/port wait probes, compose service hosts, and Ryuk registration:TESTCONTAINERS_HOST_OVERRIDE/host.overridewins;tcp:///https://daemons hand out their hostname; a local socket/pipe means "localhost" — unless the test process itself runs inside a Linux container, where the docker-bridge gateway from/proc/net/routeis returned. This is the DinD / Testcontainers-Cloud enabler. ConnectionString— a public, render-only DSN builder (scheme://[user[:password]@]host[:port][/database][?k=v&…]) with per-component percent-encoding and IPv6 bracketing; the building block behind the module getters.
Streaming
- Transfers no longer buffer payloads whole:
copy_to_containersends the tar as a chunked upload with host files read in 64 KiB blocks, andGenericBuildableImage::build()streams the context throughPOST /buildthe same way (build_imagegained aBodyProduceroverload). The tar writer moved to pax(restricted), lifting USTAR's 100-char path and 8 GiB file caps. - Directory build-context sources honor
.dockerignorewith docker-build (moby/patternmatcher) semantics. - Batched
copy_to_container(id, vector<CopyToContainer>)ships a request's whole copy set in one PUT — the runner's create→copy→start path now uses it. - Copy-from grew a raw-bytes sink overload,
copy_from_container_to(id, path, dest_dir)— a streaming download + extraction, wire to disk, tar-slip protected (the Windows interior-drive-letter escape included) — andcontainer_path_stat(id, path), a cheap HEAD existence/size probe. set_max_response_body(bytes)caps what the buffered paths will hold: a runaway body throws aDockerErrornaming the cap instead of allocating without bound.
Exec
- Deadline-bounded streaming exec:
exec(cmd, opts, consumer, deadline)→ExecStreamResult— why delivery ended (FollowEnd) plus the exit code, present only when the command actually finished (closing the attach stream does not kill it). - Stdin now interleaves with the output read on a full-duplex pump (64 KiB chunks), so a command echoing a multi-megabyte stdin back can no longer backpressure the write into a timeout.
- TTY sizing:
ExecOptions::console_size(initial rows × columns at create, API 1.42+),ExecOptions::on_started(hands out the exec id at the first resize-valid moment), andresize_exec/Container::resize_tty. - All exec overloads now run one implementation with one end-of-stream contract: any read-side end (EOF, closed pipe, daemon reset) is the peer finishing — output is kept and exec-inspect settles the exit code (the buffered path used to throw on a reset).
Networks & volumes
- IPAM multi-pool create: repeatable
Network::Builder::with_ipam_pool(subnet, IP range, gateway, auxiliary addresses), echoed back typed inNetworkInspect. Network::Builder::with_reuse— find-or-create with the container-reuse semantics (an exactwith_nameis required; a same-name network with a different config throws instead of creating an ambiguous duplicate).DockerClient::list_networks/list_volumes/prune_volumes— filters pass through verbatim; API 1.42+ daemons prune only anonymous unused volumes unless the{"all","true"}filter is given.Volume::populatenow seeds volumes on Windows daemons too: it stages into the created helper's layer, then copies onto the volume from inside as ContainerAdministrator (a Windows daemon extracts archive uploads into the layer, never through mounts).
Compose
with_profile(repeatable) activates compose profiles — emitted onupand the teardowndown, so profile-gated services are stopped gracefully instead of stranded.with_scale(service, n)plus per-instance accessors:get_service_port/get_service_container_idtake an optional instance number,service_instanceslists the running ones, andwith_exposed_servicecan probe a specific instance.- Per-service logs:
get_service_logs(snapshot) andfollow_service_logs(blocking, or deadline-bounded returningFollowEnd) — identical behavior under the Local, Containerised, and Auto client modes. with_ambassador(service, port)reaches a service port the YAML never publishes: one socat relay joined to the project network, an ephemeral host port per target, resolved transparently byget_service_port.- Compose stacks are crash-safe now:
start()registers thecom.docker.compose.projectfilter with Ryuk beforeup, so even a crash mid-upis swept.
Environment configuration
- Every env switch gained a
~/.testcontainers.propertieskey (docker.host,docker.tls.verify,hub.image.name.prefix,host.override,ryuk.disabled,testcontainers.reuse.enable, …) behind one shared parser, read once per process; the file format is shared with testcontainers-java. - The active docker context's TLS store (
~/.docker/contexts/tls/…) is consumed: atcp://-spelled context endpoint dials TLS like the docker CLI,SkipTLSVerifyis honored, andtcp://+DOCKER_TLS_VERIFYupgrades to https (default port 2375 → 2376). - Every internal utility image is configurable for corporate mirrors —
ryuk.container.image,sshd.container.image,socat.container.image,compose.container.image(env or properties) — andTESTCONTAINERS_HUB_IMAGE_NAME_PREFIXnow reaches all of them, not justGenericImage. - Age-based pull policy:
with_image_pull_policy(std::chrono::seconds)re-pulls when the local image'sCreatedtimestamp is older than the budget (javaPullPolicy.ageBasedparity; caveat:Createdis the image build time — docker records no pull time).
Lifecycle & reaping
- Built images carry the session labels (
BuildOptions::labels, sent as the?labels=query), so Ryuk removes them with the rest of the session's resources; base images and pulled layers are untouched. - Per-daemon reaper and host-port-forwarder registries keyed by endpoint URL: a second daemon used in the same process boots its own Ryuk and sshd sidecar; compose project filters always reach the environment daemon's reaper.
- Reuse-hash freshness: copy-to sources now contribute their content identity to the hash (bytes verbatim; path + per-file size+mtime for host sources), so editing a fixture in place no longer adopts the stale reused container.
Container::stop(timeout_secs)forwards the daemon grace period (unset = the create-timeStopTimeout,0= kill now, negative = wait indefinitely), andContainer::start()restarts a stopped handle — a plain daemon start: waits and hooks do not re-run, ephemeral ports re-bind.Network.hpp/Volume.hppare now actually reachable from thetestcontainers.hppumbrella.
Typed HostConfig
- Create side:
with_cpu_limit(double)(docker run --cpusparity),with_cpuset_cpus,with_pids_limit,with_restart_policy(RestartPolicyfactories),with_dns_server/with_dns_search/with_dns_option,with_sysctl,with_device. Unset fields emit nothing into the create body, so pre-existing reuse hashes are unaffected. ContainerInspect::host_configechoes the typed knobs back (memory, shm size, nano-cpus, cpuset, pids limit, restart policy, DNS triple, sysctls, devices) — assert a landed limit without parsinginspect_raw().
Registry, network, and TLS robustness
pull_imageretries a daemon 5xx (how transient registry trouble is relayed) — 3 tries with a 1s-then-2s backoff, tunable viaDockerClient::set_pull_retry; 4xx, in-stream pull errors, and transport timeouts never retry.- Credential-helper lookups are cached per (helper, registry) for 5 minutes — including the "no credentials" answer, which under Docker Desktop's
credsStoreused to forkdocker-credential-desktopon every single pull. - Fixed: the TLS transport configured its SSL context after creating the stream, so the client certificate was never presented and server verification under
DOCKER_TLS_VERIFYsilently stayed fail-open. Mutual TLS now works and is verified end to end in CI against a real--tlsverifydocker:dind daemon (including a docker-context leg with noDOCKER_*connection env).
Pre-release hardening
- Digest-pinned references (
name@sha256:…) work end to end: create,exists/inspect, pull, and the utility-image overrides all split and re-join digest-aware. - Deadline arithmetic saturates (
detail::saturated_add): an oversized startup timeout no longer overflows the clock rep — UB whose typical wrap failed healthy starts instantly, and only on stdlibs with a fine-grained clock (libstdc++'s nanosecond ticks; MSVC's 100 ns masked it). url_encodeno longer consults the C locale; the tar write path toleratesARCHIVE_WARNlike the read paths; sha256 gained the 55/56/64-byte padding-boundary vectors.
Behavior notes
Upgrading from 0.1.0: the public API is source-compatible — nothing shipped in 0.1.0 was renamed or removed. Behavior differences to know:
- One-time reuse migration: reuse containers created by 0.1.0 with copy-to sources hash differently under the freshness-aware canonical and are not adopted — prune them externally (label sweep on
org.testcontainers.reuse.hash). Copy-free reuse configs hash identically and are still adopted. - Env vs properties precedence is now java-parity (
getEnvVarOrProperty): a SET, non-empty env var decides even when it decides "off". Previously a falsyTESTCONTAINERS_REUSE_ENABLEfell through to a file-enabledtestcontainers.reuse.enable. - Lazy pull on create now runs only when the 404 body actually says "no such image" — a missing network no longer triggers a spurious re-pull with the error pinned on the wrong resource.
- TLS verification is enforced: 0.1.0 never presented the client certificate and silently skipped server verification under
DOCKER_TLS_VERIFY. A--tlsverifydaemon that 0.1.0 could not reach now works; an untrusted server that 0.1.0 silently accepted is now rejected. - Built images are session-scoped:
build()labels the image and Ryuk removes it after the process exits. To keep built images, disable the reaper (TESTCONTAINERS_RYUK_DISABLED) — the session label is then omitted, as before. - The reaper is fail-loud: a daemon where Ryuk cannot start fails that run instead of leaving it silently unwatched (
TESTCONTAINERS_RYUK_DISABLEDopts out).
Quality gates
~560 unit + ~210 integration tests (v0.1.0: ~300 / ~115), green against real daemons in both engine modes (Linux and Windows containers); the module suite runs live on the Linux CI job. New in CI this cycle: the tls-e2e job drives the integration suite against a mutual-TLS docker:dind daemon on every push. The warnings-as-errors (gcc/clang/MSVC), pinned clang-format + clang-tidy, ASan+UBSan, and CodeQL gates carry over from 0.1.0.
Full Changelog: v0.1.0...v0.2.0
Packaging
Source tarball: https://github.com/cppudge/testcontainers-cpp/archive/refs/tags/v0.2.0.tar.gz
sha256: fc76fbdd9b926d13ff9cd8c584fa74ff14048eb4a5226285206e97fb1fb48f54
ConanCenter conandata.yml entry for this version:
"0.2.0":
url: "https://github.com/cppudge/testcontainers-cpp/archive/refs/tags/v0.2.0.tar.gz"
sha256: "fc76fbdd9b926d13ff9cd8c584fa74ff14048eb4a5226285206e97fb1fb48f54"