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
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
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"
},
}
{
// ...
"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,
},
});
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 };
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(),
});
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}`;
}
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
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
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
.envfile - Exposes port 5432 to your local machine
- Runs in the background
To stop the container later:
docker stop postgres-dev
docker rm postgres-dev
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("/");
}
"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>
);
}
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" });
}
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;
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"]
# 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
HEALTHCHECKthat queries the/healthendpoint - Runs as a non-root user for better security
2. Create .dockerignore
Create .dockerignore:
node_modules
.git
.gitignore
*.md
.next
.DS_Store
.env*
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
# 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 subdomainPOSTGRES_PASSWORD- Change to a strong, unique password for production
Configuration Explained
We define two targets:
-
postgres:- Uses the official
postgres:18image. - Mounts a volume
postgres-datato/var/lib/postgresqlto ensure data persistence. - Exposes port
5432. - Is accessible to other containers on the same server via the hostname
postgres.
- Uses the official
-
nextjs-postgres:- Your application code.
- Connects to the database using the environment variables.
NODE_ENV=productionensuresdb/database-url.tsuses thepostgreshostname.
Persistent Storage
The postgres target uses a named volume:
volumes:
- postgres-data:/var/lib/postgresql
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
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
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
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
# 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
# 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
haloy deploy -t nextjs-postgres
5. Verify Deployment
# Check status of all targets
haloy status --all
# View deployment logs
haloy logs -t nextjs-postgres
# 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
# 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
# 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.