| 1 | # Self-Hosting Next.js |
| 2 | |
| 3 | Deploy Next.js outside of Vercel with confidence. |
| 4 | |
| 5 | ## Quick Start: Standalone Output |
| 6 | |
| 7 | For Docker or any containerized deployment, use standalone output: |
| 8 | |
| 9 | ```js |
| 10 | // next.config.js |
| 11 | module.exports = { |
| 12 | output: "standalone", |
| 13 | }; |
| 14 | ``` |
| 15 | |
| 16 | This creates a minimal `standalone` folder with only production dependencies: |
| 17 | |
| 18 | ``` |
| 19 | .next/ |
| 20 | ├── standalone/ |
| 21 | │ ├── server.js # Entry point |
| 22 | │ ├── node_modules/ # Only production deps |
| 23 | │ └── .next/ # Build output |
| 24 | └── static/ # Must be copied separately |
| 25 | ``` |
| 26 | |
| 27 | ## Docker Deployment |
| 28 | |
| 29 | ### Dockerfile |
| 30 | |
| 31 | ```dockerfile |
| 32 | FROM node:20-alpine AS base |
| 33 | |
| 34 | # Install dependencies |
| 35 | FROM base AS deps |
| 36 | WORKDIR /app |
| 37 | COPY package.json package-lock.json* ./ |
| 38 | RUN npm ci |
| 39 | |
| 40 | # Build |
| 41 | FROM base AS builder |
| 42 | WORKDIR /app |
| 43 | COPY --from=deps /app/node_modules ./node_modules |
| 44 | COPY . . |
| 45 | RUN npm run build |
| 46 | |
| 47 | # Production |
| 48 | FROM base AS runner |
| 49 | WORKDIR /app |
| 50 | |
| 51 | ENV NODE_ENV=production |
| 52 | |
| 53 | # Create non-root user |
| 54 | RUN addgroup --system --gid 1001 nodejs |
| 55 | RUN adduser --system --uid 1001 nextjs |
| 56 | |
| 57 | # Copy standalone output |
| 58 | COPY --from=builder /app/.next/standalone ./ |
| 59 | COPY --from=builder /app/.next/static ./.next/static |
| 60 | COPY --from=builder /app/public ./public |
| 61 | |
| 62 | USER nextjs |
| 63 | |
| 64 | EXPOSE 3000 |
| 65 | ENV PORT=3000 |
| 66 | ENV HOSTNAME="0.0.0.0" |
| 67 | |
| 68 | CMD ["node", "server.js"] |
| 69 | ``` |
| 70 | |
| 71 | ### Docker Compose |
| 72 | |
| 73 | ```yaml |
| 74 | version: "3.8" |
| 75 | |
| 76 | services: |
| 77 | web: |
| 78 | build: . |
| 79 | ports: |
| 80 | - "3000:3000" |
| 81 | environment: |
| 82 | - NODE_ENV=production |
| 83 | restart: unless-stopped |
| 84 | healthcheck: |
| 85 | test: ["CMD" |
| 86 | interval: 30s |
| 87 | timeout: 10s |
| 88 | retries: 3 |
| 89 | ``` |
| 90 | |
| 91 | ## PM2 Deployment |
| 92 | |
| 93 | For traditional server deployments: |
| 94 | |
| 95 | ```js |
| 96 | // ecosystem.config.js |
| 97 | module.exports = { |
| 98 | apps: [ |
| 99 | { |
| 100 | name: "nextjs", |
| 101 | script: ".next/standalone/server.js", |
| 102 | instances: "max", |
| 103 | exec_mode: "cluster", |
| 104 | env: { |
| 105 | NODE_ENV: "production", |
| 106 | PORT: 3000, |
| 107 | }, |
| 108 | }, |
| 109 | ], |
| 110 | }; |
| 111 | ``` |
| 112 | |
| 113 | ```bash |
| 114 | npm run build |
| 115 | pm2 start ecosystem.config.js |
| 116 | ``` |
| 117 | |
| 118 | ## ISR and Cache Handlers |
| 119 | |
| 120 | ### The Problem |
| 121 | |
| 122 | ISR (Incremental Static Regeneration) uses filesystem caching by default. This **breaks with multiple instances**: |
| 123 | |
| 124 | - Instance A regenerates page → saves to its local disk |
| 125 | - Instance B serves stale page → doesn't see Instance A's cache |
| 126 | - Load balancer sends users to random instances → inconsistent content |
| 127 | |
| 128 | ### Solution: Custom Cache Handler |
| 129 | |
| 130 | Next.js 14+ supports custom cache handlers for shared storage: |
| 131 | |
| 132 | ```js |
| 133 | // next.config.js |
| 134 | module.exports = { |
| 135 | cacheHandler: require.resolve |
| 136 | cacheMaxMemorySize: 0, // |
| 137 | }; |
| 138 | ``` |
| 139 | |
| 140 | #### Redis Cache Handler Example |
| 141 | |
| 142 | ```js |
| 143 | // cache-handler.js |
| 144 | const Redis = require |
| 145 | |
| 146 | const redis = new |
| 147 | const CACHE_PREFIX = " |
| 148 | |
| 149 | module.exports = class |
| 150 | constructor(options) { |
| 151 | this.options = |
| 152 | } |
| 153 | |
| 154 | async get(key |
| 155 | const data = |
| 156 | if (!data) |
| 157 | |
| 158 | const parsed = |
| 159 | return { |
| 160 | value: parsed.value |
| 161 | lastModified: parsed.lastModified |
| 162 | }; |
| 163 | } |
| 164 | |
| 165 | async set(key |
| 166 | const cacheData = |
| 167 | value: data, |
| 168 | lastModified: Date.now |
| 169 | }; |
| 170 | |
| 171 | // Set TTL based on revalidate option |
| 172 | if (ctx?.revalidate |
| 173 | await redis.setex |
| 174 | } else { |
| 175 | await redis.set |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | async revalidateTag(tags |
| 180 | // Implement tag-based invalidation |
| 181 | // This requires tracking which keys have which tags |
| 182 | } |
| 183 | }; |
| 184 | ``` |
| 185 | |
| 186 | #### S3 Cache Handler Example |
| 187 | |
| 188 | ```js |
| 189 | // cache-handler.js |
| 190 | const { S3Client, |
| 191 | |
| 192 | const s3 = new |
| 193 | const BUCKET = process |
| 194 | |
| 195 | module.exports = class |
| 196 | async get(key |
| 197 | try { |
| 198 | const response = |
| 199 | new GetObjectCommand({ |
| 200 | Bucket: BUCKET, |
| 201 | Key: `cache/${key |
| 202 | }), |
| 203 | ); |
| 204 | const body = |
| 205 | return JSON.parse |
| 206 | } catch (err) |
| 207 | if (err.name |
| 208 | throw err; |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | async set(key |
| 213 | await s3.send |
| 214 | new PutObjectCommand({ |
| 215 | Bucket: BUCKET, |
| 216 | Key: `cache/${key |
| 217 | Body: JSON.stringify |
| 218 | value: data, |
| 219 | lastModified: Date.now |
| 220 | }), |
| 221 | ContentType: "application/json", |
| 222 | }), |
| 223 | ); |
| 224 | } |
| 225 | }; |
| 226 | ``` |
| 227 | |
| 228 | ## What Works vs What Needs Setup |
| 229 | |
| 230 | | Feature | Single Instance | Multi-Instance | Notes | |
| 231 | | -------------------- | --------------- | |
| 232 | | SSR | Yes | Yes | No special setup | |
| 233 | | SSG | Yes | Yes | Built at deploy time | |
| 234 | | ISR | Yes | Needs cache handler | Filesystem cache breaks | |
| 235 | | Image Optimization | Yes | Yes | CPU-intensive, consider CDN | |
| 236 | | Middleware | Yes | Yes | Runs on Node.js | |
| 237 | | Edge Runtime | Limited | Limited | Some features Node-only | |
| 238 | | `revalidatePath/Tag` | Yes | Needs cache handler |
| 239 | | `next/font` | Yes | Yes |
| 240 | | Draft Mode | Yes | Yes | Cookie-based | |
| 241 | |
| 242 | ## Image Optimization |
| 243 | |
| 244 | Next.js Image Optimization works out of the box but is CPU-intensive. |
| 245 | |
| 246 | ### Option 1: Built-in (Simple) |
| 247 | |
| 248 | Works automatically, but consider: |
| 249 | |
| 250 | - Set `deviceSizes` and `imageSizes` in config to limit variants |
| 251 | - Use `minimumCacheTTL` to reduce regeneration |
| 252 | |
| 253 | ```js |
| 254 | // next.config.js |
| 255 | module.exports = { |
| 256 | images: { |
| 257 | minimumCacheTTL: 60 * 60 |
| 258 | deviceSizes: [640, 750, |
| 259 | }, |
| 260 | }; |
| 261 | ``` |
| 262 | |
| 263 | ### Option 2: External Loader (Recommended for Scale) |
| 264 | |
| 265 | Offload to Cloudinary, Imgix, or similar: |
| 266 | |
| 267 | ```js |
| 268 | // next.config.js |
| 269 | module.exports = { |
| 270 | images: { |
| 271 | loader: "custom", |
| 272 | loaderFile: "./lib/image-loader.js", |
| 273 | }, |
| 274 | }; |
| 275 | ``` |
| 276 | |
| 277 | ```js |
| 278 | // lib/image-loader.js |
| 279 | export default function cloudinaryLoader |
| 280 | const params = |
| 281 | return `https://res.cloudinary.com/demo/image/upload/${params |
| 282 | } |
| 283 | ``` |
| 284 | |
| 285 | ## Environment Variables |
| 286 | |
| 287 | ### Build-time vs Runtime |
| 288 | |
| 289 | ```js |
| 290 | // Available at build time only (baked into bundle) |
| 291 | NEXT_PUBLIC_API_URL=https://api.example.com |
| 292 | |
| 293 | // Available at runtime (server-side only) |
| 294 | DATABASE_URL=postgresql://... |
| 295 | API_SECRET=... |
| 296 | ``` |
| 297 | |
| 298 | ### Runtime Configuration |
| 299 | |
| 300 | For truly dynamic config, don't use `NEXT_PUBLIC_*`. Instead: |
| 301 | |
| 302 | ```tsx |
| 303 | // app/api/config/route.ts |
| 304 | export async function GET |
| 305 | return Response.json |
| 306 | apiUrl: process.env |
| 307 | features: process.env |
| 308 | }); |
| 309 | } |
| 310 | ``` |
| 311 | |
| 312 | ## OpenNext: Serverless Without Vercel |
| 313 | |
| 314 | [OpenNext](https://open-next.js.org/) |
| 315 | |
| 316 | ```bash |
| 317 | npx create-sst@latest |
| 318 | # or |
| 319 | npx @opennextjs/aws build |
| 320 | ``` |
| 321 | |
| 322 | Supports: |
| 323 | |
| 324 | - AWS Lambda + CloudFront |
| 325 | - Cloudflare Workers |
| 326 | - Netlify Functions |
| 327 | - Deno Deploy |
| 328 | |
| 329 | ## Health Check Endpoint |
| 330 | |
| 331 | Always include a health check for load balancers: |
| 332 | |
| 333 | ```tsx |
| 334 | // app/api/health/route.ts |
| 335 | export async function GET |
| 336 | try { |
| 337 | // Optional: check database connection |
| 338 | // await db.$queryRaw`SELECT 1`; |
| 339 | |
| 340 | return Response.json |
| 341 | } catch (error) |
| 342 | return Response.json |
| 343 | } |
| 344 | } |
| 345 | ``` |
| 346 | |
| 347 | ## Pre-Deployment Checklist |
| 348 | |
| 349 | 1. **Build locally first**: |
| 350 | 2. **Test standalone output**: |
| 351 | 3. **Set `output: 'standalone' |
| 352 | 4. **Configure cache handler** for multi-instance ISR |
| 353 | 5. **Set `HOSTNAME="0.0.0.0" |
| 354 | 6. **Copy `public/ |
| 355 | 7. **Add health check endpoint** |
| 356 | 8. **Test ISR revalidation** after deployment |
| 357 | 9. **Monitor memory usage** - Node.js defaults may need tuning |
| 358 | |
| 359 | ## Testing Cache Handler |
| 360 | |
| 361 | **Critical**: Test your cache handler on every Next.js upgrade: |
| 362 | |
| 363 | ```bash |
| 364 | # Start multiple instances |
| 365 | PORT=3001 node .next/standalone/server.js |
| 366 | PORT=3002 node .next/standalone/server.js |
| 367 | |
| 368 | # Trigger ISR revalidation |
| 369 | curl http://localhost:3001/api/revalidate?path=/posts |
| 370 | |
| 371 | # Verify both instances see the update |
| 372 | curl http://localhost:3001/posts |
| 373 | curl http://localhost:3002/posts |
| 374 | # Should return identical content |
| 375 | ``` |