Back to Blog
Tutorial7 min read2026-07-13

How to Deploy a tRPC Server with PostgreSQL

Deploy a standalone tRPC server: adapter choice, tsup build, the monorepo type-sharing gotcha, Drizzle migrations on DATABASE_URL, and going live via Git.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

tRPC isn't a framework — it's a type-safe RPC layer that rides on whatever HTTP server you give it. That's what makes it pleasant to develop with, and it's also why "deploy tRPC" confuses people: there's no trpc deploy, no official production guide for your exact setup. What you actually deploy is the Node server underneath it, plus one wrinkle unique to tRPC — your client depends on the server's *types* — that changes how you structure the repo.

Here's the whole thing for a standalone tRPC v11 server with PostgreSQL.

The server you're actually deploying

A minimal router backed by a database:

// src/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { db } from './db';
import { users } from './db/schema';

const t = initTRPC.create();

export const appRouter = t.router({
  userList: t.procedure.query(() => db.select().from(users)),
  userCreate: t.procedure
    .input(z.object({ email: z.string().email() }))
    .mutation(({ input }) =>
      db.insert(users).values(input).returning()
    ),
});

export type AppRouter = typeof appRouter;

And the entrypoint, using the standalone adapter — the simplest option when tRPC is the only thing this service does:

// src/server.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import cors from 'cors';
import { appRouter } from './router';

createHTTPServer({
  router: appRouter,
  middleware: cors(),
}).listen(Number(process.env.PORT) || 3000);

Two production notes baked in there. PORT comes from the environment because that's how every platform tells your app where to listen. And the middleware option accepts connect-style middleware — cors() is the one you'll need the moment a browser on another origin calls this API, and it's much easier to add now than to debug later as a preflight failure.

If your tRPC router lives inside an existing Express app, use the Express adapter instead and everything else in this post still applies:

import * as trpcExpress from '@trpc/server/adapters/express';

app.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter }));

Building TypeScript for production

Don't run tsx or ts-node in production — compile once at build time. tsup keeps this to one command with no config file:

{
  "scripts": {
    "build": "tsup src/server.ts --format esm --clean",
    "start": "node dist/server.js",
    "dev": "tsx watch src/server.ts"
  }
}

Plain tsc with an outDir works just as well if you prefer zero extra tooling. Either way, check your dependency split: zod validates inputs at runtime, so it must be a real dependency, not a devDependency — a mistake that only surfaces when npm ci --omit=dev runs in the production image and the server crashes on boot.

The type-sharing gotcha

This is the part that's genuinely tRPC-specific. Your client gets its end-to-end types like this:

import type { AppRouter } from '../server/src/router';

const client = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: 'https://api.example.com' })],
});

The critical keyword is import type. Type-only imports are erased at compile time, so the client bundle contains zero server code — it just borrows the shape. If you write a plain import instead, your bundler will happily try to pull your database layer into the frontend, and you'll find out via a very confusing build error.

In practice this means a monorepo (pnpm workspaces is the common setup) with server and web packages. The thing you deploy is only the server package. Point the platform's root directory at server/, or make the build command workspace-aware:

pnpm --filter server build

The client never gets deployed *with* the server — it just needs the server's source present at its own build time for the types.

Database with Drizzle

Drizzle pairs well with tRPC — the whole stack stays in TypeScript, inference end to end. Connection:

// src/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';

export const db = drizzle(process.env.DATABASE_URL!);

Schema:

// src/db/schema.ts
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').notNull().unique(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

And the migration config:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './drizzle',
  dbCredentials: { url: process.env.DATABASE_URL! },
});

The workflow has two distinct commands, and mixing them up matters:

npx drizzle-kit generate   # diffs schema.ts, writes SQL files to ./drizzle
npx drizzle-kit migrate    # applies pending SQL files via DATABASE_URL

Run generate locally and commit the SQL files — they're the reviewable record of what will hit production. Run migrate as a release step before the new version takes traffic, never in the server's boot path where two instances starting together can race. (Skip drizzle-kit push outside local development; it mutates the schema directly with no migration history.)

A Dockerfile for the tRPC server

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
COPY drizzle ./drizzle
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Copying the drizzle/ folder into the image means the same artifact that runs the app can also run its migrations. The exec-form CMD matters too — it lets SIGTERM reach node directly so the server can drain in-flight requests during deploys and scale-downs.

Deploying on PandaStack

  1. 1Provision a managed PostgreSQL (14.x or 16.x) from the [dashboard](https://dashboard.pandastack.io).
  2. 2Connect the repo as a container app. PandaStack auto-detects Node.js and the build/start commands, or uses your Dockerfile if you committed one. Monorepo? Point it at the server package. Install command is overridable — npm, yarn, pnpm, and bun all work.
  3. 3Attach the database. DATABASE_URL is injected automatically, and it's the exact variable both drizzle() and drizzle-kit migrate already read — the connection config you wrote for local dev is the production config, unchanged.
  4. 4Push. The build runs in an ephemeral, rootless BuildKit job with logs streamed live, then deploys. Deployment history and rollbacks are there when a release needs undoing.
  5. 5Verify with a raw HTTP call — tRPC queries are plain GETs underneath:
curl https://your-app.example.com/userList
# {"result":{"data":[...]}}

On the free tier, idle apps scale to zero and cold-start on the next request — one more reason migrations belong in a release step rather than the boot path, so cold starts stay as fast as node can load your bundle.

That's it: a standalone adapter on PORT, a compiled bundle, type-only imports across the repo boundary, and committed SQL migrations riding the same DATABASE_URL as the app. If you want the database provisioning and wiring handled for you, it's straightforward to try on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also