372 lines
8.1 KiB
Markdown
372 lines
8.1 KiB
Markdown
# Self-Hosting Next.js
|
|
|
|
Deploy Next.js outside of Vercel with confidence.
|
|
|
|
## Quick Start: Standalone Output
|
|
|
|
For Docker or any containerized deployment, use standalone output:
|
|
|
|
```js
|
|
// next.config.js
|
|
module.exports = {
|
|
output: 'standalone',
|
|
};
|
|
```
|
|
|
|
This creates a minimal `standalone` folder with only production dependencies:
|
|
|
|
```
|
|
.next/
|
|
├── standalone/
|
|
│ ├── server.js # Entry point
|
|
│ ├── node_modules/ # Only production deps
|
|
│ └── .next/ # Build output
|
|
└── static/ # Must be copied separately
|
|
```
|
|
|
|
## Docker Deployment
|
|
|
|
### Dockerfile
|
|
|
|
```dockerfile
|
|
FROM node:20-alpine AS base
|
|
|
|
# Install dependencies
|
|
FROM base AS deps
|
|
WORKDIR /app
|
|
COPY package.json package-lock.json* ./
|
|
RUN npm ci
|
|
|
|
# Build
|
|
FROM base AS builder
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
RUN npm run build
|
|
|
|
# Production
|
|
FROM base AS runner
|
|
WORKDIR /app
|
|
|
|
ENV NODE_ENV=production
|
|
|
|
# Create non-root user
|
|
RUN addgroup --system --gid 1001 nodejs
|
|
RUN adduser --system --uid 1001 nextjs
|
|
|
|
# Copy standalone output
|
|
COPY --from=builder /app/.next/standalone ./
|
|
COPY --from=builder /app/.next/static ./.next/static
|
|
COPY --from=builder /app/public ./public
|
|
|
|
USER nextjs
|
|
|
|
EXPOSE 3000
|
|
ENV PORT=3000
|
|
ENV HOSTNAME="0.0.0.0"
|
|
|
|
CMD ["node", "server.js"]
|
|
```
|
|
|
|
### Docker Compose
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
web:
|
|
build: .
|
|
ports:
|
|
- "3000:3000"
|
|
environment:
|
|
- NODE_ENV=production
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
```
|
|
|
|
## PM2 Deployment
|
|
|
|
For traditional server deployments:
|
|
|
|
```js
|
|
// ecosystem.config.js
|
|
module.exports = {
|
|
apps: [{
|
|
name: 'nextjs',
|
|
script: '.next/standalone/server.js',
|
|
instances: 'max',
|
|
exec_mode: 'cluster',
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
PORT: 3000,
|
|
},
|
|
}],
|
|
};
|
|
```
|
|
|
|
```bash
|
|
npm run build
|
|
pm2 start ecosystem.config.js
|
|
```
|
|
|
|
## ISR and Cache Handlers
|
|
|
|
### The Problem
|
|
|
|
ISR (Incremental Static Regeneration) uses filesystem caching by default. This **breaks with multiple instances**:
|
|
|
|
- Instance A regenerates page → saves to its local disk
|
|
- Instance B serves stale page → doesn't see Instance A's cache
|
|
- Load balancer sends users to random instances → inconsistent content
|
|
|
|
### Solution: Custom Cache Handler
|
|
|
|
Next.js 14+ supports custom cache handlers for shared storage:
|
|
|
|
```js
|
|
// next.config.js
|
|
module.exports = {
|
|
cacheHandler: require.resolve('./cache-handler.js'),
|
|
cacheMaxMemorySize: 0, // Disable in-memory cache
|
|
};
|
|
```
|
|
|
|
#### Redis Cache Handler Example
|
|
|
|
```js
|
|
// cache-handler.js
|
|
const Redis = require('ioredis');
|
|
|
|
const redis = new Redis(process.env.REDIS_URL);
|
|
const CACHE_PREFIX = 'nextjs:';
|
|
|
|
module.exports = class CacheHandler {
|
|
constructor(options) {
|
|
this.options = options;
|
|
}
|
|
|
|
async get(key) {
|
|
const data = await redis.get(CACHE_PREFIX + key);
|
|
if (!data) return null;
|
|
|
|
const parsed = JSON.parse(data);
|
|
return {
|
|
value: parsed.value,
|
|
lastModified: parsed.lastModified,
|
|
};
|
|
}
|
|
|
|
async set(key, data, ctx) {
|
|
const cacheData = {
|
|
value: data,
|
|
lastModified: Date.now(),
|
|
};
|
|
|
|
// Set TTL based on revalidate option
|
|
if (ctx?.revalidate) {
|
|
await redis.setex(
|
|
CACHE_PREFIX + key,
|
|
ctx.revalidate,
|
|
JSON.stringify(cacheData)
|
|
);
|
|
} else {
|
|
await redis.set(CACHE_PREFIX + key, JSON.stringify(cacheData));
|
|
}
|
|
}
|
|
|
|
async revalidateTag(tags) {
|
|
// Implement tag-based invalidation
|
|
// This requires tracking which keys have which tags
|
|
}
|
|
};
|
|
```
|
|
|
|
#### S3 Cache Handler Example
|
|
|
|
```js
|
|
// cache-handler.js
|
|
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
|
|
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
|
const BUCKET = process.env.CACHE_BUCKET;
|
|
|
|
module.exports = class CacheHandler {
|
|
async get(key) {
|
|
try {
|
|
const response = await s3.send(new GetObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `cache/${key}`,
|
|
}));
|
|
const body = await response.Body.transformToString();
|
|
return JSON.parse(body);
|
|
} catch (err) {
|
|
if (err.name === 'NoSuchKey') return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async set(key, data, ctx) {
|
|
await s3.send(new PutObjectCommand({
|
|
Bucket: BUCKET,
|
|
Key: `cache/${key}`,
|
|
Body: JSON.stringify({
|
|
value: data,
|
|
lastModified: Date.now(),
|
|
}),
|
|
ContentType: 'application/json',
|
|
}));
|
|
}
|
|
};
|
|
```
|
|
|
|
## What Works vs What Needs Setup
|
|
|
|
| Feature | Single Instance | Multi-Instance | Notes |
|
|
|---------|----------------|----------------|-------|
|
|
| SSR | Yes | Yes | No special setup |
|
|
| SSG | Yes | Yes | Built at deploy time |
|
|
| ISR | Yes | Needs cache handler | Filesystem cache breaks |
|
|
| Image Optimization | Yes | Yes | CPU-intensive, consider CDN |
|
|
| Middleware | Yes | Yes | Runs on Node.js |
|
|
| Edge Runtime | Limited | Limited | Some features Node-only |
|
|
| `revalidatePath/Tag` | Yes | Needs cache handler | Must share cache |
|
|
| `next/font` | Yes | Yes | Fonts bundled at build |
|
|
| Draft Mode | Yes | Yes | Cookie-based |
|
|
|
|
## Image Optimization
|
|
|
|
Next.js Image Optimization works out of the box but is CPU-intensive.
|
|
|
|
### Option 1: Built-in (Simple)
|
|
|
|
Works automatically, but consider:
|
|
- Set `deviceSizes` and `imageSizes` in config to limit variants
|
|
- Use `minimumCacheTTL` to reduce regeneration
|
|
|
|
```js
|
|
// next.config.js
|
|
module.exports = {
|
|
images: {
|
|
minimumCacheTTL: 60 * 60 * 24, // 24 hours
|
|
deviceSizes: [640, 750, 1080, 1920], // Limit sizes
|
|
},
|
|
};
|
|
```
|
|
|
|
### Option 2: External Loader (Recommended for Scale)
|
|
|
|
Offload to Cloudinary, Imgix, or similar:
|
|
|
|
```js
|
|
// next.config.js
|
|
module.exports = {
|
|
images: {
|
|
loader: 'custom',
|
|
loaderFile: './lib/image-loader.js',
|
|
},
|
|
};
|
|
```
|
|
|
|
```js
|
|
// lib/image-loader.js
|
|
export default function cloudinaryLoader({ src, width, quality }) {
|
|
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`];
|
|
return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`;
|
|
}
|
|
```
|
|
|
|
## Environment Variables
|
|
|
|
### Build-time vs Runtime
|
|
|
|
```js
|
|
// Available at build time only (baked into bundle)
|
|
NEXT_PUBLIC_API_URL=https://api.example.com
|
|
|
|
// Available at runtime (server-side only)
|
|
DATABASE_URL=postgresql://...
|
|
API_SECRET=...
|
|
```
|
|
|
|
### Runtime Configuration
|
|
|
|
For truly dynamic config, don't use `NEXT_PUBLIC_*`. Instead:
|
|
|
|
```tsx
|
|
// app/api/config/route.ts
|
|
export async function GET() {
|
|
return Response.json({
|
|
apiUrl: process.env.API_URL,
|
|
features: process.env.FEATURES?.split(','),
|
|
});
|
|
}
|
|
```
|
|
|
|
## OpenNext: Serverless Without Vercel
|
|
|
|
[OpenNext](https://open-next.js.org/) adapts Next.js for AWS Lambda, Cloudflare Workers, etc.
|
|
|
|
```bash
|
|
npx create-sst@latest
|
|
# or
|
|
npx @opennextjs/aws build
|
|
```
|
|
|
|
Supports:
|
|
- AWS Lambda + CloudFront
|
|
- Cloudflare Workers
|
|
- Netlify Functions
|
|
- Deno Deploy
|
|
|
|
## Health Check Endpoint
|
|
|
|
Always include a health check for load balancers:
|
|
|
|
```tsx
|
|
// app/api/health/route.ts
|
|
export async function GET() {
|
|
try {
|
|
// Optional: check database connection
|
|
// await db.$queryRaw`SELECT 1`;
|
|
|
|
return Response.json({ status: 'healthy' }, { status: 200 });
|
|
} catch (error) {
|
|
return Response.json({ status: 'unhealthy' }, { status: 503 });
|
|
}
|
|
}
|
|
```
|
|
|
|
## Pre-Deployment Checklist
|
|
|
|
1. **Build locally first**: `npm run build` - catch errors before deploy
|
|
2. **Test standalone output**: `node .next/standalone/server.js`
|
|
3. **Set `output: 'standalone'`** for Docker
|
|
4. **Configure cache handler** for multi-instance ISR
|
|
5. **Set `HOSTNAME="0.0.0.0"`** for containers
|
|
6. **Copy `public/` and `.next/static/`** - not included in standalone
|
|
7. **Add health check endpoint**
|
|
8. **Test ISR revalidation** after deployment
|
|
9. **Monitor memory usage** - Node.js defaults may need tuning
|
|
|
|
## Testing Cache Handler
|
|
|
|
**Critical**: Test your cache handler on every Next.js upgrade:
|
|
|
|
```bash
|
|
# Start multiple instances
|
|
PORT=3001 node .next/standalone/server.js &
|
|
PORT=3002 node .next/standalone/server.js &
|
|
|
|
# Trigger ISR revalidation
|
|
curl http://localhost:3001/api/revalidate?path=/posts
|
|
|
|
# Verify both instances see the update
|
|
curl http://localhost:3001/posts
|
|
curl http://localhost:3002/posts
|
|
# Should return identical content
|
|
```
|