DEV Community

Cover image for How to use Testcontainers with PostgreSQL and SSL
özkan pakdil
özkan pakdil

Posted on Originally published at ozkanpakdil.github.io on

How to use Testcontainers with PostgreSQL and SSL

Using Testcontainers with PostgreSQL over SSL/TLS (with client certificates)

This guide walks you through running PostgreSQL in Testcontainers with SSL enabled and client-certificate authentication (verify-full). It explains:

  • How to generate a CA, server, and client certificates.
  • How to prepare certificate/key files and the right formats/permissions.
  • How to configure PostgreSQL (postgresql.conf and pg_hba.conf) to require client certificates.
  • How to wire everything up in Testcontainers.
  • How to connect from Java (JDBC) and optionally from the psql CLI.

Everything here is based on the code in this repository, particularly:

TL;DR

  • Generate a self-signed CA.
  • Issue a server cert for hostname localhost (SAN=DNS:localhost) and a client cert for the DB user (CN=testuser).
  • Copy certs/keys into the container during init; set ssl=on and point PostgreSQL to the files.
  • Set pg_hba.conf rules to require client certs with verify-full.
  • Connect from Java using JSSE keystore/truststore or from psql using PEM files.

Prerequisites

  • Docker installed and accessible by Testcontainers.
  • Java 17+ and Maven 3.9+.
  • Optionally, psql installed if you want to try the CLI connection.

Certificate model and requirements We’ll use a minimal but correct PKI model:

  • A self-signed Certificate Authority (CA) issues the server and client certificates.
  • The server certificate must include the DNS name clients will use to connect; here we use localhost and set SAN=DNS:localhost.
  • The client certificate’s CN should match a PostgreSQL role; we’ll create role testuser and issue a client cert with CN=testuser.

Key points and formats:

  • PostgreSQL accepts server key/cert in PEM files referenced by postgresql.conf.
  • The client key for psql must be readable only by the user (0600) or psql will refuse to use it.
  • PgJDBC (when using libpq factory) expects the client private key in PKCS#8 (BEGIN PRIVATE KEY). We handle that in this project.

Generating the CA, server, and client certificates This project generates all materials at runtime for you (see CryptoUtil.java). The essentials:

  • Create a self‑signed CA:
    • Is a CA cert (basicConstraints CA:TRUE)
    • Has keyCertSign and cRLSign key usages
  • Issue a server certificate (Extended Key Usage: serverAuth) with SAN=DNS:localhost
  • Issue a client certificate (Extended Key Usage: clientAuth) with CN=testuser

Snippet (simplified from CryptoUtil):

    var ca = generateSelfSignedCA("Demo-CA");
    var server = issueCertificate(ca, "localhost", true, List.of("localhost"));
    var client = issueCertificate(ca, "testuser", false, List.of());

    // Write to PEM files
    writePemCertificate(caCertPem, ca.certificate());
    writePemCertificate(serverCertPem, server.certificate());
    writePemPrivateKey(serverKeyPem, server.keyPair().getPrivate()); // server: PKCS#1 (RSA PRIVATE KEY) ok

    writePemCertificate(clientCertPem, client.certificate());
    writePemPrivateKeyPkcs8(clientKeyPem, client.keyPair().getPrivate()); // client: PKCS#8 for PgJDBC

Enter fullscreen mode Exit fullscreen mode

About formats:

  • Server key: the code writes PKCS#1 (BEGIN RSA PRIVATE KEY), which PostgreSQL accepts.
  • Client key: written as PKCS#8 (BEGIN PRIVATE KEY) so PgJDBC can load it if using libpq factory.

Permissions for the client key (psql requires 0600):

    // Best-effort on POSIX; otherwise fallback to owner-only readable/writable
    Set<PosixFilePermission> perms = EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
    Files.setPosixFilePermissions(clientKeyPem, perms);

Enter fullscreen mode Exit fullscreen mode

Java keystore/truststore (JSSE) for JDBC:

    // Client key + chain in PKCS#12 keystore (client.p12)
    createPkcs12KeyStore(clientKeystore, "client", client.keyPair().getPrivate(),
            new Certificate[]{client.certificate(), ca.certificate()}, KEYSTORE_PASSWORD);

    // Truststore containing the CA
    createPkcs12TrustStore(truststore, "ca", ca.certificate(), KEYSTORE_PASSWORD);

