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 buildThe 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_URLRun 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
- 1Provision a managed PostgreSQL (14.x or 16.x) from the [dashboard](https://dashboard.pandastack.io).
- 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.
- 3Attach the database.
DATABASE_URLis injected automatically, and it's the exact variable bothdrizzle()anddrizzle-kit migratealready read — the connection config you wrote for local dev is the production config, unchanged. - 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.
- 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.