Next.js 15 is the most powerful React framework for production applications. But deploying it beyond Vercel can seem daunting. This guide shows you exactly how to run Next.js 15 in production on your own infrastructure—with full control over your data and costs.
We'll cover two deployment approaches:
- VPS with Docker: Full control, predictable costs
- Serverless Containers: Auto-scaling, pay-per-use
By the end, you'll have a production-ready Next.js 15 deployment with PostgreSQL, Redis, and SSL—all for a fraction of Vercel's pricing.
Why Self-Host Next.js?
Before we dive in, let's address the elephant in the room: why not just use Vercel?
Vercel Limitations
- Pricing surprises: Function invocations, bandwidth, and build minutes add up fast
- Vendor lock-in: Edge middleware, Image optimization, and other features are Vercel-specific
- Data residency: Limited control over where your data lives
- Resource limits: Function timeouts, memory limits, and cold starts
- Database costs: Vercel Postgres/KV are expensive compared to managed alternatives
Self-Hosting Benefits
- Predictable costs: Fixed monthly pricing, no surprises
- Full control: Choose your database, cache, storage
- No limits: No function timeouts, no memory caps
- Data sovereignty: Keep data in your chosen region
- Cost savings: 50-80% cheaper for most workloads
Cost Comparison
| Scenario | Vercel Pro | DanubeData |
|---|---|---|
| Base platform | $20/month | €8.99/month (VPS) |
| Database (PostgreSQL) | $20-60/month | €19.99/month |
| Cache (Redis/KV) | $10-30/month | €4.99/month |
| 100GB bandwidth | $15-55/month | Included |
| Monthly Total | $65-165 | €33.97 |
Prerequisites
Before starting, make sure you have:
- A Next.js 15 application ready to deploy
- Basic familiarity with Docker and the command line
- A DanubeData account (or any VPS provider)
Option 1: VPS Deployment with Docker
This is the most reliable approach for production. You get consistent performance, no cold starts, and complete control.
Step 1: Create Your VPS
Log into DanubeData and create a VPS:
- Size: Small (2 vCPU, 4GB RAM) is perfect for most apps
- Image: Ubuntu 24.04 LTS
- Add your SSH key
Step 2: Prepare Your Next.js App
First, ensure your Next.js app is configured for standalone output:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// Enable if using Docker
experimental: {
// Recommended for Docker deployments
},
};
export default nextConfig;
Step 3: Create Dockerfile
Create a production-optimized Dockerfile:
# Dockerfile
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
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* ./
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 . .
# Environment variables for build
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
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
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
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
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Step 4: Create Docker Compose Stack
Create a complete production stack with PostgreSQL and Redis:
# docker-compose.yml
services:
app:
build:
context: .
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
restart: always
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/myapp
- REDIS_URL=redis://cache:6379
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- app-network
db:
image: postgres:16-alpine
restart: always
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
cache:
image: redis:7-alpine
restart: always
volumes:
- redis_data:/data
command: redis-server --appendonly yes
networks:
- app-network
nginx:
image: nginx:alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
networks:
- app-network
certbot:
image: certbot/certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
volumes:
postgres_data:
redis_data:
networks:
app-network:
driver: bridge
Step 5: Configure Nginx
Create an Nginx configuration for SSL and reverse proxy:
# nginx.conf
events {
worker_connections 1024;
}
http {
upstream nextjs {
server app:3000;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name your-domain.com www.your-domain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server
server {
listen 443 ssl http2;
server_name your-domain.com www.your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
location / {
proxy_pass http://nextjs;
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;
}
# Cache static assets
location /_next/static {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /static {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
}
Step 6: Create Environment File
# .env.production
DB_PASSWORD=your-secure-password-here
NEXT_PUBLIC_API_URL=https://your-domain.com
NEXTAUTH_URL=https://your-domain.com
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
Step 7: Deploy to VPS
# SSH into your VPS
ssh root@your-vps-ip
# Install Docker and Docker Compose
curl -fsSL https://get.docker.com | sh
apt install docker-compose-plugin -y
# Clone your repo
git clone https://github.com/your-repo/your-app.git
cd your-app
# Copy your .env.production
nano .env.production
# Initial SSL certificate (run once)
docker compose run --rm certbot certonly
--webroot --webroot-path=/var/www/certbot
-d your-domain.com -d www.your-domain.com
# Build and start
docker compose up -d --build
# Check logs
docker compose logs -f app
Step 8: Set Up Auto-Renewal
# Add to crontab (crontab -e)
0 3 * * * cd /path/to/app && docker compose run --rm certbot renew && docker compose restart nginx
Option 2: DanubeData Managed Services
For an even simpler setup, use DanubeData's managed PostgreSQL and Redis instead of running them in Docker:
Benefits of Managed Services
- Automatic backups and point-in-time recovery
- SSL certificates included
- Monitoring and alerting
- Easy scaling without downtime
- No database admin required
Simplified Docker Compose
# docker-compose.yml (with managed services)
services:
app:
build:
context: .
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
restart: always
ports:
- "3000:3000"
environment:
# Connect to DanubeData managed services
- DATABASE_URL=${DANUBEDATA_DATABASE_URL}
- REDIS_URL=${DANUBEDATA_REDIS_URL}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
networks:
- app-network
nginx:
image: nginx:alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
depends_on:
- app
networks:
- app-network
networks:
app-network:
driver: bridge
Then in your DanubeData dashboard:
- Create a PostgreSQL database (Small: €19.99/mo)
- Create a Redis cache (Micro: €4.99/mo)
- Copy the connection strings to your .env file
- Add your VPS IP to the firewall rules
Option 3: Serverless Containers (Auto-Scaling)
For applications with variable traffic, serverless containers offer automatic scaling and pay-per-use pricing.
Deploy with Git Integration
- In DanubeData dashboard, create a new Serverless Container
- Connect your GitHub/GitLab repository
- Select the branch to deploy
- Configure environment variables
- Deploy automatically on every push
Serverless Dockerfile Optimization
# Dockerfile.serverless
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
# Serverless containers use PORT env variable
EXPOSE 8080
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"
# Minimal memory footprint for fast cold starts
CMD ["node", "server.js"]
Database Setup with Prisma
Most Next.js apps use Prisma for database access. Here's how to set it up properly:
Install Prisma
npm install prisma @prisma/client
npx prisma init
Configure Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Create Prisma Client Singleton
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query"] : [],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Run Migrations in Production
# In your deployment script or CI/CD
npx prisma migrate deploy
# Or in Docker
docker compose exec app npx prisma migrate deploy
Redis Caching Setup
Add Redis caching for optimal performance:
Install Redis Client
npm install ioredis
Create Redis Client
// lib/redis.ts
import Redis from "ioredis";
const getRedisUrl = () => {
if (process.env.REDIS_URL) {
return process.env.REDIS_URL;
}
throw new Error("REDIS_URL is not defined");
};
export const redis = new Redis(getRedisUrl());
Cache API Responses
// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
export async function GET() {
const cacheKey = "posts:all";
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached));
}
// Query database
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: { select: { name: true } } },
});
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(posts));
return NextResponse.json(posts);
}
Production Optimization
1. Enable Compression
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
compress: true,
};
2. Optimize Images
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "your-cdn.com",
},
],
// Use sharp for better performance
unoptimized: false,
},
};
3. Configure Caching Headers
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
return [
{
source: "/:all*(svg|jpg|png|webp|avif)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
},
};
4. Health Check Endpoint
// app/api/health/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
export async function GET() {
try {
// Check database
await prisma.$queryRaw`SELECT 1`;
// Check Redis
await redis.ping();
return NextResponse.json({
status: "healthy",
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{ status: "unhealthy", error: String(error) },
{ status: 503 }
);
}
}
CI/CD with GitHub Actions
Automate deployments with GitHub Actions:
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/myapp
git pull origin main
docker compose build --no-cache
docker compose up -d
docker compose exec -T app npx prisma migrate deploy
docker image prune -f
Monitoring and Logging
View Logs
# Real-time logs
docker compose logs -f app
# Last 100 lines
docker compose logs --tail=100 app
# Specific time range
docker compose logs --since="2025-01-01T00:00:00" app
Monitor Resources
# Container stats
docker stats
# Disk usage
docker system df
Complete Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Nginx (SSL/Proxy) │
│ Port 80 → 443 │
└─────────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Next.js 15 App │
│ Port 3000 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ SSR Pages │ │ API Routes │ │
│ └────────┬────────┘ └────────┬────────┘ │
└───────────┼────────────────────┼────────────────────────────┘
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ PostgreSQL │ │ Redis │
│ (DanubeData) │ │ (DanubeData) │
│ €19.99/mo │ │ €4.99/mo │
└───────────────────┘ └───────────────────┘
Cost Summary
| Component | DanubeData Service | Monthly Cost |
|---|---|---|
| Application Server | VPS Small | €8.99 |
| Database | PostgreSQL Small | €19.99 |
| Cache | Redis Micro | €4.99 |
| Total | €33.97/month |
Compare this to Vercel Pro + database + caching at $80-150+/month.
Troubleshooting
Container Won't Start
# Check build logs
docker compose logs app
# Rebuild with no cache
docker compose build --no-cache app
# Shell into container
docker compose run --rm app sh
Database Connection Failed
# Check if database is running
docker compose ps db
# Test connection
docker compose exec db psql -U postgres -d myapp -c "SELECT 1"
# Check DATABASE_URL format
# postgresql://user:password@host:5432/database
SSL Certificate Issues
# Check certificate status
docker compose run --rm certbot certificates
# Force renewal
docker compose run --rm certbot renew --force-renewal
Next Steps
You now have a production-ready Next.js 15 deployment. Consider adding:
- Error tracking: Sentry or similar
- Analytics: Plausible, Umami, or Posthog
- CDN: Cloudflare for global edge caching
- Backups: Automated database backups (included with DanubeData)
Get Started Today
Ready to deploy your Next.js 15 app to production?
👉 Create your free DanubeData account
Deploy your first VPS, database, and cache in under 5 minutes. No credit card required for testing.
Full-stack Next.js hosting starting at just €33.97/month—a fraction of Vercel's pricing with complete control over your infrastructure.
Need help deploying? Contact our team for personalized assistance.