Enter fullscreen mode Exit fullscreen mode

Configuring PostgreSQL for SSL + client cert auth We need to:

  1. Enable SSL in postgresql.conf and point to server cert/key and CA.
  2. Require client certificates in pg_hba.conf with verify-full.

A minimal pg_hba.conf used here:

    # TYPE DATABASE USER ADDRESS METHOD

    # Allow local socket connections for init scripts and superuser tasks
    local all all trust
    host all all 127.0.0.1/32 trust
    host all all ::1/128 trust

    # Require client certs for all TCP connections
    hostssl all all 0.0.0.0/0 cert clientcert=verify-full
    hostssl all all ::0/0 cert clientcert=verify-full

Enter fullscreen mode Exit fullscreen mode

The postgresql.conf changes we apply during container init:

    ssl=on
    ssl_cert_file='server.crt'
    ssl_key_file='server.key'
    ssl_ca_file='root.crt'
    listen_addresses='*'

Enter fullscreen mode Exit fullscreen mode

We write an init shell script (00-ssl.sh) that the official postgres image runs once on first startup:

    #!/bin/bash
    set -euo pipefail

    echo '[ssl-init] Configuring Postgres SSL (copying files into PGDATA)'
    cp /docker-entrypoint-initdb.d/server.crt "$PGDATA"/server.crt
    cp /docker-entrypoint-initdb.d/server.key "$PGDATA"/server.key
    cp /docker-entrypoint-initdb.d/root.crt "$PGDATA"/root.crt
    chmod 600 "$PGDATA"/server.key || true
    chmod 644 "$PGDATA"/server.crt "$PGDATA"/root.crt || true

    echo "ssl=on" >> "$PGDATA"/postgresql.conf
    echo "ssl_cert_file='server.crt'" >> "$PGDATA"/postgresql.conf
    echo "ssl_key_file='server.key'" >> "$PGDATA"/postgresql.conf
    echo "ssl_ca_file='root.crt'" >> "$PGDATA"/postgresql.conf
    echo "listen_addresses='*'" >> "$PGDATA"/postgresql.conf

    # Replace pg_hba.conf with our rules
    cp /docker-entrypoint-initdb.d/pg_hba.conf "$PGDATA"/pg_hba.conf

Enter fullscreen mode Exit fullscreen mode

Finally, create the database role that matches the client cert’s CN (testuser):

    CREATE ROLE "testuser" LOGIN;

Enter fullscreen mode Exit fullscreen mode

Wiring it up with Testcontainers The PostgreSQLContainer is started with SSL assets and init scripts copied into /docker-entrypoint-initdb.d:

    pg = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("postgres")
        .withUsername("postgres")
        .withPassword("postgres")
        .withStartupTimeout(Duration.ofSeconds(30))
        .withLogConsumer(new Slf4jLogConsumer(LOG).withSeparateOutputStreams())

        // Init SQL and HBA rules
        .withCopyFileToContainer(MountableFile.forHostPath(initSql), "/docker-entrypoint-initdb.d/01-init.sql")
        .withCopyFileToContainer(MountableFile.forHostPath(pgHba), "/docker-entrypoint-initdb.d/pg_hba.conf")

        // SSL assets; the 00-ssl.sh script moves them into $PGDATA
        .withCopyFileToContainer(MountableFile.forHostPath(serverCertPem, 0644), "/docker-entrypoint-initdb.d/server.crt")
        .withCopyFileToContainer(MountableFile.forHostPath(serverKeyPem, 0644), "/docker-entrypoint-initdb.d/server.key")
        .withCopyFileToContainer(MountableFile.forHostPath(caCertPem, 0644), "/docker-entrypoint-initdb.d/root.crt")
        .withCopyFileToContainer(MountableFile.forHostPath(sslInit, 0755), "/docker-entrypoint-initdb.d/00-ssl.sh");

    pg.start();

Enter fullscreen mode Exit fullscreen mode

Notes:

  • We initially set relaxed permissions (0644) on server.key while it lives in /docker-entrypoint-initdb.d so the postgres user inside the container can read it during init. The script then tightens perms when moving it into $PGDATA.
  • Make sure your server certificate’s SAN includes the hostname you’ll use from the client (localhost here). verify-full enforces this.

