6 min read
Original source

Add SSL to Drizzle + NestJS for AWS RDS (Postgres)

Encrypting database traffic isn’t optional—especially if you’re handling user data or building anything that might one day need audits or certifications.…

Encrypting database traffic isn’t optional—especially if you’re handling user data or building anything that might one day need audits or certifications. Fortunately, enabling SSL/TLS for Postgres on AWS RDS with Drizzle ORM (node-postgres) in a NestJS app is straightforward.

Below is a clean, production-ready setup that:

  • Uses the official AWS RDS global trust store
  • Verifies the server certificate (no rejectUnauthorized: false shenanigans)
  • Works with Drizzle Kit and your NestJS app
  • Plays nicely with Docker/Kubernetes through secrets

What you’ll use

  • AWS RDS Postgres
  • Drizzle ORM with node-postgres
  • AWS RDS trust bundle: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem

Why this file? It’s Amazon’s CA bundle for RDS instances across regions. Supplying it ensures TLS cert verification succeeds without disabling hostname checks.

Step 1: Add the RDS trust bundle to your project (or store it as a secret)

Option A — Commit it (fastest to demo; fine for public, non-rotating CA bundles):

/global-bundle.pem

Option B — Provision it at build/runtime (recommended for infra):

  • In Dockerfile (build-time): RUN curl -fsSL https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -o /app/global-bundle.pem
  • Or mount it via Kubernetes Secret or your platform’s secret manager.

Tip: Don’t disable verification. Avoid ssl: { rejectUnauthorized: false }. You want full cert validation to prevent MITM.

Step 2: Configure Drizzle Kit to connect with SSL

Your Drizzle Kit config should pass the CA certificate via dbCredentials.ssl.ca. Example:

import * as fs from "fs"
import * as path from "path"
 
import { defineConfig } from "drizzle-kit"
 
const certificate = fs.readFileSync(path.resolve(__dirname, "global-bundle.pem")).toString()
 
export default defineConfig({
  schema: "./src/**/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    host: process.env.DATABASE_HOST!,
    port: parseInt(process.env.DATABASE_PORT!, 10),
    user: process.env.DATABASE_USER!,
    password: process.env.DATABASE_PASSWORD!,
    database: process.env.DATABASE_NAME!,
    ssl: { ca: certificate }, // verification enabled by default
  },
})
import { defineConfig } from 'drizzle-kit';
import * as fs from 'fs';
import * as path from 'path';
 
const certificate = fs
  .readFileSync(path.resolve(__dirname, 'global-bundle.pem'))
  .toString();
 
export default defineConfig({
  schema: './src/**/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    host: process.env.DATABASE_HOST!,
    port: parseInt(process.env.DATABASE_PORT!, 10),
    user: process.env.DATABASE_USER!,
    password: process.env.DATABASE_PASSWORD!,
    database: process.env.DATABASE_NAME!,
    ssl: { ca: certificate }, // verification enabled by default
  },
});

Running migrations with SSL

Once your env vars are set, run:

pnpm drizzle-kit generate
pnpm drizzle-kit migrate

If you see self signed certificate in certificate chain, you’re either missing the CA bundle or the file path is wrong.

Step 3: Create a NestJS DatabaseModule with verified SSL

Use pg’s Pool with the same CA. In production, enable SSL; for local dev against a local Postgres, you can skip SSL.

import { Module } from "@nestjs/common"
import { drizzle } from "drizzle-orm/node-postgres"
import { ConfigModule, ConfigService } from "@nestjs/config"
import { Pool } from "pg"
import * as fs from "fs"
import * as path from "path"
import { DATABASE_CONNECTION } from "./database-connection"
 
export const schema = {
  ...schema,
}
 
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: DATABASE_CONNECTION,
      useFactory: (configService: ConfigService) => {
        let ssl: any = false
 
        if (configService.get("NODE_ENV") === "production") {
          const certPath = path.resolve(__dirname, "../../global-bundle.pem")
          const certificate = fs.readFileSync(certPath).toString()
 
          // rejectUnauthorized is true by default when CA is provided.
          // Explicitly set it for clarity:
          ssl = { ca: certificate, rejectUnauthorized: true }
        }
 
        const pool = new Pool({
          host: configService.getOrThrow("DATABASE_HOST"),
          port: parseInt(configService.getOrThrow("DATABASE_PORT"), 10),
          user: configService.getOrThrow("DATABASE_USER"),
          password: configService.getOrThrow("DATABASE_PASSWORD"),
          database: configService.getOrThrow("DATABASE_NAME"),
          ssl,
        })
 
        return drizzle(pool, { schema })
      },
      inject: [ConfigService],
    },
  ],
  exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}
