~ 6 min read

How to run tRPC and Prisma on AWS with the CDK

Pitfalls, Tips and Tricks to get your tRPC / Prisma Combo into the cloud.
Image credit: www.midjourney.com

We have had the pleasure of launching a microfrontend feature using tRPC, Prisma and the AWS CDK. While official documentation exists, putting that into practice we encountered a few pitfalls. If you are also looking to leverage the power of tRPC and Prisma on your next app on top of AWS, this post on leveraging tRPC, Prisma, and AWS CDK is for you!

We will first go through a primer on tRPC and Prisma. Knowing the why, we will go through the how, and notable obstacles you might also face. Of course the whole code supporting this post is also available as a working template.

Why Choose Prisma and tRPC?Section titled Why Choose Prisma and tRPC?

Our journey with Prisma and tRPC emerged as a game-changer in boosting our application’s efficiency and scalability. Prisma simplifies database access by auto-generating a type-safe query builder for TypeScript and Node.js. No more wrestling with raw SQL or settling for the limitations of traditional ORMs. On the other hand, tRPC stands out by offering end-to-end type safety for your API routes, bridging the gap between your data and the frontend seamlessly.

Frontend to Backend ConnectionSection titled Frontend to Backend Connection

Here’s how you can set up a seamless frontend-to-backend connection with AWS, tRPC, and Prisma.

AWS usually has tons of options for any given requirement. Hosting a website is the exception. AWS has only one service for content distribution: AWS Cloudfront. The AWS S3 storage service can also do basic webhosting, but it is only regional and does not support https. A simple fact that sends many innocent cloud engineers down the rabbit hole of configuring Cloudfront correctly.