Connecting from Java (JDBC) with verify-full Two approaches are common:

  • JSSE keystore/truststore (used here): put client key+cert chain into a PKCS#12 keystore and CA into a PKCS#12 truststore; use DefaultJavaSSLFactory.
  • Libpq (PEM) style files via PgJDBC’s LibPQFactory (not shown here, but compatible with the PEM files you generated).

In this project we use JSSE:

    String url = "jdbc:postgresql://localhost:" + pg.getFirstMappedPort() + "/postgres";

    // JSSE system properties
    System.setProperty("javax.net.ssl.keyStore", clientKeystore.toAbsolutePath().toString());
    System.setProperty("javax.net.ssl.keyStorePassword", new String(KEYSTORE_PASSWORD));
    System.setProperty("javax.net.ssl.keyStoreType", "PKCS12");
    System.setProperty("javax.net.ssl.trustStore", truststore.toAbsolutePath().toString());
    System.setProperty("javax.net.ssl.trustStorePassword", new String(KEYSTORE_PASSWORD));
    System.setProperty("javax.net.ssl.trustStoreType", "PKCS12");

    Properties props = new Properties();
    props.setProperty("user", "testuser");
    props.setProperty("ssl", "true");
    props.setProperty("sslmode", "verify-full");
    props.setProperty("sslfactory", "org.postgresql.ssl.DefaultJavaSSLFactory");

    try (Connection conn = DriverManager.getConnection(url, props)) {
        // ... use the connection
    }

Enter fullscreen mode Exit fullscreen mode

Verification query used in the test:

    select current_user,
           (select ssl from pg_stat_ssl where pid = pg_backend_pid()) as ssl_used;

Enter fullscreen mode Exit fullscreen mode

Connecting with psql (optional) If you have psql on your PATH, you can connect with the generated PEM files and verify-full:

  • Set the environment variables:
    • PGHOST=localhost
    • PGPORT=
    • PGDATABASE=postgres
    • PGUSER=testuser
    • PGSSLMODE=verify-full
    • PGSSLROOTCERT=/path/to/root.crt
    • PGSSLCERT=/path/to/client.crt
    • PGSSLKEY=/path/to/client.key (must be 0600)

Example invocation (from the test):

    psql --set ON_ERROR_STOP=on -c "select 1;"

Enter fullscreen mode Exit fullscreen mode

The test builds a Process with these environment variables and checks the exit code to be 0.

Common pitfalls and troubleshooting

  • SAN and hostname mismatch: verify-full checks that the hostname you connect to matches a SAN on the server certificate. Include DNS:localhost in SAN if you connect to localhost.
  • Wrong key format for client key in PgJDBC: use PKCS#8 (BEGIN PRIVATE KEY) for compatibility with libpq factories, or use JSSE keystore as shown.
  • Client key permissions: psql requires 0600 on the private key; otherwise it refuses to use it.
  • CA trust: ensure your client trusts the issuing CA. With JSSE, put the CA into a truststore; with psql, pass sslrootcert.
  • Server key permissions: PostgreSQL demands restrictive permissions on server.key (the init script sets 600 in $PGDATA).
  • Docker not available: tests in this project self-skip if Docker is not reachable; ensure Docker is running.
  • Container startup hangs: set reasonable timeouts; this project uses a 30s startup timeout and a class-level @Timeout(60).

Security notes

  • Use a real CA and long(er) lifetimes for non-test environments.
  • Manage keys securely; don’t commit private keys to VCS. This project generates them at test time only.
  • Prefer verify-full over verify-ca; verify-full protects you from hostname spoofing.
  • Rotate keys/certs periodically; keep revocation in mind for production.

Full flow summary

  1. Generate runtime CA, server (SAN=localhost) and client (CN=testuser) certificates.
  2. Create keystore/truststore for Java and write PEM files for PostgreSQL and psql.
  3. Copy certs/keys and pg_hba.conf into the postgres container; run an init script to enable SSL and tighten permissions.
  4. Create role testuser.
  5. Connect via JDBC using verify-full (JSSE) or via psql with PEM files.

That’s it—you now have a fully working Postgres over SSL with client certificates inside Testcontainers, including both JDBC and CLI examples.

Top comments (0)