Docker Compose Support¶
Testcontainers for Rust supports running multi-container applications defined in Docker Compose files. This is useful when your tests need multiple interconnected services or when you want to reuse existing docker-compose configurations from your development environment.
Note: Docker Compose support is currently only available for async runtimes. Synchronous/blocking support may be added in a future release.
Installation¶
Add the docker-compose feature to your dependencies:
[dev-dependencies]
testcontainers = { version = "x.y.z", features = ["docker-compose"] }
Minimal Example¶
use testcontainers::compose::DockerCompose;
#[tokio::test]
async fn test_redis() -> Result<(), Box<dyn std::error::Error>> {
let mut compose = DockerCompose::with_local_client(&["tests/docker-compose.yml"]);
compose.up().await?;
let redis = compose.service("redis").expect("redis service");
let port = redis.get_host_port_ipv4(6379).await?;
// Use redis at localhost:{port}
let client = redis::Client::open(format!("redis://localhost:{}", port))?;
let mut con = client.get_connection()?;
redis::cmd("PING").query::<String>(&mut con)?;
Ok(())
}
With docker-compose.yml:
services:
redis:
image: redis:7-alpine
ports:
- "6379"
Basic Usage¶
Use DockerCompose to start services defined in your compose files:
use testcontainers::compose::DockerCompose;
#[tokio::test]
async fn test_with_compose() -> Result<(), Box<dyn std::error::Error>> {
let mut compose = DockerCompose::with_local_client(&["tests/docker-compose.yml"]);
compose.up().await?;
// Access service by name
let web = compose.service("web").expect("web service");
let port = web.get_host_port_ipv4(8080).await?;
let response = reqwest::get(format!("http://localhost:{}", port)).await?;
assert!(response.status().is_success());
Ok(())
// Automatic cleanup on drop
}
Accessing Services¶
After calling up(), you can access individual services by name. The service() method returns a reference to the container, providing full access to the container API:
Get Service Container¶
compose.up().await?;
let redis = compose.service("redis").expect("redis service exists");
// Full container API available
let port = redis.get_host_port_ipv4(6379).await?;
let logs = redis.stdout(true);
redis.exec(ExecCommand::new(["redis-cli", "PING"])).await?;
List All Services¶
compose.up().await?;
for service_name in compose.services() {
println!("Service: {}", service_name);
}
Access Ports, Logs, and Execute Commands¶
Services return a container reference with the full API:
compose.up().await?;
let redis = compose.service("redis").expect("redis service");
// Get mapped ports
let port = redis.get_host_port_ipv4(6379).await?;
let ipv6_port = redis.get_host_port_ipv6(6379).await?;
// Stream logs
let stdout = redis.stdout(true);
let stderr = redis.stderr(false);
// Execute commands
let result = redis.exec(ExecCommand::new(["redis-cli", "PING"])).await?;
// Get container info
let container_id = redis.id();
let host = redis.get_host().await?;
Client Modes¶
Local Client (Default)¶
Uses the locally installed docker compose CLI:
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]);
compose.up().await?;
Requirements: - Docker CLI with Compose plugin installed locally - Compose files must be on the filesystem
Containerised Client¶
Runs docker compose inside a container (no local Docker CLI required):
let mut compose = DockerCompose::with_containerised_client(&["docker-compose.yml"]).await;
compose.up().await?;
Benefits: - No local Docker CLI installation needed - Consistent compose version across environments - Useful for CI/CD where Docker CLI might not be available
If your compose files use relative paths for bind mounts, set an explicit project directory so docker compose resolves those paths against the host location:
use testcontainers::compose::{ContainerisedComposeOptions, DockerCompose};
let options = ContainerisedComposeOptions::new(&["/home/me/app/docker-compose.yml"])
.with_project_directory("/home/me/app");
let mut compose = DockerCompose::with_containerised_client(options).await?;
compose.up().await?;
Auto Client¶
Tries the local docker compose CLI first and falls back to the containerised client:
use testcontainers::compose::{AutoComposeOptions, DockerCompose};
let options = AutoComposeOptions::new(&["docker-compose.yml"]);
let mut compose = DockerCompose::with_auto_client(options).await?;
compose.up().await?;
Configuration Options¶
Environment Variables¶
Pass environment variables to your compose stack:
use std::collections::HashMap;
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"])
.with_env("DATABASE_URL", "postgres://test:test@db:5432/test")
.with_env("REDIS_PORT", "6380");
compose.up().await?;
Or use a HashMap for bulk configuration:
let mut env_vars = HashMap::new();
env_vars.insert("API_KEY".to_string(), "test-key-123".to_string());
env_vars.insert("DEBUG".to_string(), "true".to_string());
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"])
.with_env_vars(env_vars);
compose.up().await?;
Lifecycle and Cleanup¶
You can either let the stack automatically clean up on drop, or explicitly tear it down:
// Option 1: Automatic cleanup (default behavior)
{
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]);
compose.up().await?;
// Use services...
} // Automatically cleaned up here
// Option 2: Explicit teardown
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]);
compose.up().await?;
// Use services...
compose.down().await?; // Explicit cleanup, consumes compose
Control what gets removed during cleanup:
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"]);
// Remove volumes on cleanup (default: true)
compose.with_remove_volumes(true);
// Remove images on cleanup (default: false)
compose.with_remove_images(false);
compose.up().await?;
Build and Pull Options¶
Configure whether to build or pull images before starting:
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"])
.with_build(true) // Build images defined in compose file
.with_pull(true); // Pull latest images before starting
compose.up().await?;
Multiple Compose Files¶
You can use multiple compose files (e.g., base + override):
let mut compose = DockerCompose::with_local_client(&[
"docker-compose.yml",
"docker-compose.test.yml",
]);
compose.up().await?;
Complete Example¶
use testcontainers::{
compose::DockerCompose,
core::IntoContainerPort,
};
#[tokio::test]
async fn integration_test_with_compose() -> Result<(), Box<dyn std::error::Error>> {
let mut compose = DockerCompose::with_local_client(&[
"tests/docker-compose.yml",
])
.with_env("POSTGRES_PASSWORD", "test-password")
.with_env("REDIS_MAXMEMORY", "256mb");
compose.up().await?;
// List all running services
println!("Running services: {:?}", compose.services());
// Access database
let db_port = compose.get_host_port_ipv4("db", 5432).await?;
let db_url = format!("postgres://postgres:test-password@localhost:{}/test", db_port);
let db_pool = sqlx::PgPool::connect(&db_url).await?;
// Run migrations or setup
sqlx::query("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY)")
.execute(&db_pool)
.await?;
// Access Redis
let redis_port = compose.get_host_port_ipv4("redis", 6379).await?;
let redis_client = redis::Client::open(format!("redis://localhost:{}", redis_port))?;
let mut con = redis_client.get_connection()?;
redis::cmd("SET").arg("test-key").arg("test-value").query::<()>(&mut con)?;
// Access web service
let web_port = compose.get_host_port_ipv4("web", 8080).await?;
let response = reqwest::get(format!("http://localhost:{}/health", web_port))
.await?
.text()
.await?;
assert_eq!(response, "OK");
Ok(())
// Automatic cleanup: containers, networks, and volumes are removed
}
Sample Compose File¶
Here's an example docker-compose.yml that works with the above code:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: test
ports:
- "5432"
redis:
image: redis:7-alpine
command: redis-server --maxmemory ${REDIS_MAXMEMORY:-128mb}
ports:
- "6379"
web:
image: my-web-app:latest
environment:
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/test
REDIS_URL: redis://redis:6379
ports:
- "8080"
depends_on:
- db
- redis
Best Practices¶
Use Unique Project Names¶
Each test gets a unique project name automatically via UUID, preventing conflicts between parallel tests. No manual configuration needed. If you need a stable name (for example, to reuse volumes across runs), override it explicitly:
let mut compose = DockerCompose::with_local_client(&["docker-compose.yml"])
.with_project_name("my-test-stack");
Rely on Compose's --wait Flag¶
The compose up() method uses Docker Compose's built-in --wait functionality, which waits for services to be healthy before returning. Configure healthchecks in your compose file:
services:
web:
image: nginx
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 5s
timeout: 3s
retries: 3
ports:
- "80"
Clean Up Resources¶
By default, volumes are removed on cleanup but images are not. Adjust based on your needs:
// Keep volumes for debugging or to reuse data across test runs
compose.with_remove_volumes(false);
// Remove images to save disk space
compose.with_remove_images(true);
Troubleshooting¶
Service Not Found¶
If compose.service("name") returns None:
- Check the service name matches exactly what's in your compose file
- Ensure
up()was called and succeeded - Verify the service started successfully (check Docker logs)
Port Not Exposed Error¶
If get_host_port_ipv4() fails:
- Ensure the port is listed in the
ports:section of your compose file - Use the container's internal port, not the host port
- Example: If mapped as
"8081:80", usecompose.get_host_port_ipv4("web", 80)
Compose Command Fails¶
If up() returns an error:
- Verify Docker Compose is installed:
docker compose version - Check compose file is valid:
docker compose -f your-file.yml config - Ensure all required images are available or can be pulled