Skip to main contentSkip to navigation
[email protected]
Client AreaSupport
Hosting Mammoth
HostingMammothYour Data, Our Responsibility
Home
Solutions
Hosting Services
Store
Pricing
About
Blog
API
Contact

Stay Ahead of the Curve

Get the latest insights on cybersecurity, AI innovations, and enterprise data solutions delivered to your inbox.

Hosting Mammoth
HostingMammothEnterprise Solutions

Enterprise-grade data solutions. Hosting, recovery, cybersecurity, and AI-powered services for businesses worldwide.

[email protected]
Sun - Fri, 9:00am - 5:00pm

Services

  • Cloud Hosting
  • Data Recovery
  • Cybersecurity
  • Legal Support
  • MSP Services
  • Web Development
  • AI Services
  • Free Server Migration

Hosting

  • VPS Hosting (NVMe SSD)
  • VDS Hosting (NVMe)
  • Storage VPS (High SSD)
  • GPU Servers
  • Managed Services
  • Cloud Firewall
  • Load Balancer
  • One-Click Apps
  • n8n Hosting
  • Object Storage
  • FAQ

Company

  • Store
  • Pricing
  • About Us
  • Locations
  • Blog
  • Testimonials
  • Contact
  • Affiliate Program
  • White-Label
  • Terms of Service
  • Privacy Policy
  • Browser Cookies
  • SLA

Support

  • Client Area
  • Submit Ticket
  • Knowledge Base
  • Server Status
  • API Documentation

© 2026 Hosting Mammoth. All rights reserved.

Knowledge Base
Getting StartedAccount ManagementVPS HostingGPU ServersStorage VPSCloud FirewallLoad BalancerServer ManagementBilling & PaymentsSupport & TicketsAffiliate ProgramReseller ProgramMarketplace & Appsn8n HostingManaged ServicesServer MigrationAPI & DevelopersSecurityTroubleshootingGlossaryInstall Guides
  1. Home
  2. /
  3. Support
  4. /
  5. Install Guides
  6. /
  7. How To Install T3 Stack Ubuntu
GUIDEInstall Guides

How to Deploy T3 Stack on Ubuntu 24.04 VPS: Next.js + tRPC + Prisma + NextAuth with Postgres

