diff --git a/Directory.Build.props b/Directory.Build.props
index 8eba195b4..ffca9e0cf 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -21,10 +21,14 @@
https://github.com/testcontainers/testcontainers-dotnet
- $(MSBuildThisFileDirectory)src/strongname.snk
- true
+
+ true
+ false
embedded
+
+ $(MSBuildThisFileDirectory)src/strongname.snk
+
CA1859,CA1861,CS0618,CS1591,xUnit1044,xUnit1045
@@ -41,9 +45,14 @@
-
+
<_Parameter1>Testcontainers.Tests, PublicKey=$([System.IO.File]::ReadAllText($(MSBuildThisFileDirectory)src/strongname.pub))
+
+
+ <_Parameter1>Testcontainers.Tests
+
+
diff --git a/docs/modules/postgres.md b/docs/modules/postgres.md
index 376056cf3..ebae12332 100644
--- a/docs/modules/postgres.md
+++ b/docs/modules/postgres.md
@@ -32,4 +32,32 @@ The test example uses the following NuGet dependencies:
To execute the tests, use the command `dotnet test` from a terminal.
---8<-- "docs/modules/_call_out_test_projects.txt"
\ No newline at end of file
+## Enable SSL/TLS
+
+The PostgreSQL module supports configuring server-side SSL. Provide paths to your CA certificate, server certificate, and server private key when building the container:
+
+```csharp
+var postgreSqlContainer = new PostgreSqlBuilder()
+ .WithSSLSettings("/path/to/ca_cert.pem",
+ "/path/to/server.crt",
+ "/path/to/server.key")
+ .Build();
+await postgreSqlContainer.StartAsync();
+```
+
+When connecting with Npgsql during tests, you can require SSL and (optionally) trust the test certificate:
+
+```csharp
+var csb = new Npgsql.NpgsqlConnectionStringBuilder(postgreSqlContainer.GetConnectionString())
+{
+ SslMode = Npgsql.SslMode.Require,
+ // For testing only; prefer proper CA validation in production.
+ TrustServerCertificate = true
+};
+await using var connection = new Npgsql.NpgsqlConnection(csb.ConnectionString);
+await connection.OpenAsync();
+```
+
+For production scenarios, validate the server certificate against a trusted CA instead of using TrustServerCertificate.
+
+--8<-- "docs/modules/_call_out_test_projects.txt"
diff --git a/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs b/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs
index 73ec3a317..00f2f1456 100644
--- a/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs
+++ b/src/Testcontainers.PostgreSql/PostgreSqlBuilder.cs
@@ -2,7 +2,8 @@ namespace Testcontainers.PostgreSql;
///
[PublicAPI]
-public sealed class PostgreSqlBuilder : ContainerBuilder
+public sealed class
+ PostgreSqlBuilder : ContainerBuilder
{
public const string PostgreSqlImage = "postgres:15.1";
@@ -14,6 +15,8 @@ public sealed class PostgreSqlBuilder : ContainerBuilder
/// Initializes a new instance of the class.
///
@@ -69,14 +72,122 @@ public PostgreSqlBuilder WithPassword(string password)
.WithEnvironment("POSTGRES_PASSWORD", password);
}
+ ///
+ /// Sets the PostgreSql SSL mode.
+ ///
+ /// The PostgreSql SSL mode.
+ /// A configured instance of .
+ public PostgreSqlBuilder WithSslMode(SslMode sslMode)
+ {
+ return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration(sslMode: sslMode))
+ .WithEnvironment("PGSSLMODE", sslMode.ToString().ToLowerInvariant());
+ }
+
+ ///
+ /// Sets the PostgreSql root certificate file.
+ ///
+ /// The path to the root certificate file.
+ /// A configured instance of .
+ public PostgreSqlBuilder WithRootCertificate(string rootCertFile)
+ {
+ return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration(rootCertFile: rootCertFile))
+ .WithBindMount(rootCertFile, Path.Combine(DefaultCertificatesDirectory, "root.crt"), AccessMode.ReadOnly)
+ .WithEnvironment("PGSSLROOTCERT", Path.Combine(DefaultCertificatesDirectory, "root.crt"));
+ }
+
+ ///
+ /// Sets the PostgreSql client certificate and key files.
+ ///
+ /// The path to the client certificate file.
+ /// The path to the client key file.
+ /// A configured instance of .
+ public PostgreSqlBuilder WithClientCertificate(string clientCertFile, string clientKeyFile)
+ {
+ return Merge(DockerResourceConfiguration,
+ new PostgreSqlConfiguration(clientCertFile: clientCertFile, clientKeyFile: clientKeyFile))
+ .WithBindMount(clientCertFile, Path.Combine(DefaultCertificatesDirectory, "postgresql.crt"),
+ AccessMode.ReadOnly)
+ .WithBindMount(clientKeyFile, Path.Combine(DefaultCertificatesDirectory, "postgresql.key"),
+ AccessMode.ReadOnly)
+ .WithEnvironment("PGSSLCERT", Path.Combine(DefaultCertificatesDirectory, "postgresql.crt"))
+ .WithEnvironment("PGSSLKEY", Path.Combine(DefaultCertificatesDirectory, "postgresql.key"));
+ }
+
+ ///
+ /// Configures the PostgreSQL server to run with SSL using the provided CA certificate, server certificate and private key.
+ /// This enables server-side SSL configuration with client certificate authentication.
+ ///
+ /// The path to the CA certificate file.
+ /// The path to the server certificate file.
+ /// The path to the server private key file.
+ /// A configured instance of .
+ ///
+ /// This method configures PostgreSQL for server-side SSL with client certificate authentication.
+ /// It requires a custom PostgreSQL configuration file that enables SSL and sets the appropriate
+ /// certificate paths. The certificates are mounted into the container and PostgreSQL is configured
+ /// to use them for SSL connections.
+ ///
+ public PostgreSqlBuilder WithSSLSettings(string caCertFile, string serverCertFile, string serverKeyFile)
+ {
+ if (string.IsNullOrWhiteSpace(caCertFile))
+ {
+ throw new ArgumentException("CA certificate file path cannot be null or empty.", nameof(caCertFile));
+ }
+
+ if (string.IsNullOrWhiteSpace(serverCertFile))
+ {
+ throw new ArgumentException("Server certificate file path cannot be null or empty.",
+ nameof(serverCertFile));
+ }
+
+ if (string.IsNullOrWhiteSpace(serverKeyFile))
+ {
+ throw new ArgumentException("Server key file path cannot be null or empty.", nameof(serverKeyFile));
+ }
+
+ const string sslConfigDir = "/tmp/testcontainers-dotnet/postgres";
+
+ var wrapperEntrypoint = @"#!/bin/sh
+set -e
+SSL_DIR=/tmp/testcontainers-dotnet/postgres
+# Fix ownership and permissions for SSL key/cert before Postgres init runs
+if [ -f ""$SSL_DIR/server.key"" ]; then
+ chown postgres:postgres ""$SSL_DIR/server.key"" || true
+ chmod 600 ""$SSL_DIR/server.key"" || true
+fi
+if [ -f ""$SSL_DIR/server.crt"" ]; then
+ chown postgres:postgres ""$SSL_DIR/server.crt"" || true
+fi
+if [ -f ""$SSL_DIR/ca_cert.pem"" ]; then
+ chown postgres:postgres ""$SSL_DIR/ca_cert.pem"" || true
+fi
+exec /usr/local/bin/docker-entrypoint.sh ""$@""
+";
+
+ return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration(
+ serverCertFile: serverCertFile,
+ serverKeyFile: serverKeyFile,
+ caCertFile: caCertFile))
+ .WithResourceMapping(File.ReadAllBytes(caCertFile), $"{sslConfigDir}/ca_cert.pem", fileMode: Unix.FileMode644)
+ .WithResourceMapping(File.ReadAllBytes(serverCertFile), $"{sslConfigDir}/server.crt", fileMode: Unix.FileMode644)
+ .WithResourceMapping(File.ReadAllBytes(serverKeyFile), $"{sslConfigDir}/server.key", fileMode: Unix.FileMode700)
+ .WithResourceMapping(Encoding.UTF8.GetBytes(wrapperEntrypoint), "/usr/local/bin/docker-entrypoint-ssl.sh", fileMode: Unix.FileMode755)
+ .WithEntrypoint("/usr/local/bin/docker-entrypoint-ssl.sh")
+ .WithCommand("-c", "ssl=on")
+ .WithCommand("-c", $"ssl_ca_file={sslConfigDir}/ca_cert.pem")
+ .WithCommand("-c", $"ssl_cert_file={sslConfigDir}/server.crt")
+ .WithCommand("-c", $"ssl_key_file={sslConfigDir}/server.key");
+ }
+
///
public override PostgreSqlContainer Build()
{
Validate();
- // By default, the base builder waits until the container is running. However, for PostgreSql, a more advanced waiting strategy is necessary that requires access to the configured database and username.
- // If the user does not provide a custom waiting strategy, append the default PostgreSql waiting strategy.
- var postgreSqlBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration)));
+ // Ensure PostgreSQL is actually ready to accept connections over TCP, not just that the container is running.
+ // Always append the pg_isready-based wait strategy by default so tests using the default fixture are stable.
+ var postgreSqlBuilder =
+ WithWaitStrategy(Wait.ForUnixContainer().AddCustomWaitStrategy(new WaitUntil(DockerResourceConfiguration)));
return new PostgreSqlContainer(postgreSqlBuilder.DockerResourceConfiguration);
}
@@ -135,7 +246,11 @@ private sealed class WaitUntil : IWaitUntil
public WaitUntil(PostgreSqlConfiguration configuration)
{
// Explicitly specify the host to ensure readiness only after the initdb scripts have executed, and the server is listening on TCP/IP.
- _command = new List { "pg_isready", "--host", "localhost", "--dbname", configuration.Database, "--username", configuration.Username };
+ _command = new List
+ {
+ "pg_isready", "--host", "localhost", "--dbname", configuration.Database, "--username",
+ configuration.Username
+ };
}
///
@@ -154,7 +269,8 @@ public async Task UntilAsync(IContainer container)
if (execResult.Stderr.Contains("pg_isready was not found"))
{
- throw new NotSupportedException($"The '{container.Image.FullName}' image does not contain: pg_isready. Please use 'postgres:9.3' onwards.");
+ throw new NotSupportedException(
+ $"The '{container.Image.FullName}' image does not contain: pg_isready. Please use 'postgres:9.3' onwards.");
}
return 0L.Equals(execResult.ExitCode);
diff --git a/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs b/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs
index 05873fe8a..bac9d9ec1 100644
--- a/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs
+++ b/src/Testcontainers.PostgreSql/PostgreSqlConfiguration.cs
@@ -10,14 +10,35 @@ public sealed class PostgreSqlConfiguration : ContainerConfiguration
/// The PostgreSql database.
/// The PostgreSql username.
/// The PostgreSql password.
+ /// The PostgreSql SSL mode.
+ /// The path to the PostgreSql root certificate file.
+ /// The path to the PostgreSql client certificate file.
+ /// The path to the PostgreSql client key file.
+ /// The path to the PostgreSql server certificate file.
+ /// The path to the PostgreSql server key file.
+ /// The path to the PostgreSql CA certificate file.
public PostgreSqlConfiguration(
string database = null,
string username = null,
- string password = null)
+ string password = null,
+ SslMode? sslMode = null,
+ string rootCertFile = null,
+ string clientCertFile = null,
+ string clientKeyFile = null,
+ string serverCertFile = null,
+ string serverKeyFile = null,
+ string caCertFile = null)
{
Database = database;
Username = username;
Password = password;
+ SslMode = sslMode;
+ RootCertFile = rootCertFile;
+ ClientCertFile = clientCertFile;
+ ClientKeyFile = clientKeyFile;
+ ServerCertFile = serverCertFile;
+ ServerKeyFile = serverKeyFile;
+ CaCertFile = caCertFile;
}
///
@@ -61,6 +82,13 @@ public PostgreSqlConfiguration(PostgreSqlConfiguration oldValue, PostgreSqlConfi
Database = BuildConfiguration.Combine(oldValue.Database, newValue.Database);
Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username);
Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password);
+ SslMode = BuildConfiguration.Combine(oldValue.SslMode, newValue.SslMode);
+ RootCertFile = BuildConfiguration.Combine(oldValue.RootCertFile, newValue.RootCertFile);
+ ClientCertFile = BuildConfiguration.Combine(oldValue.ClientCertFile, newValue.ClientCertFile);
+ ClientKeyFile = BuildConfiguration.Combine(oldValue.ClientKeyFile, newValue.ClientKeyFile);
+ ServerCertFile = BuildConfiguration.Combine(oldValue.ServerCertFile, newValue.ServerCertFile);
+ ServerKeyFile = BuildConfiguration.Combine(oldValue.ServerKeyFile, newValue.ServerKeyFile);
+ CaCertFile = BuildConfiguration.Combine(oldValue.CaCertFile, newValue.CaCertFile);
}
///
@@ -77,4 +105,39 @@ public PostgreSqlConfiguration(PostgreSqlConfiguration oldValue, PostgreSqlConfi
/// Gets the PostgreSql password.
///
public string Password { get; }
+
+ ///
+ /// Gets the PostgreSql SSL mode.
+ ///
+ public SslMode? SslMode { get; }
+
+ ///
+ /// Gets the path to the PostgreSql root certificate file.
+ ///
+ public string RootCertFile { get; }
+
+ ///
+ /// Gets the path to the PostgreSql client certificate file.
+ ///
+ public string ClientCertFile { get; }
+
+ ///
+ /// Gets the path to the PostgreSql client key file.
+ ///
+ public string ClientKeyFile { get; }
+
+ ///
+ /// Gets the path to the PostgreSql server certificate file.
+ ///
+ public string ServerCertFile { get; }
+
+ ///
+ /// Gets the path to the PostgreSql server key file.
+ ///
+ public string ServerKeyFile { get; }
+
+ ///
+ /// Gets the path to the PostgreSql CA certificate file.
+ ///
+ public string CaCertFile { get; }
}
\ No newline at end of file
diff --git a/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs b/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs
index 1981730dd..812617ccc 100644
--- a/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs
+++ b/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs
@@ -44,7 +44,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation
await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, fileMode: Unix.FileMode644, ct: ct)
.ConfigureAwait(false);
- return await ExecAsync(new[] { "psql", "--username", _configuration.Username, "--dbname", _configuration.Database, "--file", scriptFilePath }, ct)
+ return await ExecAsync(new[] { "psql", "--host", "localhost", "--username", _configuration.Username, "--dbname", _configuration.Database, "--file", scriptFilePath }, ct)
.ConfigureAwait(false);
}
}
\ No newline at end of file
diff --git a/src/Testcontainers.PostgreSql/SslMode.cs b/src/Testcontainers.PostgreSql/SslMode.cs
new file mode 100644
index 000000000..8abcc4947
--- /dev/null
+++ b/src/Testcontainers.PostgreSql/SslMode.cs
@@ -0,0 +1,27 @@
+namespace Testcontainers.PostgreSql;
+
+///
+/// Represents the SSL mode for PostgreSQL connections.
+///
+public enum SslMode
+{
+ ///
+ /// SSL is disabled.
+ ///
+ Disable,
+
+ ///
+ /// SSL is required.
+ ///
+ Require,
+
+ ///
+ /// SSL is required, and the server certificate is verified against the root certificate.
+ ///
+ VerifyCa,
+
+ ///
+ /// SSL is required, and the server certificate is verified against the root certificate and the common name.
+ ///
+ VerifyFull
+}
\ No newline at end of file
diff --git a/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSSLConfigTest.cs b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSSLConfigTest.cs
new file mode 100644
index 000000000..bcc3828eb
--- /dev/null
+++ b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSSLConfigTest.cs
@@ -0,0 +1,269 @@
+#nullable enable
+using System.IO;
+using System.Net;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace Testcontainers.PostgreSql.Tests;
+
+[UsedImplicitly]
+public sealed class PostgreSqlSslConfigTest
+{
+ private const string CaCertFileName = "ca_cert.pem";
+ private const string ServerCertFileName = "server.crt";
+ private const string ServerKeyFileName = "server.key";
+ private const string ClientCertFileName = "client.crt";
+ private const string ClientKeyFileName = "client.key";
+
+ private readonly string _tempDir;
+ private readonly string _caCertPath;
+ private readonly string _serverCertPath;
+ private readonly string _serverKeyPath;
+
+ private PostgreSqlContainer? _postgreSqlContainer;
+
+ public PostgreSqlSslConfigTest()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "testcontainers-ssl-" + Guid.NewGuid().ToString("N")[..8]);
+ Directory.CreateDirectory(_tempDir);
+
+ _caCertPath = Path.Combine(_tempDir, CaCertFileName);
+ _serverCertPath = Path.Combine(_tempDir, ServerCertFileName);
+ _serverKeyPath = Path.Combine(_tempDir, ServerKeyFileName);
+ Path.Combine(_tempDir, ClientCertFileName);
+ Path.Combine(_tempDir, ClientKeyFileName);
+ }
+
+ private async Task EnsureContainerStartedAsync()
+ {
+ if (_postgreSqlContainer != null)
+ {
+ return;
+ }
+
+ // Generate SSL certificates for testing
+ await GenerateSSLCertificates();
+
+ // Create and start the PostgreSQL container with SSL configuration
+ _postgreSqlContainer = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .WithDatabase("testdb")
+ .WithUsername("testuser")
+ .WithPassword("testpass123")
+ .WithSSLSettings(_caCertPath, _serverCertPath, _serverKeyPath)
+ .WithWaitStrategy(Wait.ForUnixContainer()
+ .UntilInternalTcpPortIsAvailable(PostgreSqlBuilder.PostgreSqlPort)
+ .UntilMessageIsLogged("database system is ready to accept connections"))
+ .Build();
+
+ await _postgreSqlContainer.StartAsync();
+ }
+
+ private void Cleanup()
+ {
+ try
+ {
+ _postgreSqlContainer?.DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+ catch
+ {
+ // ignore
+ }
+
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task PostgreSqlContainerCanStartWithSSLSettings()
+ {
+ // Given
+ await EnsureContainerStartedAsync();
+ Assert.NotNull(_postgreSqlContainer);
+
+ // When
+ var connectionString = _postgreSqlContainer!.GetConnectionString();
+
+ // Then
+ Assert.NotEmpty(connectionString);
+ Assert.Contains("testdb", connectionString);
+ Assert.Contains("testuser", connectionString);
+ }
+
+ [Fact]
+ public async Task PostgreSqlContainerCanConnectWithSSL()
+ {
+ // Given
+ await EnsureContainerStartedAsync();
+ Assert.NotNull(_postgreSqlContainer);
+
+ var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_postgreSqlContainer!.GetConnectionString())
+ {
+ SslMode = Npgsql.SslMode.Require,
+ TrustServerCertificate = true // For testing only - in production use proper certificate validation
+ };
+
+ // When
+ await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString);
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+
+ // Then
+ Assert.Equal(ConnectionState.Open, connection.State);
+
+ // Verify SSL is being used
+ await using var command =
+ new NpgsqlCommand("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();", connection);
+ var sslIsUsed = await command.ExecuteScalarAsync(TestContext.Current.CancellationToken);
+ Assert.True(sslIsUsed is bool b && b, "SSL should be enabled for the connection");
+ }
+
+ [Fact]
+ public async Task PostgreSqlContainerWithSSLCanExecuteQueries()
+ {
+ // Given
+ await EnsureContainerStartedAsync();
+ Assert.NotNull(_postgreSqlContainer);
+
+ var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_postgreSqlContainer!.GetConnectionString())
+ {
+ SslMode = Npgsql.SslMode.Require,
+ TrustServerCertificate = true
+ };
+
+ // When
+ await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString);
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+
+ await using var command =
+ new NpgsqlCommand("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name VARCHAR(100));", connection);
+ await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
+
+ await using var insertCommand =
+ new NpgsqlCommand("INSERT INTO test_table (name) VALUES ('Test SSL Connection');", connection);
+ await insertCommand.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
+
+ await using var selectCommand = new NpgsqlCommand("SELECT COUNT(*) FROM test_table;", connection);
+ var count = await selectCommand.ExecuteScalarAsync(TestContext.Current.CancellationToken);
+
+ // Then
+ Assert.Equal(1L, count);
+ }
+
+ [Fact]
+ public void WithSSLCSettingsThrowsArgumentExceptionForEmptyCaCert()
+ {
+ // Given, When, Then
+ var exception = Assert.Throws(() =>
+ new PostgreSqlBuilder().WithSSLSettings("", _serverCertPath, _serverKeyPath));
+
+ Assert.Equal("caCertFile", exception.ParamName);
+ Assert.Contains("CA certificate file path cannot be null or empty", exception.Message);
+ }
+
+ [Fact]
+ public void WithSSLSettingsThrowsArgumentExceptionForEmptyServerCert()
+ {
+ // Given, When, Then
+ var exception = Assert.Throws(() =>
+ new PostgreSqlBuilder().WithSSLSettings(_caCertPath, "", _serverKeyPath));
+
+ Assert.Equal("serverCertFile", exception.ParamName);
+ Assert.Contains("Server certificate file path cannot be null or empty", exception.Message);
+ }
+
+ [Fact]
+ public void WithSSLSettingsThrowsArgumentExceptionForEmptyServerKey()
+ {
+ // Given, When, Then
+ var exception = Assert.Throws(() =>
+ new PostgreSqlBuilder().WithSSLSettings(_caCertPath, _serverCertPath, ""));
+
+ Assert.Equal("serverKeyFile", exception.ParamName);
+ Assert.Contains("Server key file path cannot be null or empty", exception.Message);
+ }
+
+ private async Task GenerateSSLCertificates()
+ {
+ // Create a simple RSA key pair for testing
+ using var rsa = RSA.Create(2048);
+
+ // Create CA certificate
+ var caCertRequest = new CertificateRequest(
+ "CN=Test CA, O=Testcontainers",
+ rsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ caCertRequest.CertificateExtensions.Add(
+ new X509BasicConstraintsExtension(true, false, 0, true));
+
+ caCertRequest.CertificateExtensions.Add(
+ new X509KeyUsageExtension(
+ X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
+ true));
+
+ var caNotBefore = DateTimeOffset.UtcNow.AddDays(-1);
+ var caNotAfter = DateTimeOffset.UtcNow.AddDays(365);
+ using var caCert = caCertRequest.CreateSelfSigned(caNotBefore, caNotAfter);
+
+ // Save CA certificate
+ await File.WriteAllTextAsync(_caCertPath,
+ "-----BEGIN CERTIFICATE-----\n" +
+ Convert.ToBase64String(caCert.RawData, Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END CERTIFICATE-----\n");
+
+ // Create server certificate
+ using var serverRsa = RSA.Create(2048);
+ var serverCertRequest = new CertificateRequest(
+ "CN=localhost, O=Testcontainers",
+ serverRsa,
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+
+ serverCertRequest.CertificateExtensions.Add(
+ new X509KeyUsageExtension(
+ X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
+ true));
+
+ serverCertRequest.CertificateExtensions.Add(
+ new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, // Server Authentication
+ true));
+
+ // Add Subject Alternative Names
+ var sanBuilder = new SubjectAlternativeNameBuilder();
+ sanBuilder.AddDnsName("localhost");
+ sanBuilder.AddIpAddress(IPAddress.Loopback);
+ serverCertRequest.CertificateExtensions.Add(sanBuilder.Build());
+
+ var serverNotBefore = caNotBefore.AddMinutes(1) > DateTimeOffset.UtcNow.AddDays(-1)
+ ? caNotBefore.AddMinutes(1)
+ : DateTimeOffset.UtcNow.AddDays(-1);
+ var serverNotAfter = caNotAfter.AddMinutes(-1);
+ using var serverCert = serverCertRequest.Create(
+ caCert,
+ serverNotBefore,
+ serverNotAfter,
+ new ReadOnlySpan(RandomNumberGenerator.GetBytes(16)));
+
+ // Save server certificate
+ await File.WriteAllTextAsync(_serverCertPath,
+ "-----BEGIN CERTIFICATE-----\n" +
+ Convert.ToBase64String(serverCert.RawData, Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END CERTIFICATE-----\n");
+
+ // Save server private key
+ await File.WriteAllTextAsync(_serverKeyPath,
+ "-----BEGIN PRIVATE KEY-----\n" +
+ Convert.ToBase64String(serverRsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END PRIVATE KEY-----\n");
+
+ // Set appropriate permissions for private key
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+ {
+ File.SetUnixFileMode(_serverKeyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSslTest.cs b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSslTest.cs
new file mode 100644
index 000000000..223bd7a00
--- /dev/null
+++ b/tests/Testcontainers.PostgreSql.Tests/PostgreSqlSslTest.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Data;
+using System.IO;
+using System.Threading.Tasks;
+using DotNet.Testcontainers.Configurations;
+using Npgsql;
+using Xunit;
+using Xunit.Internal;
+
+namespace Testcontainers.PostgreSql.Tests;
+
+[UsedImplicitly]
+public sealed class PostgreSqlSslTest : IAsyncLifetime
+{
+ private readonly string _tempDir;
+ private readonly string _caCertPath;
+ private readonly string _serverCertPath;
+ private readonly string _serverKeyPath;
+
+ private readonly PostgreSqlContainer _postgreSqlContainer;
+
+ public PostgreSqlSslTest()
+ {
+ _tempDir = Path.Combine(Path.GetTempPath(), "testcontainers-ssl-" + Guid.NewGuid().ToString("N").Substring(0, 8));
+ Directory.CreateDirectory(_tempDir);
+
+ _caCertPath = Path.Combine(_tempDir, "ca_cert.pem");
+ _serverCertPath = Path.Combine(_tempDir, "server.crt");
+ _serverKeyPath = Path.Combine(_tempDir, "server.key");
+
+ // Generate simple CA and server certificates for the test
+ GenerateCertificatesAsync().GetAwaiter().GetResult();
+
+ _postgreSqlContainer = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .WithDatabase("testdb")
+ .WithUsername("testuser")
+ .WithPassword("testpass123")
+ .WithSSLSettings(_caCertPath, _serverCertPath, _serverKeyPath)
+ .WithWaitStrategy(Wait.ForUnixContainer()
+ .UntilInternalTcpPortIsAvailable(PostgreSqlBuilder.PostgreSqlPort)
+ .UntilMessageIsLogged("database system is ready to accept connections"))
+ .Build();
+ }
+
+ [Fact]
+ public async Task PostgreSqlContainerCanConnectWithSsl()
+ {
+ // Given
+ await _postgreSqlContainer.StartAsync(TestContext.Current.CancellationToken);
+
+ var connectionStringBuilder = new NpgsqlConnectionStringBuilder(_postgreSqlContainer.GetConnectionString())
+ {
+ SslMode = Npgsql.SslMode.Require,
+ TrustServerCertificate = true
+ };
+
+ // When
+ await using var connection = new NpgsqlConnection(connectionStringBuilder.ConnectionString);
+ await connection.OpenAsync(TestContext.Current.CancellationToken);
+
+ // Then
+ Assert.Equal(ConnectionState.Open, connection.State);
+ }
+
+ public ValueTask InitializeAsync()
+ {
+ // no-op, container started within test
+ return ValueTask.CompletedTask;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (_postgreSqlContainer != null)
+ {
+ await _postgreSqlContainer.DisposeAsync();
+ }
+ }
+ finally
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+ }
+
+ private static async Task WritePemAsync(string path, byte[] derBytes, string begin, string end)
+ {
+ await File.WriteAllTextAsync(path, $"{begin}\n{Convert.ToBase64String(derBytes, Base64FormattingOptions.InsertLineBreaks)}\n{end}\n");
+ }
+
+ private async Task GenerateCertificatesAsync()
+ {
+ using var caRsa = System.Security.Cryptography.RSA.Create(2048);
+ var caReq = new System.Security.Cryptography.X509Certificates.CertificateRequest(
+ "CN=Test CA, O=Testcontainers",
+ caRsa,
+ System.Security.Cryptography.HashAlgorithmName.SHA256,
+ System.Security.Cryptography.RSASignaturePadding.Pkcs1);
+ caReq.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension(true, false, 0, true));
+ caReq.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509KeyUsageExtension(
+ System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.KeyCertSign |
+ System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.CrlSign, true));
+ var caNotBefore = DateTimeOffset.UtcNow.AddDays(-1);
+ var caNotAfter = DateTimeOffset.UtcNow.AddDays(365);
+ using var caCert = caReq.CreateSelfSigned(caNotBefore, caNotAfter);
+ await WritePemAsync(_caCertPath, caCert.RawData, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----");
+
+ using var serverRsa = System.Security.Cryptography.RSA.Create(2048);
+ var serverReq = new System.Security.Cryptography.X509Certificates.CertificateRequest(
+ "CN=localhost, O=Testcontainers",
+ serverRsa,
+ System.Security.Cryptography.HashAlgorithmName.SHA256,
+ System.Security.Cryptography.RSASignaturePadding.Pkcs1);
+ serverReq.CertificateExtensions.Add(new System.Security.Cryptography.X509Certificates.X509KeyUsageExtension(
+ System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.DigitalSignature |
+ System.Security.Cryptography.X509Certificates.X509KeyUsageFlags.KeyEncipherment, true));
+ var san = new System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder();
+ san.AddDnsName("localhost");
+ san.AddIpAddress(System.Net.IPAddress.Loopback);
+ serverReq.CertificateExtensions.Add(san.Build());
+ var serverNotBefore = caNotBefore.AddMinutes(1);
+ var serverNotAfter = caNotAfter.AddMinutes(-1);
+ using var serverCert = serverReq.Create(
+ caCert,
+ serverNotBefore,
+ serverNotAfter,
+ new ReadOnlySpan(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
+ await WritePemAsync(_serverCertPath, serverCert.RawData, "-----BEGIN CERTIFICATE-----", "-----END CERTIFICATE-----");
+ await File.WriteAllTextAsync(_serverKeyPath, "-----BEGIN PRIVATE KEY-----\n" +
+ Convert.ToBase64String(serverRsa.ExportPkcs8PrivateKey(), Base64FormattingOptions.InsertLineBreaks) +
+ "\n-----END PRIVATE KEY-----\n");
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+ {
+ File.SetUnixFileMode(_serverKeyPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+}
\ No newline at end of file