Next.js · 2025

Next.js Cloud Hosting: The Complete Production Deployment Guide

Updated April 2025 · 9 min read

App Router, SSR, ISR, and API routes on managed container hosting with git push deployment.

HomeBlog › Next.js Cloud Hosting: The Complete Production Deployment 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 Required

Powered by WHMCompleteSolution