Next.js with PostgreSQL

This guide walks you through deploying a Next.js application with a PostgreSQL database to your own server using Haloy. Any Linux-based VPS or dedicated server will work.

The complete source code for this guide is available at: haloydev/examples/nextjs-postgres

What You’ll Build

A full-stack React application using:

  • Next.js - The React framework for production with App Router
  • PostgreSQL - Powerful, open source object-relational database system
  • Drizzle ORM - TypeScript ORM for type-safe database queries
  • Haloy - Simple deployment to your own server

Prerequisites

  • Node.js 20+ installed
  • Haloy installed (Quickstart)
  • A linux server (VPS or dedicated server)
  • A domain or a subdomain
  • Basic familiarity with React and TypeScript

This guide uses pnpm, but you can use npm instead by replacing pnpm add with npm install and pnpm with npm run for scripts.

1. Create the Project

pnpm create next-app@latest my-nextjs-app cd my-nextjs-app

When prompted, select Yes, use recommended defaults

2. Install Database Dependencies

Install Drizzle and PostgreSQL:

pnpm add drizzle-orm pg pnpm add -D drizzle-kit @types/pg

3. Update package.json

Add database scripts to your package.json:

{ // ... "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" }, }

Database Setup

1. Configure Drizzle

Create drizzle.config.ts at the root of your project:

import { defineConfig } from "drizzle-kit"; import { getDatabaseUrl } from "./db/database-url"; const databaseUrl = getDatabaseUrl(); export default defineConfig({ out: "./drizzle", schema: "./db/schema.ts", dialect: "postgresql", dbCredentials: { url: databaseUrl, }, });

2. Create Database Client

Create db/index.ts:

import { drizzle } from "drizzle-orm/node-postgres"; import { getDatabaseUrl } from "./database-url"; const databaseUrl = getDatabaseUrl(); const db = drizzle(databaseUrl); export { db };

3. Define Your Schema

Create db/schema.ts:

import { integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; export const todos = pgTable("todos", { id: integer().primaryKey().generatedAlwaysAsIdentity(), title: varchar({ length: 255 }).notNull(), createdAt: timestamp({ mode: "date" }).defaultNow(), });

4. Database Connection Helper

Create db/database-url.ts to handle connection string construction:

export function getDatabaseUrl() { const postgresUser = process.env.POSTGRES_USER; const postgresPassword = process.env.POSTGRES_PASSWORD; const postgresDb = process.env.POSTGRES_DB; // During build time, environment variables may not be available // Return a placeholder - actual connection only happens at runtime if (!postgresUser || !postgresPassword || !postgresDb) { return "postgres://placeholder:placeholder@localhost:5432/placeholder"; } // In production, we use the service name 'postgres' as the host // In development, we connect to localhost const host = process.env.NODE_ENV === "production" ? "postgres" : "localhost"; return `postgres://${postgresUser}:${postgresPassword}@${host}:5432/${postgresDb}`; }

This helper constructs the database connection string from environment variables and automatically switches between localhost (development) and postgres (production hostname) based on NODE_ENV.

5. Create Environment File

Create .env for local development. Make sure you have a local PostgreSQL instance running or use Docker.

POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=todo_app

6. Set Up Local Database (Optional)

For local testing, you can use Docker to run PostgreSQL without installing it:

docker run --name postgres-dev \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -e POSTGRES_DB=todo_app \ -p 5432:5432 \ -d postgres:18

This command:

  • Creates a PostgreSQL container named postgres-dev
  • Sets up credentials matching your .env file
  • Exposes port 5432 to your local machine
  • Runs in the background

To stop the container later:

docker stop postgres-dev docker rm postgres-dev

Application Code

1. Create Server Actions

Create app/actions.ts for your database operations:

"use server"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { todos } from "@/db/schema"; export async function getTodos() { return await db.select().from(todos); } export async function addTodo(formData: FormData) { const title = formData.get("title")?.toString(); if (!title) { return; } await db.insert(todos).values({ title }); revalidatePath("/"); } export async function deleteTodo(id: number) { await db.delete(todos).where(eq(todos.id, id)); revalidatePath("/"); }

2. Create the Home Page

Replace app/page.tsx with:

import { addTodo, deleteTodo, getTodos } from "./actions"; export const dynamic = "force-dynamic"; export default async function Home() { const todoList = await getTodos(); return ( <main className="p-8"> <h1 className="text-2xl font-bold mb-4">Todo App</h1> <ul className="mb-6 space-y-2"> {todoList.map((todo) => ( <li key={todo.id} className="flex items-center gap-2"> <span>{todo.title}</span> <form action={async () => { "use server"; await deleteTodo(todo.id); }} > <button type="submit" className="text-red-500 hover:text-red-700"> X </button> </form> </li> ))} </ul> <h2 className="text-xl font-semibold mb-2">Add todo</h2> <form action={addTodo} className="flex gap-2"> <input name="title" placeholder="Enter a new todo..." className="border rounded px-2 py-1" /> <button type="submit" className="bg-blue-500 text-white px-4 py-1 rounded hover:bg-blue-600" > Add </button> </form> </main> ); }

3. Create Health Check Route

Create app/health/route.ts for health checks:

import { NextResponse } from "next/server"; export async function GET() { return NextResponse.json({ status: "ok" }); }

This endpoint responds without querying the database, ensuring the container can be marked healthy quickly.

Next.js Configuration

Update next.config.ts to enable standalone output for Docker deployment:

import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", }; export default nextConfig;

The standalone output creates a self-contained build that includes only the necessary dependencies, resulting in a smaller Docker image.

Docker Configuration

1. Create Dockerfile

Create Dockerfile:

# syntax=docker.io/docker/dockerfile:1 FROM node:24-alpine AS base # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ RUN \ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ elif [ -f package-lock.json ]; then npm ci; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ else echo "Lockfile not found." && exit 1; \ fi # Rebuild the source code only when needed FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. # ENV NEXT_TELEMETRY_DISABLED=1 RUN \ if [ -f yarn.lock ]; then yarn run build; \ elif [ -f package-lock.json ]; then npm run build; \ elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ else echo "Lockfile not found." && exit 1; \ fi # Production image, copy all the files and run next FROM base AS runner WORKDIR /app ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. # ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output ENV HOSTNAME="0.0.0.0" HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" CMD ["node", "server.js"]

Key points:

  • Uses multi-stage builds for smaller final image
  • Uses Node.js 24 Alpine for the latest features and security updates
  • Leverages Next.js standalone output to minimize image size
  • Includes a HEALTHCHECK that queries the /health endpoint
  • Runs as a non-root user for better security

2. Create .dockerignore

Create .dockerignore:

node_modules .git .gitignore *.md .next .DS_Store .env*

Haloy Configuration

Create haloy.yaml:

For PostgreSQL, we need to deploy two services: the database and the application. We can define both in a single haloy.yaml file.

# Global server and environment variables shared across targets server: your-server.haloy.dev env: - name: POSTGRES_USER value: postgres - name: POSTGRES_PASSWORD value: "postgres" - name: POSTGRES_DB value: "todo_app" targets: # Database Service postgres: preset: database image: repository: postgres:18 port: 5432 volumes: - postgres-data:/var/lib/postgresql # Application Service nextjs-postgres: domains: - domain: my-app.example.com port: 3000

Important: Replace your-server.haloy.dev with the actual server domain you configured during the Quickstart setup. This should match the server where you installed the Haloy daemon using haloy server setup.

Also update:

  • my-app.example.com - Replace with your actual domain or subdomain
  • POSTGRES_PASSWORD - Change to a strong, unique password for production

Configuration Explained

We define two targets:

  1. postgres:

    • Uses the official postgres:18 image.
    • Mounts a volume postgres-data to /var/lib/postgresql to ensure data persistence.
    • Exposes port 5432.
    • Is accessible to other containers on the same server via the hostname postgres.
  2. nextjs-postgres:

    • Your application code.
    • Connects to the database using the environment variables.
    • NODE_ENV=production ensures db/database-url.ts uses the postgres hostname.

Persistent Storage

The postgres target uses a named volume:

volumes: - postgres-data:/var/lib/postgresql

This ensures that even if you redeploy or restart the database container, your data remains safe on the server.

Deploy

1. Test Locally

Before deploying, verify everything works locally. Ensure you have a local PostgreSQL database running and updated .env.

pnpm db:push pnpm dev

Visit http://localhost:3000 and try adding a todo.

2. Deploy the Database

Deploy the PostgreSQL database first:

haloy deploy -t postgres

Wait for the database deployment to complete before proceeding.

Note: If you started a local PostgreSQL container for testing, stop it first to free up port 5432:

docker stop postgres-dev

3. Push Your Schema to Production

Before deploying your application, you need to set up the database schema. Haloy’s tunnel feature lets you connect to the production database from your local machine:

# In one terminal, open a tunnel to the database haloy tunnel 5432 -t postgres

The tunnel forwards the remote PostgreSQL port to your local machine. Now, in a separate terminal, push your schema:

# In another terminal, push your schema pnpm db:push

Drizzle will connect to localhost:5432 (which tunnels to your production database) and apply your schema changes.

4. Deploy the Application

With the database schema in place, deploy your application:

haloy deploy -t nextjs-postgres

5. Verify Deployment

# Check status of all targets haloy status --all # View deployment logs haloy logs -t nextjs-postgres

Working with Your Production Database

The tunnel feature is useful beyond initial deployment. Here are some common workflows:

Inspecting Data with Drizzle Studio

Drizzle Studio provides a visual interface for browsing and editing your database:

# Terminal 1: Open the tunnel haloy tunnel 5432 -t postgres # Terminal 2: Start Drizzle Studio pnpm db:studio

Then open https://local.drizzle.studio in your browser to explore your production data.

Updating the Schema

When you modify your schema in db/schema.ts, push the changes to production:

# Terminal 1: Open the tunnel (if not already open) haloy tunnel 5432 -t postgres # Terminal 2: Push schema changes pnpm db:push

Drizzle will show you a diff of the changes and prompt for confirmation before applying them.

Alternative: Migration-Based Workflow

The drizzle-kit push approach shown above is ideal for solo developers who want to move fast. For teams or projects that need a more controlled change management process, consider using migrations instead.

With migrations, schema changes are captured as versioned SQL files that can be reviewed in pull requests and applied consistently across environments. See the Drizzle Migrations documentation for details on this approach.