28 min read

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?
  • Why Self-Host T3 on a VPS Instead of Vercel + Supabase
  • Prerequisites
  • Step 1: Prepare the VPS
  • Step 2: Install Node.js 20 LTS
  • Step 3: Install and Configure PostgreSQL 16
  • Step 4: Install pnpm and Global Tooling
  • Step 5: Bootstrap a T3 Project
  • Step 6: Configure Environment Variables
  • Step 7: Define the Prisma Schema and Run Migrations
  • Step 8: Configure NextAuth Providers
  • Step 9: Build a Protected tRPC Router
  • Step 10: Build Next.js in Standalone Mode
  • Step 11: Run with PM2
  • Step 12: Reverse Proxy with Nginx and TLS
  • Post-Deploy: Zero-Downtime Releases
  • Troubleshooting
  • FAQ
  • Next Steps
  • 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.
    The value of the stack is not any single tool -- it is that one TypeScript type flows from your Prisma schema, through tRPC input validation, into your React component props, and back again. Rename a column in your database, run a migration, and every stale reference in your codebase turns red in your editor before you deploy.

    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

    ComponentVercel + SupabaseSelf-Hosted on VPS
    Next.js hostingVercel Pro: $20/user/mo + bandwidth + function invocationsIncluded
    PostgreSQLSupabase Pro: $25/mo for 8 GB storage, $0.0125/GB-hour computeIncluded (local socket)
    Bandwidth (500 GB/mo)~$40/mo on Vercel (above 1 TB free it gets expensive fast)Unmetered
    Background jobs / cronVercel Cron (Hobby: 2/day limit) + separate worker servicesystemd timers or PM2 cron (free)
    Object storageSupabase Storage: $0.021/GB/mo + egressMinIO or local disk
    Typical monthly total$80-150+/mo once usage growsEUR 19.99/mo flat
    Beyond the dollar amount, self-hosting on a VPS gives you:

    • 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.
    For a detailed comparison of Next.js hosting choices, see our companion Next.js on Ubuntu guide.

    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:

    bash
    ssh root@your-server-ip

    Step 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:

    bash
    sudo apt update && sudo apt upgrade -y

    Create a deploy user with sudo access:

    bash
    sudo adduser deploy
    sudo usermod -aG sudo deploy
    sudo rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

    Install essential tooling:

    bash
    sudo apt install -y curl git build-essential ufw ca-certificates gnupg lsb-release

    Configure the firewall:

    bash
    sudo ufw allow OpenSSH
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    sudo ufw --force enable
    sudo ufw status

    Expected output:

    text
    Status: active

    To Action From -- ------ ---- OpenSSH ALLOW Anywhere 80/tcp ALLOW Anywhere 443/tcp ALLOW Anywhere

    From this point forward, log in as deploy:

    bash
    exit
    ssh deploy@your-server-ip

    Step 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.

    bash
    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt install -y nodejs

    Verify the installation:

    bash
    node --version
    npm --version

    Expected output:

    text
    v20.18.1
    10.8.2

    For 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:

    bash
    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-16

    Confirm the service is running:

    bash
    sudo systemctl status postgresql

    Create the Application Database and User

    Switch to the postgres superuser and open a psql shell:

    bash
    sudo -u postgres psql

    Inside psql, create a dedicated database and role (use a long, generated password):

    sql
    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;
    \q

    Test the new credentials:

    bash
    psql -h 127.0.0.1 -U t3app -d t3app_prod -W

    Enter 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.

    bash
    sudo npm install -g pnpm pm2

    Verify:

    bash
    pnpm --version
    pm2 --version

    PM2 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.

    bash
    sudo mkdir -p /var/www
    sudo chown -R deploy:deploy /var/www
    cd /var/www

    Run the T3 scaffolding wizard:

    bash
    pnpm create t3-app@latest t3app

    Answer 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
    After the scaffold completes, enter the project directory:

    bash
    cd /var/www/t3app

    The directory structure should look like this:

    text
    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.json

    Step 6: Configure Environment Variables

    Open .env and fill in production values:

    bash
    nano .env

    Replace the contents with:

    bash
    # 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:

    bash
    openssl rand -base64 32

    Paste the output into the AUTH_SECRET field.

    Register a GitHub OAuth App

  • Visit github.com/settings/developers and click New OAuth App.
  • Application name: T3 App Production
  • Homepage URL: https://t3app.yourdomain.com
  • Authorization callback URL: https://t3app.yourdomain.com/api/auth/callback/github
  • Click Register application, then generate a client secret and copy both values into .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:

    bash
    nano prisma/schema.prisma

    Replace the contents with:

    prisma
    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:

    bash
    pnpm prisma generate
    pnpm prisma migrate dev --name init

    Expected output:

    text
    Applying migration 20260416103012_init
    The 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:

    bash
    pnpm prisma migrate deploy

    This 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:

    bash
    nano src/server/auth.ts
    typescript
    import { 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:

    bash
    pnpm add bcryptjs
    pnpm add -D @types/bcryptjs

    When a user registers via Credentials, hash their password before writing it to the database:

    typescript
    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:

    bash
    nano src/server/api/routers/post.ts
    typescript
    import { 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:

    typescript
    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:

    typescript
    "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:

    bash
    nano next.config.js

    Set the output mode:

    javascript
    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:

    bash
    pnpm prisma generate
    pnpm prisma migrate deploy
    pnpm build

    Expected output (abbreviated):

    text
    ▲ Next.js 15.0.3
      - Environments: .env

    Creating 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:

    bash
    cp -r public .next/standalone/
    cp -r .next/static .next/standalone/.next/

    Test it manually:

    bash
    PORT=3000 HOSTNAME=127.0.0.1 node .next/standalone/server.js

    In another terminal, hit it:

    bash
    curl http://127.0.0.1:3000

    You 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:

    bash
    nano ecosystem.config.cjs
    javascript
    module.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:

    bash
    sudo mkdir -p /var/log/t3app
    sudo chown deploy:deploy /var/log/t3app

    Start the app:

    bash
    pm2 start ecosystem.config.cjs
    pm2 save

    Generate a systemd unit so PM2 and your app start on boot:

    bash
    pm2 startup systemd

    Copy and run the sudo env ... command PM2 prints. Verify:

    bash
    pm2 status

    Expected output:

    text
    ┌────┬────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┐
    │ 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:

    bash
    sudo apt install -y nginx certbot python3-certbot-nginx

    Create the site config:

    bash
    sudo nano /etc/nginx/sites-available/t3app
    nginx
    upstream 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:

    bash
    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 nginx

    Obtain a Let's Encrypt Certificate

    Before running Certbot, confirm your DNS A record resolves:

    bash
    dig +short t3app.yourdomain.com

    Run Certbot:

    bash
    sudo certbot --nginx -d t3app.yourdomain.com

    Answer the prompts:

    • Enter your email for expiry reminders
    • Agree to the Terms of Service
    • Choose whether to redirect HTTP to HTTPS (pick 2: Redirect)
    Certbot edits your Nginx config in place to add the TLS server block. Reload:

    bash
    sudo nginx -t && sudo systemctl reload nginx

    Visit 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:

    bash
    sudo systemctl list-timers | grep certbot

    Post-Deploy: Zero-Downtime Releases

    For subsequent deploys from your workstation or CI:

    bash
    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 t3app

    pm2 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:

    yaml
    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 t3app

    Troubleshooting

    ProblemCauseSolution
    PrismaClientInitializationError: Can't reach database server at 127.0.0.1:5432PostgreSQL not running or DATABASE_URL password wrongsudo systemctl status postgresql; verify credentials with psql -h 127.0.0.1 -U t3app -d t3app_prod
    Build fails with Invalid environment variablesMissing .env value caught by env.js Zod validationThe error message lists the missing or invalid variables -- fill them in
    502 Bad Gateway from NginxNode process not running on port 3000pm2 status; pm2 logs t3app --lines 100; confirm HOSTNAME=127.0.0.1 matches proxy_pass
    NextAuth Configuration error on sign-inAUTH_URL does not match the browser's URL, or AUTH_SECRET is missingSet 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 proxyAdd AUTH_TRUST_HOST="true" to .env and pm2 reload t3app
    EADDRINUSE: address already in use :::3000A previous Node process is still holding the portpm2 delete t3app && pm2 start ecosystem.config.cjs
    Next.js build OOM on small VPSDefault Node heap is too small for large Tailwind/Next buildsBuild 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 stateInspect 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 deployForgot to copy .next/static into the standalone folderRe-run the cp -r .next/static .next/standalone/.next/ step

    Useful Log Commands

    bash
    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.log

    FAQ

    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_dump runs 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/health every 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/ratelimit with a local Redis instance, or configure Nginx limit_req_zone to throttle /api/auth/signin and /api/trpc/ endpoints.
    • Introduce Sentry for error tracking -- The @sentry/nextjs SDK 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.

    Was this article helpful?

    ← Back to Install GuidesBrowse all categories →

    Still have questions?

    Contact Support →Submit a Ticket