Next.js Cloud Hosting: The Complete Production Deployment Guide
App Router, SSR, ISR, and API routes on managed container hosting with git push deployment.
In This Guide
Next.js Cloud Hosting: Deploying Next.js Applications to Production
Next.js is the dominant React framework for production applications, but its deployment story has a catch: the "just deploy to Vercel" default isn't always the right answer. Vendor lock-in, per-seat pricing that scales unpredictably, and features that only work on Vercel's proprietary infrastructure push many teams toward self-hosted deployment. This guide covers deploying Next.js on your own container infrastructure — with full feature parity.
Understanding Next.js Output Modes
Next.js supports three deployment targets, and choosing the wrong one limits your options:
Standalone output (output: 'standalone'): Next.js traces all required files and creates a minimal production bundle. This is what you want for container deployment. The output includes a self-contained Node.js server.
Static export (output: 'export'): Generates static HTML/CSS/JS files. No server required — deploy to any CDN or static host. Works only for applications with no server-side rendering or API routes.
Default (no output config): Requires the full Next.js server. Works but produces a larger deployment artifact than standalone.
For containerized deployment, always use output: 'standalone':
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
// Other config...
}
module.exports = nextConfig
Dockerfile for Next.js
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Build the application
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build arguments for public env vars (build-time only)
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy public assets
COPY --from=builder /app/public ./public
# Copy standalone output
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"]
The NEXT_TELEMETRY_DISABLED=1 flag disables Next.js telemetry in production — saves a network call on every build.
Note the ARG NEXT_PUBLIC_API_URL pattern: environment variables prefixed with NEXT_PUBLIC_ are inlined into the client-side JavaScript bundle at build time. They must be available during npm run build, not just at runtime. Pass them as Docker build arguments.
Environment Variables: Build-time vs Runtime
This is the most common source of confusion in Next.js deployments.
Build-time (baked into client bundle):
NEXT_PUBLIC_API_URL=https://api.myapp.com
NEXT_PUBLIC_STRIPE_KEY=pk_live_...
NEXT_PUBLIC_GA_TRACKING_ID=G-XXXXXXXX
These are exposed to the browser. Never put secrets here.
Runtime (server-side only):
DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=https://myapp.com
STRIPE_SECRET_KEY=sk_live_...
API_SECRET_KEY=...
These are only accessible in getServerSideProps, API routes, and Server Components. Never prefixed with NEXT_PUBLIC_.
Set runtime variables as container environment variables on your platform. They're injected at container start and never baked into the image.
Next.js with a Database
For API routes and server-side data fetching with PostgreSQL:
// lib/db.js
import { Pool } from 'pg'
// Singleton connection pool — important for serverless-style deployments
// where modules are cached between requests
let pool
export function getPool() {
if (!pool) {
pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
}
return pool
}
// pages/api/products.js
import { getPool } from '@/lib/db'
export default async function handler(req, res) {
const pool = getPool()
try {
const { rows } = await pool.query(
'SELECT id, name, price FROM products WHERE active = true ORDER BY name'
)
res.status(200).json(rows)
} catch (error) {
console.error('Database error:', error)
res.status(500).json({ error: 'Internal server error' })
}
}
The singleton pattern matters: each module is loaded once per Node.js process. A new Pool() on every request would exhaust database connections quickly.
When your database runs on the same internal network as your Next.js container:
DATABASE_URL=postgresql://user:password@db:5432/myapp
Internal network queries complete in sub-millisecond time — significantly faster than an external database connection.
Next.js Image Optimization
Next.js's <Image> component optimizes images on-demand: resizing, format conversion (WebP, AVIF), and lazy loading. By default it uses a local image optimization server built into Next.js.
In container deployments, this works out of the box. Configure allowed external image domains:
// next.config.js
module.exports = {
output: 'standalone',
images: {
domains: ['images.yourcdn.com', 'res.cloudinary.com'],
formats: ['image/avif', 'image/webp'],
},
}
Image optimization is CPU-intensive for the first request to each unique image/size combination. Subsequent requests serve the cached version quickly. On a container with limited CPU, a burst of first-time image requests can be slow. If this is a bottleneck, use a dedicated image CDN (Cloudinary, Imgix) and disable Next.js image optimization:
// For a specific image component
<Image src={url} unoptimized />
// Or globally in next.config.js
images: { unoptimized: true }
Caching Strategy
Next.js has multiple cache layers that need to be understood for correct behavior in containers.
Data Cache (Next.js 13+ App Router)
// Cache for 1 hour
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
})
// No cache (always fresh)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
The data cache persists within the container's filesystem. On container restart, the cache is cleared and warms up again. This is expected behavior — don't mount the cache directory as a volume unless you specifically want cache persistence across restarts.
Full Route Cache
Statically generated pages are cached in .next/cache. In standalone mode, this lives inside the container. Same behavior: cleared on restart, warms up on first request.
On-Demand Revalidation
For content that needs immediate cache invalidation (CMS updates, price changes):
// pages/api/revalidate.js
export default async function handler(req, res) {
// Validate secret token
if (req.query.secret !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ message: 'Invalid token' })
}
try {
await res.revalidate('/') // Revalidate homepage
await res.revalidate(`/products/${req.query.slug}`)
return res.json({ revalidated: true })
} catch (err) {
return res.status(500).send('Error revalidating')
}
}
Call this endpoint from your CMS webhook when content changes. The Next.js server immediately regenerates the cached page.
NextAuth.js Configuration
// pages/api/auth/[...nextauth].js
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import { PostgreSQLAdapter } from '@next-auth/pg-adapter'
import { getPool } from '@/lib/db'
export const authOptions = {
adapter: PostgreSQLAdapter(getPool()),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
session: {
strategy: 'jwt', // No database sessions — scales better in containers
},
secret: process.env.NEXTAUTH_SECRET,
}
export default NextAuth(authOptions)
Required environment variables:
NEXTAUTH_URL=https://myapp.com # Full URL of your application
NEXTAUTH_SECRET=64-char-random-string # Run: openssl rand -base64 32
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
NEXTAUTH_URL must be set to your exact production URL. Incorrect values cause authentication callback failures.
Health Check
// pages/api/health.js
import { getPool } from '@/lib/db'
export default async function handler(req, res) {
const checks = {}
let healthy = true
// Database check
try {
const pool = getPool()
await pool.query('SELECT 1')
checks.database = 'connected'
} catch (err) {
checks.database = 'disconnected'
healthy = false
}
// Next.js server is obviously running if we got here
checks.server = 'running'
res.status(healthy ? 200 : 503).json({
status: healthy ? 'healthy' : 'degraded',
checks,
timestamp: new Date().toISOString(),
})
}
Handling Multiple Instances (Horizontal Scaling)
Running multiple Next.js container instances requires stateless session management:
JWT sessions (recommended): Sessions encoded in signed tokens, no server-side session storage needed. All instances can verify any session without coordination.
Shared Redis for sessions: If you need server-side sessions (for security-sensitive applications), store them in Redis accessible to all instances:
# next-auth with Redis adapter
npm install @upstash/redis @auth/upstash-redis-adapter
Shared cache: The data cache and route cache are container-local. With multiple instances, each container has its own cache that may be out of sync. Use on-demand revalidation via API endpoint that hits all instances, or accept that cache warming happens per-instance.
For most applications, JWT sessions and per-instance caches work fine. The inconsistency window is the cache TTL period — typically minutes to hours depending on your revalidation settings.
Production Environment Variables Checklist
# Next.js
NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1
PORT=3000
HOSTNAME=0.0.0.0
# Application URL
NEXTAUTH_URL=https://myapp.com
# Database (internal network)
DATABASE_URL=postgresql://user:password@db:5432/myapp
# Secrets
NEXTAUTH_SECRET=<openssl rand -base64 32>
# Public vars (build-time — must be set during docker build)
NEXT_PUBLIC_API_URL=https://api.myapp.com
Common Deployment Issues
"NEXT_PUBLIC_* variable is undefined at runtime": Build-time variables must be passed as Docker build ARGs. They can't be set as runtime environment variables.
Images not displaying: domains in next.config.js must include the hostname of all external image sources. Missing a domain shows broken images with a 400 error in the Next.js image API.
NEXTAUTH_URL mismatch: NextAuth redirects to the URL in NEXTAUTH_URL after authentication. If it doesn't match your actual domain exactly (including protocol, no trailing slash), authentication callbacks fail.
Large standalone build: The standalone output copies all files required to run Next.js. If your build is large (>500MB), check for accidentally included large files. Add a .dockerignore:
node_modules
.next
.git
*.md
tests/
Next.js on container infrastructure is reliable and cost-effective. The standalone output mode was specifically designed for this deployment pattern — Vercel uses it internally for its own edge deployments. Running it on your own container platform gives you the same capabilities without the lock-in.
Deploy Your App with Git Push
Automatic builds, environment variables, live logs, rollback, and custom domains. No server management required.
Deploy Free — No Card RequiredPowered by WHMCompleteSolution