Self-Hosting Next.js with PostgreSQL and Docker on a $4 VPS - Complete Guide
Learn how to deploy a Next.js application with PostgreSQL database, Nginx reverse proxy, and Docker containers on an affordable DigitalOcean VPS with image optimization, streaming, ISR caching, and more.
Self-Hosting Next.js with PostgreSQL and Docker on a $4 VPS - Complete Guide
This comprehensive guide walks you through deploying a full-stack Next.js application with PostgreSQL database, Nginx reverse proxy, and advanced features like image optimization, streaming server components, ISR caching, and cron jobs - all running on an affordable $4/month VPS.
What You'll Build
By the end of this guide, you'll have:
- A Next.js application with App Router
- PostgreSQL database with Drizzle ORM
- Nginx reverse proxy with SSL certificates
- Image optimization on the Next.js server
- Streaming server components with Suspense
- ISR (Incremental Static Regeneration) caching
- Middleware and environment variable handling
- Cron jobs for database maintenance
- Rate limiting and security configuration
- All running in Docker containers on a single VPS
Prerequisites
- Domain name (required for SSL certificates)
- VPS or server (we'll show how to get a $4 DigitalOcean droplet)
- Docker installed locally (install with Homebrew on Mac:
brew install docker
) - Basic command line knowledge
Demo Application Features
The demo application we'll deploy includes:
- Data fetching with server components
- Image optimization using Next.js built-in optimization
- Streaming with server components and Suspense boundaries
- PostgreSQL integration with CRUD operations
- ISR/Caching with customizable cache handlers
- Middleware for route protection
- Environment variables (both client and server-side)
- Server startup code with instrumentation
- Cron jobs that hit API routes
- Rate limiting with Nginx configuration
Step 1: Setting Up Your VPS
1.1 Choose a VPS Provider
DigitalOcean (used in this tutorial):
- $4/month basic droplet (512MB RAM, 1 CPU, 10GB SSD)
- Good for small applications and learning
- Easy-to-use interface with monitoring graphs
Hetzner (alternative option):
- Often more hardware for the price
- Excellent price-to-performance ratio
- Popular choice for cost-conscious developers
1.2 Create DigitalOcean Droplet
-
Sign up at DigitalOcean
-
Create a new Droplet:
- Image: Ubuntu (latest LTS version)
- Plan: Basic ($4/month - 512MB, 1 CPU)
- Region: Choose closest to your users
- Authentication: SSH keys (recommended) or password
- Hostname: Give it a memorable name
-
Note your server's IP address - you'll need this for SSH access and DNS configuration
1.3 Initial Server Access
Once your droplet is created:
ssh root@YOUR_SERVER_IP
Enter your password when prompted. You'll see the Ubuntu welcome message and system information.
Step 2: Understanding VPS vs Dedicated Servers
Virtual Private Server (VPS)
- Shared hardware among multiple tenants
- Very affordable pricing due to resource sharing
- Good performance for most small to medium applications
- Scalable with upgrade options
Dedicated Server
- Exclusive hardware access
- Higher cost but more control and performance
- Better for high-traffic or resource-intensive applications
Database Hosting Considerations
- Shared infrastructure: Good for development and small apps
- Dedicated infrastructure: Better for production with heavy I/O
- Managed services: Automatic backups, monitoring, scaling (higher cost)
Step 3: Scaling Strategies
Vertical Scaling
- Add more resources to existing server (CPU, RAM, storage)
- Operationally simpler - just upgrade your plan
- Single point of failure - if server goes down, everything goes down
Horizontal Scaling
- Add more containers/servers behind a load balancer
- Better fault tolerance - multiple instances provide redundancy
- Zero-downtime deployments - route traffic between old and new versions
- More complex operational overhead
Step 4: Deploying with Docker
4.1 Download and Run Deploy Script
The deployment uses a bash script that automates the entire setup process:
# Download the deploy script
curl -fsSL https://raw.githubusercontent.com/your-repo/deploy.sh -o deploy.sh
# Make it executable
chmod +x deploy.sh
# Edit the configuration (see next step)
vi deploy.sh
# Run the deployment
./deploy.sh
4.2 Configure Environment Variables
Before running the script, edit the configuration variables:
vi deploy.sh
Key variables to modify:
# Your domain name (required for SSL)
DOMAIN="your-domain.com"
# Your email for SSL certificate registration
EMAIL="your-email@example.com"
# PostgreSQL configuration (auto-generated)
POSTGRES_USER="nextapp"
POSTGRES_PASSWORD="generated-random-password"
POSTGRES_DB="nextapp"
# Application secrets (auto-generated)
NEXTAUTH_SECRET="generated-secret"
NEXTAUTH_URL="https://your-domain.com"
Quick Vim editing:
j/k
: Move up/downw
: Move to next wordci"
: Change text inside quotesEscape
: Exit insert mode:wq
: Save and quit
4.3 What the Deploy Script Does
The deploy script automates these tasks:
-
System Updates:
apt update && apt upgrade -y
-
Swap Space Setup (critical for 512MB RAM):
fallocate -l 1G /swapfile chmod 600 /swapfile mkswap /swapfile swapon /swapfile
-
Docker Installation:
curl -fsSL https://get.docker.com -o get-docker.sh sh get-docker.sh
-
Docker Compose Installation:
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose
-
Git Repository Clone:
git clone https://github.com/your-username/next-self-host.git app
-
Environment File Creation:
cat > app/.env << EOF POSTGRES_USER=$POSTGRES_USER POSTGRES_PASSWORD=$POSTGRES_PASSWORD POSTGRES_DB=$POSTGRES_DB DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@db:5432/$POSTGRES_DB EOF
-
Nginx Installation and Configuration:
apt install nginx -y
-
SSL Certificate Setup:
apt install certbot python3-certbot-nginx -y certbot --nginx -d $DOMAIN --email $EMAIL --agree-tos --non-interactive
-
Nginx Configuration with:
- Rate limiting (prevents abuse)
- SSL termination
- Proxy buffering disabled (enables streaming)
- Security headers
-
Docker Compose Deployment:
cd app && docker-compose up -d --build
Step 5: Understanding the Docker Setup
5.1 Multi-Stage Dockerfile
The Next.js application uses a multi-stage Dockerfile for efficiency:
Stage 1: Dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
Stage 2: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build
Stage 3: Production
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
5.2 Next.js Standalone Output
Critical configuration in next.config.js
:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone", // Reduces Docker image size by ~80%
// Other configurations...
};
module.exports = nextConfig;
Package.json start script:
{
"scripts": {
"start": "node .next/standalone/server.js"
}
}
5.3 Docker Compose Configuration
The docker-compose.yml
orchestrates three services:
Web Application:
services:
web:
build: .
ports:
- "3000:3000"
depends_on:
- db
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/dbname
networks:
- app-network
PostgreSQL Database:
db:
image: postgres:15
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
Cron Service:
cron:
image: alpine:latest
command: >
sh -c "echo '*/10 * * * * curl -X POST http://web:3000/api/cleanup' | crontab - && crond -f"
networks:
- app-network
Step 6: Advanced Next.js Features Configuration
6.1 Image Optimization
Next.js provides built-in image optimization that works seamlessly when self-hosting:
Basic usage:
import Image from "next/image";
export default function MyPage() {
return (
<Image
src="https://example.com/image.jpg"
alt="Description"
width={500}
height={300}
/>
);
}
Configuration in next.config.js
:
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
port: "",
pathname: "/**",
},
],
// Prevent abuse with query parameter restrictions
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
};
What happens: Image requests go to /_next/image?url=...&w=...&q=...
, Next.js optimizes them server-side using Sharp (built-in as of Next.js 15).
Custom image loader (optional):
// image-loader.js
export default function myImageLoader({ src, width, quality }) {
return `https://my-cdn.com/${src}?w=${width}&q=${quality || 75}`;
}
6.2 Streaming Server Components
Enable streaming by disabling proxy buffering in Nginx:
Nginx configuration (handled by deploy script):
location / {
proxy_pass http://localhost:3000;
proxy_buffering off; # Critical for streaming
proxy_cache off;
}
Next.js configuration:
// next.config.js
module.exports = {
compress: false, // Let Nginx handle compression
};
Example streaming page:
import { Suspense } from "react";
function LoadingCard() {
return <div>Loading...</div>;
}
async function AsyncData({ delay }) {
await new Promise((resolve) => setTimeout(resolve, delay * 1000));
return <div>Data loaded after {delay} seconds</div>;
}
export default function StreamingPage() {
return (
<div>
<h1>Streaming Demo</h1>
<Suspense fallback={<LoadingCard />}>
<AsyncData delay={1} />
</Suspense>
<Suspense fallback={<LoadingCard />}>
<AsyncData delay={2} />
</Suspense>
<Suspense fallback={<LoadingCard />}>
<AsyncData delay={3} />
</Suspense>
</div>
);
}
6.3 PostgreSQL with Drizzle ORM
Database schema (db/schema.ts
):
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const todos = pgTable("todos", {
id: serial("id").primaryKey(),
text: text("text").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
Database connection (db/index.ts
):
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client);
Server actions for CRUD operations:
"use server";
import { db } from "@/db";
import { todos } from "@/db/schema";
import { revalidatePath } from "next/cache";
export async function addTodo(formData: FormData) {
const text = formData.get("text") as string;
await db.insert(todos).values({
text: text,
});
revalidatePath("/todos");
}
export async function deleteTodo(id: number) {
await db.delete(todos).where(eq(todos.id, id));
revalidatePath("/todos");
}
Accessing the database via Docker:
# Connect to PostgreSQL container
docker exec -it app_db_1 psql -U your_user -d your_database
# View tables
\d
# Query data
SELECT * FROM todos;
6.4 ISR (Incremental Static Regeneration)
Basic ISR with time-based revalidation:
// app/isr/page.js
async function getPokemon() {
const res = await fetch("https://pokeapi.co/api/v2/pokemon/1", {
next: { revalidate: 10 }, // Revalidate every 10 seconds
});
return res.json();
}
export default async function ISRPage() {
const pokemon = await getPokemon();
return (
<div>
<h1>{pokemon.name}</h1>
<p>Generated at: {new Date().toISOString()}</p>
</div>
);
}
Route-level revalidation:
// Alternative: set revalidation at page level
export const revalidate = 10; // seconds
Manual revalidation:
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateISR() {
revalidatePath("/isr");
}
Custom cache handler (advanced):
// next.config.js
module.exports = {
cacheHandler: "./cache-handler.js",
cacheMaxMemorySize: 0, // Disable in-memory caching
};
// cache-handler.js
const fs = require("fs");
const path = require("path");
const CACHE_DIR = ".cache";
module.exports = class CustomCacheHandler {
constructor(options) {
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
}
async get(key) {
const filePath = path.join(CACHE_DIR, `${key}.json`);
try {
const data = fs.readFileSync(filePath, "utf8");
return JSON.parse(data);
} catch {
return null;
}
}
async set(key, data) {
const filePath = path.join(CACHE_DIR, `${key}.json`);
fs.writeFileSync(filePath, JSON.stringify(data));
}
async revalidateTag(tag) {
// Clear cache entries for this tag
console.log(`Revalidating tag: ${tag}`);
}
};
6.5 Middleware Configuration
Route protection with cookies:
// middleware.js
import { NextResponse } from "next/server";
export function middleware(request) {
const protectedCookie = request.cookies.get("protected");
if (request.nextUrl.pathname === "/protected") {
if (!protectedCookie || protectedCookie.value !== "1") {
return NextResponse.redirect(new URL("/", request.url));
}
}
}
export const config = {
matcher: "/protected",
};
6.6 Environment Variables
Client-side variables (bundled in build):
// components/ClientComponent.js
"use client";
export default function ClientComponent() {
return (
<div>
<p>Public key: {process.env.NEXT_PUBLIC_SAFE_KEY}</p>
</div>
);
}
Server-side variables (runtime only):
// app/protected/page.js
export default function ProtectedPage() {
const secretKey = process.env.SECRET_KEY; // Only available on server
return (
<div>
<p>Secret: {secretKey}</p>
</div>
);
}
Server startup code (instrumentation.js
):
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Run code when server starts
console.log("Server starting up...");
// Example: Fetch secrets from HashiCorp Vault
const vaultResponse = await fetch("https://vault.example.com/secret", {
headers: { Authorization: `Bearer ${process.env.VAULT_TOKEN}` },
});
const secrets = await vaultResponse.json();
global.SECRET_API_KEY = secrets.apiKey;
}
}
6.7 Cron Jobs and Route Handlers
API route for cleanup (app/api/cleanup/route.js
):
import { db } from "@/db";
import { todos } from "@/db/schema";
import { revalidatePath } from "next/cache";
export async function POST() {
try {
// Delete all todos (or add your cleanup logic)
await db.delete(todos);
// Revalidate affected pages
revalidatePath("/todos");
return Response.json({ success: true });
} catch (error) {
return Response.json({ error: "Cleanup failed" }, { status: 500 });
}
}
Cron service (in docker-compose.yml):
cron:
image: alpine:latest
command: >
sh -c "
apk add --no-cache curl &&
echo '*/10 * * * * curl -X POST http://web:3000/api/cleanup' | crontab - &&
crond -f
"
networks:
- app-network
6.8 Rate Limiting with Nginx
The deploy script configures rate limiting automatically:
# Rate limiting configuration
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location / {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://localhost:3000;
}
}
}
Test rate limiting:
# Load test tool (install with: apt install wrk)
wrk -t2 -c10 -d5s https://your-domain.com
# Results will show blocked requests
Step 7: DNS Configuration
7.1 Point Domain to Server
In your domain registrar's DNS settings:
A Record:
- Name:
@
(root domain) - Value: Your server's IP address
- TTL: 3600 (1 hour)
Optional CNAME for www:
- Name:
www
- Value:
your-domain.com
7.2 Verify DNS Propagation
# Check if DNS has propagated
nslookup your-domain.com
# Test HTTPS certificate
curl -I https://your-domain.com
Step 8: Monitoring and Maintenance
8.1 Check Application Status
# SSH into server
ssh root@your-server-ip
# Navigate to app directory
cd app
# Check running containers
docker ps
# View logs
docker-compose logs web
docker-compose logs db
docker-compose logs cron
# Check environment variables
cat .env
8.2 Resource Monitoring
# Check system resources
htop # Interactive process viewer
df -h # Disk usage
free -h # Memory usage
# Check swap usage (important for 512MB VPS)
swapon --show
8.3 Database Maintenance
# Backup database
docker exec app_db_1 pg_dump -U your_user your_database > backup.sql
# Restore database
cat backup.sql | docker exec -i app_db_1 psql -U your_user -d your_database
Step 9: Scaling Considerations
9.1 Vertical Scaling (Upgrade Server)
When you outgrow your $4 VPS:
- DigitalOcean: Resize droplet in control panel
- More RAM: Essential for image optimization and caching
- More CPU: Helps with concurrent requests and builds
- More storage: Required as your application data grows
9.2 Horizontal Scaling (Multiple Containers)
For higher traffic, consider splitting services:
Separate Image Optimization:
# docker-compose.yml
image-optimizer:
image: your-custom-image-service
ports:
- "3001:3000"
External Cache (Redis):
redis:
image: redis:7
volumes:
- redis_data:/data
Load Balancer (Nginx):
upstream nextjs_backend {
server localhost:3000;
server localhost:3002;
server localhost:3003;
}
server {
location / {
proxy_pass http://nextjs_backend;
}
}
9.3 Multi-Server Setup
For production applications:
- Dedicated database server
- Multiple application servers
- Load balancer (Nginx or cloud service)
- CDN for static assets
- Monitoring (Grafana, Prometheus)
Step 10: Cost Analysis
Monthly Costs Breakdown
Basic Setup:
- DigitalOcean $4 droplet: $4/month
- Domain name: ~$1/month (amortized)
- Total: ~$5/month
Scaling Options:
- $12 droplet (1GB RAM, 1 CPU): Better performance
- $18 droplet (2GB RAM, 1 CPU): Recommended for production
- $24 droplet (2GB RAM, 2 CPU): Good for higher traffic
Comparison to Managed Services:
- Vercel Pro: $20/month + usage
- Railway: $5-20+/month
- Render: $7-25+/month
- Savings: 60-90% for similar functionality
Troubleshooting Common Issues
Memory Issues (512MB VPS)
Symptoms:
- Build failures
- Application crashes
- Slow performance
Solutions:
# Check if swap is active
swapon --show
# Add more swap if needed
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
# Make permanent
echo '/swapfile none swap sw 0 0' >> /etc/fstab
SSL Certificate Issues
Common problems:
- Domain not pointing to server
- Firewall blocking ports 80/443
- DNS not propagated
Debug steps:
# Test domain resolution
nslookup your-domain.com
# Check if Nginx is running
systemctl status nginx
# Manually renew certificate
certbot renew --dry-run
Docker Build Failures
Check logs:
docker-compose logs web
Rebuild containers:
docker-compose down
docker-compose up --build -d
Database Connection Issues
Verify database is running:
docker-compose ps
Test connection:
docker exec -it app_db_1 psql -U your_user -d your_database
Check environment variables:
cat .env | grep DATABASE_URL
Security Considerations
Basic Security Measures
-
Regular updates:
apt update && apt upgrade -y
-
Firewall configuration:
ufw allow ssh ufw allow 80 ufw allow 443 ufw enable
-
SSH key authentication (disable password auth):
# Edit SSH config nano /etc/ssh/sshd_config # Set PasswordAuthentication no # Restart SSH service systemctl restart ssh
-
Regular backups:
# Database backup script #!/bin/bash docker exec app_db_1 pg_dump -U $POSTGRES_USER $POSTGRES_DB > backup-$(date +%Y%m%d).sql
Advanced Security
- Fail2Ban: Prevent brute force attacks
- Log monitoring: Set up alerts for suspicious activity
- Container scanning: Regular security updates
- Network segmentation: Isolate database from public access
Alternative Deployment Options
1. Static Export (No Server)
For simple sites without server-side features:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
trailingSlash: true,
images: { unoptimized: true },
};
module.exports = nextConfig;
Deploy to any static hosting (Netlify, GitHub Pages, etc.)
2. Kamal (from 37signals)
Production-ready deployment tool:
# config/deploy.yml
service: myapp
image: myapp
servers:
web:
- 192.168.1.1
registry:
server: registry.digitalocean.com
username: token
password: $DOCKER_REGISTRY_TOKEN
3. Coolify (Self-hosted PaaS)
Alternative to the manual Docker setup:
- Git integration
- Automatic deployments
- Web interface for management
- Database management
- SSL certificate automation
4. Cloud Provider Adapters
- SST: Deploy to AWS Lambda/CloudFront
- Vercel: Official managed platform
- Netlify: JAMstack-focused hosting
- Railway: Simple container hosting
Best Practices Summary
Development Workflow
- Test locally with Docker Compose
- Use environment variables for configuration
- Version control your deployment scripts
- Monitor application performance and errors
- Regular backups of data and configuration
Production Readiness
- Health checks for containers
- Log aggregation and monitoring
- Automated deployments via CI/CD
- Load testing before high traffic
- Disaster recovery plan
Cost Optimization
- Start small and scale as needed
- Monitor resource usage regularly
- Use CDN for static assets when traffic grows
- Optimize images and caching strategies
- Consider managed services at scale
Conclusion
Self-hosting Next.js applications provides excellent learning opportunities and significant cost savings. This setup gives you:
- Full control over your infrastructure
- Deep understanding of web application deployment
- Cost savings of 60-90% compared to managed platforms
- Scalability path as your application grows
The $4 VPS setup works well for:
- Personal projects and portfolios
- Small business websites
- Learning and experimentation
- MVP and prototype applications
As your application grows, you can scale vertically (upgrade server resources) or horizontally (add more servers and load balancing).
The containerized approach with Docker makes your application portable across different hosting providers, giving you flexibility to migrate if needed.
Next Steps:
- Deploy your own application using this guide
- Experiment with the different Next.js features
- Monitor performance and optimize as needed
- Consider managed services when scaling becomes complex
This guide provides a solid foundation for understanding both self-hosted infrastructure and what managed platforms like Vercel provide behind the scenes.