How to Deploy T3 Stack on Ubuntu 24.04 VPS: Next.js + tRPC + Prisma + NextAuth with Postgres
The T3 Stack is the fastest way to ship a type-safe, full-stack TypeScript application. Next.js handles routing and rendering, tRPC gives you end-to-end typed APIs with no schema duplication, Prisma owns your database access layer, and NextAuth.js handles the authentication heavy lifting. This guide takes you from a fresh Ubuntu 24.04 VPS to a hardened production deployment: you will scaffold a project with create-t3-app, stand up PostgreSQL locally, wire up NextAuth with both Credentials and GitHub OAuth providers, build a tRPC router, compile Next.js in standalone mode, run it under PM2, and terminate TLS at Nginx with a free Let's Encrypt certificate.
Already know Node and Next.js? Jump to Step 5: Bootstrap a T3 Project and skim the sections you need.
Table of Contents
What is the T3 Stack?
The T3 Stack is an opinionated, full-stack TypeScript starter created by Theo Browne and the T3 community. It bundles six tools that are each best-in-class on their own, with the integration boilerplate already wired up:
- Next.js 14/15 -- React framework with the App Router, Server Components, and streaming SSR.
- tRPC -- End-to-end typed APIs without code generation. Your React components import server procedures as if they were local functions, with autocomplete and compile-time errors when the server signature changes.
- Prisma -- Type-safe ORM that generates a client from your schema file. Handles migrations, raw SQL escape hatches, and works with PostgreSQL, MySQL, SQLite, and more.
- NextAuth.js (Auth.js v5) -- Pluggable authentication with 80+ OAuth providers, email magic links, credentials, and WebAuthn. Ships with JWT and database session adapters.
- Tailwind CSS -- Utility-first CSS with a production JIT compiler.
- Zod -- Runtime schema validation that shares types with tRPC and React Hook Form.
Typical use cases include SaaS dashboards, internal tools, marketplaces, social apps, and any product where you would otherwise choose Rails, Django, or Laravel but want to keep one language across the stack.
Why Self-Host T3 on a VPS Instead of Vercel + Supabase
The default T3 deployment story points at Vercel for Next.js and Supabase (or Neon/PlanetScale) for Postgres. That works beautifully on free tiers and demos. The moment you cross the threshold of a real product -- a few thousand users, a handful of background jobs, any kind of file upload -- the bill changes shape fast.
Realistic Monthly Cost Comparison
| Component | Vercel + Supabase | Self-Hosted on VPS |
|---|---|---|
| Next.js hosting | Vercel Pro: $20/user/mo + bandwidth + function invocations | Included |
| PostgreSQL | Supabase Pro: $25/mo for 8 GB storage, $0.0125/GB-hour compute | Included (local socket) |
| Bandwidth (500 GB/mo) | ~$40/mo on Vercel (above 1 TB free it gets expensive fast) | Unmetered |
| Background jobs / cron | Vercel Cron (Hobby: 2/day limit) + separate worker service | systemd timers or PM2 cron (free) |
| Object storage | Supabase Storage: $0.021/GB/mo + egress | MinIO or local disk |
| Typical monthly total | $80-150+/mo once usage grows | EUR 19.99/mo flat |
- No cold starts -- Your Next.js server is a long-running Node process, not a Lambda that spins up on each request. P95 latency is predictable.
- Direct database connections -- Prisma talks to PostgreSQL over a Unix socket on localhost. No connection pooler middle layer, no per-query network hop.
- Full Node.js runtime -- Every Node API works. Binary dependencies, native modules, puppeteer, sharp, ffmpeg -- all fine. No Edge Runtime restrictions.
- Predictable scaling -- When you outgrow one VPS, you vertical-scale to a larger plan or add a replica. No surprise serverless bill from a runaway loop.
- Data sovereignty -- Your users' data lives on one machine in a known jurisdiction. Simpler GDPR story, no third-party data processor agreements to maintain.
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to the server
- A domain name pointed at the server's public IP (an A record -- DNS propagation takes 5-30 minutes)
- A GitHub account (for the OAuth provider step -- optional if you only need credentials auth)
- At least 2 GB of RAM (4 GB+ recommended for builds; Next.js builds can OOM on 1 GB)
- At least 20 GB of free disk space
Recommended Plan: CloudCore Professional>
For a T3 Stack application with Postgres on the same box, comfortable Next.js build times, and room to grow, we recommend the CloudCore Professional plan:>
- 6 vCPU cores
- 12 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth
- EUR 19.99/month>
The 12 GB of RAM gives you headroom to run Node plus PostgreSQL plus a Redis cache plus a build pipeline without thrashing. For smaller side projects, the 4 GB plan also works -- you just want to avoid building on-device and instead build in CI.
Connect to your server:
ssh root@your-server-ipStep 1: Prepare the VPS
Start with a clean package index and a non-root deploy user. Running Node.js and Nginx as root is a recipe for bad outcomes.
Update the system:
sudo apt update && sudo apt upgrade -yCreate a deploy user with sudo access:
sudo adduser deploy
sudo usermod -aG sudo deploy
sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deployInstall essential tooling:
sudo apt install -y curl git build-essential ufw ca-certificates gnupg lsb-releaseConfigure the firewall:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw statusExpected output:
Status: active
To Action From -- ------ ---- OpenSSH ALLOW Anywhere 80/tcp ALLOW Anywhere 443/tcp ALLOW Anywhere
From this point forward, log in as deploy:
exit
ssh deploy@your-server-ipStep 2: Install Node.js 20 LTS
Ubuntu's default Node.js package is too old for Next.js 14+. Use the NodeSource repository to get the current LTS.
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejsVerify the installation:
node --version
npm --versionExpected output:
v20.18.1
10.8.2For deeper Node.js tuning (memory limits, process managers, multiple versions with nvm), see our Node.js on Ubuntu guide.
Step 3: Install and Configure PostgreSQL 16
Add the official PostgreSQL apt repository so you get PostgreSQL 16 rather than whatever ships with the Ubuntu release:
sudo install -d /usr/share/postgresql-common/pgdg
sudo curl -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc \
--fail https://www.postgresql.org/media/keys/ACCC4CF8.asc
sudo sh -c 'echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \
https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list'
sudo apt update
sudo apt install -y postgresql-16 postgresql-contrib-16Confirm the service is running:
sudo systemctl status postgresqlCreate the Application Database and User
Switch to the postgres superuser and open a psql shell:
sudo -u postgres psqlInside psql, create a dedicated database and role (use a long, generated password):
CREATE USER t3app WITH PASSWORD 'REPLACE_WITH_A_LONG_RANDOM_PASSWORD';
CREATE DATABASE t3app_prod OWNER t3app;
GRANT ALL PRIVILEGES ON DATABASE t3app_prod TO t3app;
\qTest the new credentials:
psql -h 127.0.0.1 -U t3app -d t3app_prod -WEnter the password you set. You should see a t3app_prod=> prompt. Type \q to exit.
For in-depth PostgreSQL tuning (shared buffers, WAL settings, streaming replication), see our PostgreSQL on Ubuntu guide.
Step 4: Install pnpm and Global Tooling
create-t3-app supports npm, pnpm, and yarn. We prefer pnpm for its strict node_modules layout and faster installs.
sudo npm install -g pnpm pm2Verify:
pnpm --version
pm2 --versionPM2 is the production process manager we will use in Step 11.
Step 5: Bootstrap a T3 Project
Create a workspace directory and scaffold the project. We will keep production code in /var/www/t3app.
sudo mkdir -p /var/www
sudo chown -R deploy:deploy /var/www
cd /var/wwwRun the T3 scaffolding wizard:
pnpm create t3-app@latest t3appAnswer the prompts as follows for this tutorial:
- Will you be using TypeScript or JavaScript? TypeScript
- Will you be using Tailwind CSS for styling? Yes
- Would you like to use tRPC? Yes
- What authentication provider would you like to use? NextAuth.js
- What database ORM would you like to use? Prisma
- Would you like to use Next.js App Router? Yes
- What database provider would you like to use? PostgreSQL
- Should we initialize a Git repository and stage the changes? Yes
- Should we run pnpm install? Yes
cd /var/www/t3appThe directory structure should look like this:
t3app/
├── prisma/
│ └── schema.prisma
├── public/
├── src/
│ ├── app/
│ ├── server/
│ │ ├── api/
│ │ │ ├── routers/
│ │ │ └── trpc.ts
│ │ ├── auth.ts
│ │ └── db.ts
│ ├── trpc/
│ └── env.js
├── .env
├── next.config.js
├── package.json
└── tsconfig.jsonStep 6: Configure Environment Variables
Open .env and fill in production values:
nano .envReplace the contents with:
# Database
DATABASE_URL="postgresql://t3app:[email protected]:5432/t3app_prod?schema=public&connection_limit=10"NextAuth
AUTH_SECRET="GENERATED_BELOW"
AUTH_URL="https://t3app.yourdomain.com"
AUTH_TRUST_HOST="true"GitHub OAuth (from https://github.com/settings/developers)
AUTH_GITHUB_ID="your_github_oauth_client_id"
AUTH_GITHUB_SECRET="your_github_oauth_client_secret"Node environment
NODE_ENV="production"Generate a cryptographically strong AUTH_SECRET:
openssl rand -base64 32Paste the output into the AUTH_SECRET field.
Register a GitHub OAuth App
https://t3app.yourdomain.comhttps://t3app.yourdomain.com/api/auth/callback/github.env.The env.js file that ships with create-t3-app uses Zod to validate these variables at build time -- if any are missing, your build will fail loudly rather than crashing at runtime.
Step 7: Define the Prisma Schema and Run Migrations
Open prisma/schema.prisma. The scaffold already includes the NextAuth adapter models. Add a Post model and a hashedPassword column on User for credentials auth:
nano prisma/schema.prismaReplace the contents with:
generator client { provider = "prisma-client-js" }datasource db { provider = "postgresql" url = env("DATABASE_URL") }
model Post { id Int @id @default(autoincrement()) title String content String published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author User @relation(fields: [authorId], references: [id], onDelete: Cascade) authorId String
@@index([authorId]) }
model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? @db.Text access_token String? @db.Text expires_at Int? token_type String? scope String? id_token String? @db.Text session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId]) }
model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) }
model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? hashedPassword String? accounts Account[] sessions Session[] posts Post[] }
model VerificationToken { identifier String token String @unique expires DateTime
@@unique([identifier, token]) }
Create and Apply the Migration
In development on your workstation you would run pnpm prisma migrate dev --name init to generate a migration file. Commit that file to git. In production, you run only the deploy command, which applies committed migrations without modifying the schema:
pnpm prisma generate
pnpm prisma migrate dev --name initExpected output:
Applying migration20260416103012_initThe following migration(s) have been created and applied from new schema changes:migrations/ └─ 20260416103012_init/ └─ migration.sql
Your database is now in sync with your schema.
Running generate... (Use --skip-generate to skip the generators) ✔ Generated Prisma Client (v5.22.0) to ./node_modules/@prisma/client in 78ms
For subsequent deployments, always use:
pnpm prisma migrate deployThis never generates new migrations -- it only applies files already committed in prisma/migrations/.
Step 8: Configure NextAuth Providers
Open src/server/auth.ts and replace it with a configuration that supports both Credentials and GitHub OAuth:
nano src/server/auth.tsimport { PrismaAdapter } from "@auth/prisma-adapter"; import { type DefaultSession, type NextAuthConfig } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import GitHubProvider from "next-auth/providers/github"; import bcrypt from "bcryptjs"; import { z } from "zod"; import { db } from "~/server/db";declare module "next-auth" { interface Session extends DefaultSession { user: { id: string; } & DefaultSession["user"]; } }
const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(8), });
export const authConfig = { adapter: PrismaAdapter(db), session: { strategy: "jwt" }, providers: [ GitHubProvider({ clientId: process.env.AUTH_GITHUB_ID!, clientSecret: process.env.AUTH_GITHUB_SECRET!, }), CredentialsProvider({ name: "Email and password", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { const parsed = credentialsSchema.safeParse(credentials); if (!parsed.success) return null;
const user = await db.user.findUnique({ where: { email: parsed.data.email }, }); if (!user?.hashedPassword) return null;
const valid = await bcrypt.compare( parsed.data.password, user.hashedPassword, ); if (!valid) return null;
return { id: user.id, email: user.email, name: user.name }; }, }), ], callbacks: { jwt({ token, user }) { if (user) token.id = user.id; return token; }, session({ session, token }) { if (token.id) session.user.id = token.id as string; return session; }, }, pages: { signIn: "/auth/signin", }, } satisfies NextAuthConfig;
Install bcryptjs:
pnpm add bcryptjs
pnpm add -D @types/bcryptjsWhen a user registers via Credentials, hash their password before writing it to the database:
import bcrypt from "bcryptjs";
const hashed = await bcrypt.hash(plaintextPassword, 12);JWT sessions mean NextAuth signs a token with AUTH_SECRET and stores it in a cookie. tRPC middleware can read and verify that token on every request with no database round trip. The GitHub provider still creates an Account and User row via the Prisma adapter on first login.
Step 9: Build a Protected tRPC Router
Add a protected procedure and a posts router. Open src/server/api/routers/post.ts:
nano src/server/api/routers/post.tsimport { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "~/server/api/trpc";export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().min(1).max(100).default(20) })) .query(({ ctx, input }) => ctx.db.post.findMany({ where: { published: true }, orderBy: { createdAt: "desc" }, take: input.limit, include: { author: { select: { name: true } } }, }), ),
create: protectedProcedure .input( z.object({ title: z.string().min(3).max(200), content: z.string().min(1).max(10_000), }), ) .mutation(({ ctx, input }) => ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }), ), });
Register it in src/server/api/root.ts:
import { postRouter } from "~/server/api/routers/post"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";export const appRouter = createTRPCRouter({ post: postRouter, });
export type AppRouter = typeof appRouter; export const createCaller = createCallerFactory(appRouter);
protectedProcedure is defined in src/server/api/trpc.ts as middleware that throws UNAUTHORIZED when ctx.session?.user is missing. On the client, you call these procedures with full type safety:
"use client"; import { api } from "~/trpc/react";export function CreatePostForm() { const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), });
return ( <form onSubmit={(e) => { e.preventDefault(); const fd = new FormData(e.currentTarget); createPost.mutate({ title: fd.get("title") as string, content: fd.get("content") as string, }); }} > <input name="title" required /> <textarea name="content" required /> <button type="submit" disabled={createPost.isPending}>Publish</button> </form> ); }
If you rename title to headline in the Zod schema, the mutation call above turns red in your editor instantly. That is the T3 value proposition.
Step 10: Build Next.js in Standalone Mode
Next.js has a built-in output mode that copies exactly the files needed to run in production into .next/standalone/. This shrinks the deployed artifact from hundreds of megabytes to roughly 100 MB and removes the need to keep node_modules on the production host.
Edit next.config.js:
nano next.config.jsSet the output mode:
await import("./src/env.js");/* @type {import("next").NextConfig} / const config = { output: "standalone", reactStrictMode: true, experimental: { serverActions: { bodySizeLimit: "2mb" }, }, };
export default config;
Run the production build:
pnpm prisma generate
pnpm prisma migrate deploy
pnpm buildExpected output (abbreviated):
▲ Next.js 15.0.3 - Environments: .envCreating an optimized production build ... ✓ Compiled successfully ✓ Linting and checking validity of types ✓ Collecting page data ✓ Generating static pages (8/8) ✓ Collecting build traces ✓ Finalizing page optimization
Route (app) Size First Load JS ┌ ○ / 5.73 kB 102 kB ├ ○ /_not-found 871 B 88.1 kB ├ λ /api/auth/[...nextauth] 0 B 0 B ├ λ /api/trpc/[trpc] 0 B 0 B └ ○ /auth/signin 1.42 kB 94.8 kB
The standalone bundle lives at .next/standalone/server.js. Copy the static assets that Next.js does not include in the standalone trace:
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/Test it manually:
PORT=3000 HOSTNAME=127.0.0.1 node .next/standalone/server.jsIn another terminal, hit it:
curl http://127.0.0.1:3000You should see HTML. Stop the process with Ctrl+C.
Step 11: Run with PM2
PM2 keeps the Node process alive, restarts it on crashes, and brings it back up after reboots. It also gives you log rotation and zero-downtime reloads.
Create ecosystem.config.cjs in the project root:
nano ecosystem.config.cjsmodule.exports = {
apps: [
{
name: "t3app",
script: ".next/standalone/server.js",
cwd: "/var/www/t3app",
instances: "max",
exec_mode: "cluster",
env: {
NODE_ENV: "production",
PORT: 3000,
HOSTNAME: "127.0.0.1",
},
max_memory_restart: "800M",
error_file: "/var/log/t3app/error.log",
out_file: "/var/log/t3app/out.log",
merge_logs: true,
time: true,
},
],
};Create the log directory:
sudo mkdir -p /var/log/t3app
sudo chown deploy:deploy /var/log/t3appStart the app:
pm2 start ecosystem.config.cjs
pm2 saveGenerate a systemd unit so PM2 and your app start on boot:
pm2 startup systemdCopy and run the sudo env ... command PM2 prints. Verify:
pm2 statusExpected output:
┌────┬────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │
├────┼────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┤
│ 0 │ t3app │ default │ 0.1.0 │ cluster │ 41234 │ 10s │ 0 │ online │ 0.2% │ 120.4mb │
│ 1 │ t3app │ default │ 0.1.0 │ cluster │ 41235 │ 10s │ 0 │ online │ 0.3% │ 121.1mb │
└────┴────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┘With instances: "max", PM2 spawns one worker per CPU core in cluster mode. On a 6 vCPU VPS you get six Node processes sharing port 3000 via the built-in load balancer.
Step 12: Reverse Proxy with Nginx and TLS
PM2 serves your app on 127.0.0.1:3000. Nginx will terminate TLS on port 443 and forward requests to it.
Install Nginx and Certbot:
sudo apt install -y nginx certbot python3-certbot-nginxCreate the site config:
sudo nano /etc/nginx/sites-available/t3appupstream t3app_upstream { server 127.0.0.1:3000; keepalive 64; }server { listen 80; server_name t3app.yourdomain.com;
# Certbot will add HTTPS redirects here after certificate issuance
# Gzip gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
# Serve Next.js static assets directly, bypassing Node location /_next/static/ { alias /var/www/t3app/.next/static/; expires 1y; access_log off; add_header Cache-Control "public, immutable"; }
location /public/ { alias /var/www/t3app/public/; expires 1y; access_log off; }
client_max_body_size 10m;
location / { proxy_pass http://t3app_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_cache_bypass $http_upgrade; proxy_read_timeout 300s; } }
Enable the site and reload:
sudo ln -s /etc/nginx/sites-available/t3app /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxObtain a Let's Encrypt Certificate
Before running Certbot, confirm your DNS A record resolves:
dig +short t3app.yourdomain.comRun Certbot:
sudo certbot --nginx -d t3app.yourdomain.comAnswer the prompts:
- Enter your email for expiry reminders
- Agree to the Terms of Service
- Choose whether to redirect HTTP to HTTPS (pick 2: Redirect)
sudo nginx -t && sudo systemctl reload nginxVisit https://t3app.yourdomain.com in your browser. You should see your T3 home page served over TLS.
Certbot installs a systemd timer that renews certificates automatically 30 days before expiry. Verify it:
sudo systemctl list-timers | grep certbotPost-Deploy: Zero-Downtime Releases
For subsequent deploys from your workstation or CI:
cd /var/www/t3app
git pull
pnpm install --frozen-lockfile
pnpm prisma generate
pnpm prisma migrate deploy
pnpm build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
pm2 reload t3apppm2 reload (as opposed to pm2 restart) swaps workers one by one in cluster mode -- new requests continue to be served throughout the deploy with zero dropped connections.
GitHub Actions Deploy Example
Create .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: deploy
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/t3app
git pull
pnpm install --frozen-lockfile
pnpm prisma migrate deploy
pnpm build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
pm2 reload t3appTroubleshooting
| Problem | Cause | Solution |
|---|---|---|
PrismaClientInitializationError: Can't reach database server at 127.0.0.1:5432 | PostgreSQL not running or DATABASE_URL password wrong | sudo systemctl status postgresql; verify credentials with psql -h 127.0.0.1 -U t3app -d t3app_prod |
Build fails with Invalid environment variables | Missing .env value caught by env.js Zod validation | The error message lists the missing or invalid variables -- fill them in |
502 Bad Gateway from Nginx | Node process not running on port 3000 | pm2 status; pm2 logs t3app --lines 100; confirm HOSTNAME=127.0.0.1 matches proxy_pass |
NextAuth Configuration error on sign-in | AUTH_URL does not match the browser's URL, or AUTH_SECRET is missing | Set AUTH_URL to the full https:// URL users see; confirm AUTH_SECRET has 32+ random bytes |
GitHub OAuth callback redirects to http:// instead of https:// | Missing AUTH_TRUST_HOST=true behind a reverse proxy | Add AUTH_TRUST_HOST="true" to .env and pm2 reload t3app |
EADDRINUSE: address already in use :::3000 | A previous Node process is still holding the port | pm2 delete t3app && pm2 start ecosystem.config.cjs |
| Next.js build OOM on small VPS | Default Node heap is too small for large Tailwind/Next builds | Build in CI, or raise the heap: NODE_OPTIONS=--max-old-space-size=3072 pnpm build |
prisma migrate deploy exits with P3009 (failed migration in DB) | A previous migration errored and left the _prisma_migrations table in a broken state | Inspect the failed row: SELECT * FROM _prisma_migrations WHERE finished_at IS NULL;. Fix manually and mark as rolled back with prisma migrate resolve --rolled-back <migration_name>. |
| Static assets return 404 after deploy | Forgot to copy .next/static into the standalone folder | Re-run the cp -r .next/static .next/standalone/.next/ step |
Useful Log Commands
pm2 logs t3app --lines 100 # Next.js logs
pm2 logs t3app --err # errors only
sudo journalctl -u nginx -n 100 # Nginx service log
sudo tail -f /var/log/nginx/access.log
sudo tail -f /var/log/postgresql/postgresql-16-main.logFAQ
What is the T3 Stack?
The T3 Stack is an opinionated full-stack TypeScript toolkit built around Next.js, tRPC, Prisma, NextAuth.js, Tailwind CSS, and Zod. It prioritises end-to-end type safety: a single TypeScript type flows from your Prisma schema through tRPC input validation into your React components, so any schema change produces immediate compile-time errors at every call site. It is the TypeScript equivalent of Rails or Laravel in terms of "batteries included," but without giving up type safety.
Why deploy T3 Stack on a VPS instead of Vercel and Supabase?
A single Ubuntu VPS runs your Next.js server, PostgreSQL, background jobs, and static assets for a flat EUR 19.99/month. Vercel plus Supabase can easily exceed $80-150/month once you cross the free tier for bandwidth, Postgres compute, and serverless invocations. You also gain predictable latency (no cold starts), a full Node.js runtime (binary dependencies work), and data sovereignty (one machine, one jurisdiction, no third-party data processor agreements).
Can I run Prisma migrations in production?
Yes, and you should. Use prisma migrate deploy during deployment -- never migrate dev in production. migrate deploy applies only committed migration files from prisma/migrations/ and never generates new ones, so your production database state is always deterministic and reviewable in git. Run it after pnpm install and before pnpm build (so the generated Prisma client matches the live schema).
Do I need Docker for the T3 Stack?
No. Running Node.js and PostgreSQL directly on Ubuntu 24.04 with systemd and PM2 is simpler, uses less RAM, and deploys faster than a containerised stack for single-server setups. Docker becomes useful when you have multiple services that need isolated dependencies, or when you want portability across cloud providers. For a typical T3 app on one VPS, the extra layer is pure overhead.
Should I use JWT or database sessions with NextAuth?
JWT sessions scale better, avoid a database round trip per request, and work cleanly with tRPC middleware -- the session is decoded from the cookie once per request with no Prisma query. Use database sessions only when you need the ability to instantly revoke sessions server-side (for example, forcing a logout when a user is banned). With JWT, revocation requires rotating AUTH_SECRET or waiting for the token to expire.
How do I add a typed API endpoint?
Add a procedure to a router in src/server/api/routers/, register the router in src/server/api/root.ts, and call it from any client component with api.routerName.procedureName.useQuery() or .useMutation(). No OpenAPI spec, no schema generation step, no client SDK to regenerate -- the types flow automatically through the shared AppRouter type import.
Can I host multiple T3 apps on one VPS?
Yes. Each app gets its own project directory, its own PostgreSQL database, its own PM2 entry (with a unique name and port), and its own Nginx server block on a different subdomain. On a CloudCore Professional plan with 12 GB RAM, you can comfortably run 3-5 small-to-medium T3 apps side by side.
How do I add background jobs?
For simple periodic tasks, use pm2 start ecosystem.config.cjs --only worker with a separate script that imports your Prisma client. For queue-backed jobs, add BullMQ with a local Redis instance (sudo apt install redis-server) -- BullMQ integrates cleanly into the T3 server code and runs in the same Node process or a separate PM2 worker.
Next Steps
Your T3 Stack app is live, but there are several refinements that turn a working deployment into a production-grade one:
- Set up automated database backups -- Schedule nightly
pg_dumpruns and push the output to S3-compatible object storage. Test the restore path monthly.
- Add monitoring and uptime checks -- Deploy Uptime Kuma on a separate VPS to monitor
https://t3app.yourdomain.com/api/healthevery minute and alert via email, Slack, or Discord when the app is down.
- Stream logs to a centralised service -- Forward PM2 logs to Grafana Loki or Better Stack Logs so you can query months of history without SSHing into the box.
- Add rate limiting -- Use
@upstash/ratelimitwith a local Redis instance, or configure Nginxlimit_req_zoneto throttle/api/auth/signinand/api/trpc/endpoints.
- Introduce Sentry for error tracking -- The
@sentry/nextjsSDK integrates in five minutes and captures both server and client errors with full stack traces, breadcrumbs, and release tagging.
- Scale reads with a PostgreSQL replica -- When one VPS is not enough, set up streaming replication to a second server and point read-heavy queries at it. See our PostgreSQL on Ubuntu guide for the replication walkthrough.
- Explore the T3 documentation -- The official site at create.t3.gg maintains detailed docs on the philosophy, FAQ, and deployment for each provider choice. The T3 Discord is the fastest place to get help.
Skip the Manual Setup -- Get a Production-Ready Node.js VPS>
Our CloudCore Professional plan is pre-tuned for full-stack Node.js workloads: NVMe storage for fast Prisma migrations, generous RAM for Next.js builds, and unmetered bandwidth so you never worry about egress bills.>
- 6 vCPU cores, 12 GB RAM, 100 GB NVMe SSD
- Ubuntu 24.04 LTS image with unattended-upgrades enabled
- Free snapshots before each deploy
- IPv4 + IPv6 with reverse DNS
- EUR 19.99/month -- flat>
Deploy Your T3 Stack VPS Now and have your first type-safe API live in under an hour.