import { Module } from '@nestjs/common';
import { drizzle } from 'drizzle-orm/node-postgres';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
import * as fs from 'fs';
import * as path from 'path';
import { DATABASE_CONNECTION } from './database-connection';
 
export const schema = {
  ...schema
};
 
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: DATABASE_CONNECTION,
      useFactory: (configService: ConfigService) => {
        let ssl: any = false;
 
        if (configService.get('NODE_ENV') === 'production') {
          const certPath = path.resolve(__dirname, '../../global-bundle.pem');
          const certificate = fs.readFileSync(certPath).toString();
 
          // rejectUnauthorized is true by default when CA is provided.
          // Explicitly set it for clarity:
          ssl = { ca: certificate, rejectUnauthorized: true };
        }
 
        const pool = new Pool({
          host: configService.getOrThrow('DATABASE_HOST'),
          port: parseInt(configService.getOrThrow('DATABASE_PORT'), 10),
          user: configService.getOrThrow('DATABASE_USER'),
          password: configService.getOrThrow('DATABASE_PASSWORD'),
          database: configService.getOrThrow('DATABASE_NAME'),
          ssl,
        });
 
        return drizzle(pool, { schema });
      },
      inject: [ConfigService],
    },
  ],
  exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

Why not always-on SSL?

const certificate = fs.readFileSync('/app/global-bundle.pem', 'utf8');
const ssl = { ca: certificate, rejectUnauthorized: true };

You can keep SSL on everywhere. Many teams keep:

…and use it in all environments. For local Docker/Compose you might also run Postgres with TLS. The split above is just a pragmatic default.

Important: Don’t use DATABSE_URL

Avoid using the DATABASE_URL environment variable for configuring the connection string. Using it will override the SSL option. Instead, explicitly provide the connection string parts like in our example.

Step 4: Environment variables

Typical .env:

NODE_ENV=production
DATABASE_HOST=mydb.abcdefg12345.us-east-1.rds.amazonaws.com
DATABASE_PORT=5432
DATABASE_USER=app_user
DATABASE_PASSWORD=supersecret
DATABASE_NAME=app_db

Ensure the RDS hostname is the actual endpoint hostname (not an IP). Hostname verification is part of TLS security.

Step 5: Docker/Kubernetes tips

Dockerfile snippet

# Install CA bundle during build
RUN curl -fsSL https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -o /app/global-bundle.pem

Kubernetes Secret (recommended)

kubectl create secret generic rds-ca \
  --from-url=https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem

Then mount it:

volumeMounts:
  - name: rds-ca
    mountPath: /app/certs
    readOnly: true
volumes:
  - name: rds-ca
    secret:
      secretName: rds-ca

And update your code to read /app/certs/global-bundle.pem.

Step 6: Quick local verification with psql

If you have psql:

psql "host=mydb.abcdefg12345.us-east-1.rds.amazonaws.com \
      port=5432 dbname=app_db user=app_user sslmode=verify-full \
      sslrootcert=./global-bundle.pem"

If that works, your CA bundle and hostname verification are sound.

Common pitfalls & fixes

  • self signed certificate in certificate chain
    The CA bundle isn’t being read. Check the path, ensure the file exists in the container/pod, and verify permissions.
  • Using rejectUnauthorized: false
    This disables cert and hostname verification; don’t do this in production. It defeats the purpose of TLS.
  • Wrong host
    TLS verification uses the hostname. If you connect by IP or a mismatched hostname, verification fails. Always use the RDS endpoint.
  • RDS Proxy
    Works fine with the same CA approach; use the proxy endpoint hostname.

TL;DR

  1. Download the RDS global trust bundle:
    https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
  2. Provide it to Drizzle Kit and node-postgres via ssl: { ca: <file>, rejectUnauthorized: true }.
  3. Use the RDS endpoint hostname (not IP) to pass hostname verification.
  4. Prefer secrets/volumes for production deployments.

That’s it—you’ve got encrypted, verified connections from NestJS + Drizzle to AWS RDS.

The post Add SSL to Drizzle + NestJS for AWS RDS (Postgres) appeared first on Michael Guay.

Add SSL to Drizzle + NestJS for AWS RDS (Postgres) | NestJS.io