To mimic the local setup, the Cloudfront distribution will serve as the entry point and it will proxy all requests matching /api/* to the Api Gateway. To do this, you will already need a special forwarding policy, otherwise you will get a Bad Request error:

    const originRequestPolicy = new OriginRequestPolicy(this, "origin-request", {
      originRequestPolicyName: `${projectName}-forwarding`,
      headerBehavior: OriginRequestHeaderBehavior.allowList(
        "CloudFront-Forwarded-Proto",
        "Origin",
        "Content-Type"
      ),
      cookieBehavior: OriginRequestCookieBehavior.all(),
      queryStringBehavior: OriginRequestCookieBehavior.all()
    })

The API PathSection titled The API Path

To ensure we have the same api structure locally as in the cloud, this proxy configuration works best. Misconfiguration here can be hard to debug, especially in combination with Cloudfront acting as the api proxy.

The Api Route config (/api/trpc/{proxy}):

    const trpcResource = api.root.addResource("api")
    const trpcApiResource = trpcResource.addResource("trpc")
    trpcApiResource.addProxy({
      anyMethod: true,
      defaultIntegration: new LambdaIntegration(handler),
    })

Into a friendly api name which hides also the Api Gateway Stage (/prod prefix in this case):

    // ...
    new ARecord(this, `WebsiteAliasRecord`, {
      zone: hostedZone,
      recordName: apiDomain,
      target: RecordTarget.fromAlias(new ApiGateway(api)),
    });

Into a Cloudfront origin forwarding anything below /api/* to that nice record:

      additionalBehaviors: {
        "/api/*": {
          origin: trpcApiOrigin,
          allowedMethods: AllowedMethods.ALLOW_ALL,
          viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: CachePolicy.CACHING_DISABLED,
          originRequestPolicy: originRequestPolicy
        },
      },

Building and BundlingSection titled Building and Bundling

Naturally, when building on AWS, the most favorite deployment tool is the CDK. After a very long period of undifferentiated hype, the CDK is finally met with some sober honesty (see voices from Alex DeBrie and Yan Cui), while it still remains the tool of choice for many companies today.

To build the api with the tRPC, you will need the correct bundling steps. Point your entry at the file exporting the lambda handler:

      trpcLambda = new NodejsFunction(this, 'trpcApiFunction', {
        // ...
        entry: path.join(__dirname, '../../src/server/routers/index.ts'),
        handler: 'handler',
        // ...
      })

And you’ll be able to bundle it with this config:

        bundling: {
          commandHooks: {
            beforeBundling(inputDir: string, outputDir: string): string[] {
              return []
            },
            beforeInstall(inputDir: string, outputDir: string) {
              return []
            },
            afterBundling(inputDir: string, outputDir: string): string[] {
              return [
                `cd ${outputDir}`,
                `cp -R ${inputDir}/node_modules/prisma/libquery_engine-rhel-openssl-1.0.x.so.node ${outputDir}/`,
                `npx prisma generate --schema=${inputDir}/prisma/schema.prisma`,
                `cp -R ${inputDir}/prisma/ .`,
                `rm -rf node_modules/@prisma/engines`,
              ]
            },
          },
        },

Notably this also requires adding the query engine required by the lambda runtime to your prisma.schema:

generator client {
  provider = "prisma-client-js"
  binaryTargets = ["native", "rhel-openssl-1.0.x"]
}

The Lambda HandlerSection titled The Lambda Handler

This brings us to the handler itself. To expose the tRPC router as a lambda handler, tRPC offers a nice lamdba adapter:

// src/server/router/index.ts
import { awsLambdaRequestHandler } from '@trpc/server/adapters/aws-lambda';

import { appRouter } from './_app';

// adapted example from: https://trpc.io/docs/v9/aws-lambda
export const handler = awsLambdaRequestHandler({
  router: appRouter,
});

Connecting to the DBSection titled Connecting to the DB

Typically, as you’re building on AWS, you will have your relational database running in RDS. To get the database credentials which are usually stored in a AWS Secretsmanager Secret, we’ll have to move the Prisma client into something async. That’s because we will need to make sure to first fetch the credentials before instantiating our tRPC client.

To fetch it, you can make use of this very crappy helper function. But hey, it works and its cached!

import { SecretsManager } from 'aws-sdk';

export const secretId = process.env.DB_SECRET_ARN;

type dbSecret = {
  host: string;
  username: string;
  password: string;
};

const secretsManager = new SecretsManager({
  region: 'eu-central-1',
});
let secret: dbSecret;

export async function fetchSecret() {
  if (process.env.NODE_ENV === 'development') {
    if (!process.env.DATABASE_URL)
      throw 'DB URL cannot be found in development env variables';
    return process.env.DATABASE_URL;
  }
  console.log(`trying to fetch secret ${secretId}`);
  if (!secretId) throw 'Env variable for secret not set';

  if (!secret) {
    console.log('fetching secret...');
    const secretresponse = await secretsManager
      .getSecretValue({ SecretId: secretId })
      .promise();
    if (!secretresponse.SecretString) throw 'empty response';
    secret = JSON.parse(secretresponse.SecretString);
    console.log(`secret fetched: ${secret.host},...`);
  } else {
    console.log('secret already fetched!');
    console.debug(secret.host);
  }

  console.log(`returning secret: ${secret.host},...`);
  return `postgres://${secret.username}:${secret.password}@${secret.host}/main`;
}

export let secretUrl: string;
(async () => {
  secretUrl = await fetchSecret();
})();

The nice thing is that it will work with your Dockerfile local setup and in your cloud environment without adding any extra build flags.

You can then remove your static instantiation of the Prisma client, as it will be async. Within each tRPC route, you need to then add:

      await initPrisma()

Which calls this helper function to prevent extra calls:

async function initPrisma() {
  console.log(secretId)
  if (!secret) {
    secret = await fetchSecret()
  }
  if (!prisma) {
    console.log("setting prisma...")
    prisma = new PrismaClient({
      log: ['query'],
      datasources: {
        db: {
          url: secret
        }
      }
    })
    console.log("prisma set.")
  } else {
    console.log("prisma already set.")
  }
}

Review AppsSection titled Review Apps

Last but not least the example repo also contains the code necessary to setup Review Apps - branch based deployments of your frontend stack. One technique which we already went in-depth into in a previous post, and we’re using again here.

Rounding upSection titled Rounding up

All in all working with tRPC and Prisma has been great fun, and as always, your first app will always be the hardest.

We hope with this post we can help others on their journey and save maybe somebody some very late night 😃. Also, the repo for the code behind this post is available here. Feel free to contribute if you have suggestions!

Any Comments? Join the Discussion on Twitter: