Vercel is genuinely excellent for Next.js. It's made by the same people, it deploys in 30 seconds, and the DX is hard to beat. But not every project lives on Vercel. Maybe your client has a security policy that requires everything on their own infrastructure. Maybe you need to colocate your app with a database for latency reasons. Maybe Vercel's pricing at scale doesn't make sense for your traffic pattern. Whatever the reason — you'll eventually need to containerize a Next.js app and run it somewhere else, and the first time you try it, you're going to hit some walls.
We've containerized more Next.js apps than we'd like to count at this point. Some for clients on AWS ECS, some on Railway, one on a VPS we spun up at 2am because the client's budget ran out mid-project. The patterns are consistent. The mistakes are also consistent. This post covers both.
First: Enable Standalone Output
This is the single most important thing. Without it, your Docker image includes the entire node_modules directory — which for a typical Next.js project is somewhere between 'embarrassingly large' and 'should not be allowed by law'. With standalone output enabled, Next.js traces which files your app actually needs and bundles only those. Images go from 1.5GB to under 200MB. Seriously.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
// Everything else you already have
};
export default nextConfig;After you build, check your .next/standalone directory. You'll find a self-contained Node.js server. Your static assets end up in .next/static and public/ still needs to be copied over manually — that trips people up. The standalone server doesn't serve static files itself; you either put a CDN in front or configure your Dockerfile to copy them correctly.
The Dockerfile That Actually Works
Multi-stage builds are non-negotiable here. You want your final image to contain zero build tools, zero dev dependencies, and nothing that doesn't need to be there. Here's the Dockerfile we've converged on after iterating through several projects:
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Pass build-time env vars if needed
# ARG NEXT_PUBLIC_API_URL
# ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy static assets
COPY --from=builder /app/public ./public
# Set correct permissions for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy standalone build
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"]A few things worth calling out. The non-root user is not optional if you care about security. Running Node as root inside a container is a bad habit that'll bite you eventually. The libc6-compat package on Alpine is needed for some native modules — include it by default and save yourself a 40-minute debugging session. And HOSTNAME=0.0.0.0 is critical: without it, the server binds to localhost and nothing outside the container can reach it.
Handling Environment Variables — The Annoying Part
Next.js has two kinds of environment variables and Docker makes the distinction matter more than you'd think. Server-side variables (without NEXT_PUBLIC_ prefix) are runtime — you can inject them when starting the container and they work fine. NEXT_PUBLIC_ variables are baked into the JavaScript bundle at build time. This is where people get surprised.
If you're building your Docker image in CI and then deploying the same image to staging and production with different API URLs — and those URLs are exposed via NEXT_PUBLIC_ — you have a problem. The production image will have the staging URL baked in if you built with staging vars.
- Keep NEXT_PUBLIC_ values to things that are actually the same across environments (feature flags, analytics IDs, etc.)
- For values that differ per environment, use a runtime configuration pattern — fetch config from an API endpoint on app load
- If you absolutely must bake environment-specific public vars, build separate images per environment (not ideal but sometimes unavoidable)
- Pass server-only env vars at container runtime, never bake them into the image layer
// app/config/env.ts — server-side only, evaluated at runtime
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
RESEND_API_KEY: z.string(),
});
// This throws at startup if env vars are missing — much better than
// cryptic runtime errors 20 requests into a deploy
export const env = envSchema.parse(process.env);Validate your environment variables at startup. We learned this the hard way after a deploy where the DATABASE_URL was missing and the app started fine, served static pages, then crashed on the first database query. With Zod validation at module load time, the container refuses to start if something's wrong. That's the behavior you want — fail fast, fail loudly.
The .dockerignore File Matters More Than You Think
Building Docker images without a proper .dockerignore is like deploying without a .gitignore — technically works, but you're sending gigabytes of garbage into your build context and wondering why it's slow.
# .dockerignore
.git
.gitignore
.next
node_modules
npm-debug.log
README.md
.env
.env.*
!.env.example
Dockerfile
.dockerignore
.vercel
*.test.ts
*.test.tsx
*.spec.ts
*.spec.tsx
__tests__
coverage
.nyc_output
.turbo
distThe .env files specifically — make sure those are excluded. It's not just about image size; accidentally baking secrets into a Docker layer is a real security incident waiting to happen. The node_modules exclusion matters for build speed: you're copying node_modules from deps stage anyway, no need to include the local one in the build context.
Health Checks and Graceful Shutdown
If you're running on any orchestration platform — ECS, Kubernetes, Railway, Fly.io — you need a health check endpoint. Without it, your platform has no idea whether your container actually started successfully or is just pretending.
// app/api/health/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
// Basic check — extend this to ping your database if you want
// a 'deep' health check
return NextResponse.json(
{
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
},
{ status: 200 }
);
}
// Opt out of caching — health checks should always be fresh
export const dynamic = 'force-dynamic';Then wire it up in your Dockerfile or docker-compose:
# docker-compose.yml example
services:
app:
build: .
ports:
- '3000:3000'
environment:
- DATABASE_URL=${DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/api/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stoppedThe start_period gives your app time to actually boot before the health check starts failing and triggering restarts. Next.js apps aren't slow to start, but if you're connecting to a database on startup and waiting for connection pool initialization, you need a few seconds of grace.
Always add a health check. Not because things go wrong often, but because when they do, you want your platform to know immediately and restart the container — not serve 500s for 10 minutes while you're asleep.
Caching and Build Performance
Docker layer caching is your friend, but only if you structure your Dockerfile to use it correctly. The key insight: copy your package files and install dependencies before copying your source code. Your source changes constantly; your dependencies don't. If you copy everything first and then run npm ci, every single code change invalidates the dependency cache.
In CI, you'll want to push cache explicitly. Here's what we use with GitHub Actions:
# .github/workflows/docker-build.yml
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_APP_URL=${{ vars.APP_URL }}The type=gha cache stores Docker layers in GitHub Actions cache. First build is full speed; subsequent builds skip unchanged layers. On a typical Next.js app, this takes a build from 4-5 minutes down to under a minute if only source files changed.
Where to Actually Run the Container
You've got a container. Now what? Depends on your constraints and budget. Here's our honest take:
- Railway: Closest to Vercel DX but container-based. Point it at your repo or registry, set env vars, done. Pricing is usage-based and predictable. We use this for most client projects that can't use Vercel.
- Fly.io: Great for apps that need global distribution or WebSocket support. Slightly more config overhead but the flyctl CLI is excellent. Volumes for persistent storage.
- AWS ECS Fargate: What you reach for when the client has an AWS account and a security team. More setup, but it's the 'enterprise-approved' option.
- Hetzner VPS + Coolify: Embarrassingly cost-effective. €5/month VPS, Coolify for the deployment UI and reverse proxy, and you're running containers like a proper adult. We've shipped real production apps this way.
- DigitalOcean App Platform: Underrated. Detects your Dockerfile automatically, handles SSL and scaling. Good middle ground between VPS and fully managed.
If you're building on top of a Next.js template from peal.dev, the Dockerfiles and deployment configs come pre-configured for standalone output — so you're not starting from scratch every time a client needs self-hosted deployment.
Common Issues and Quick Fixes
Things that will bite you and how to handle them:
- Font files 404ing: Public directory isn't served by the standalone server. Copy it explicitly in your Dockerfile: COPY --from=builder /app/public ./public
- Sharp not working for image optimization: Alpine + Sharp needs special handling. Either add --platform=linux/amd64 to your build or use the node:20-slim base image instead of alpine
- Container starts but app crashes immediately: Almost always a missing environment variable. Add the Zod validation pattern above and the error will be obvious
- Slow first requests after cold start: Next.js lazy-loads route handlers. First hit to each route compiles it. Consider warming your container up after deploy by hitting key routes
- File uploads failing: If you're writing to the filesystem, it won't persist across container restarts. Use S3 or similar for any file storage
The standalone output + multi-stage Dockerfile combo solves 90% of Docker+Next.js friction. Everything else is environment variable management and infrastructure choices.
Containerizing Next.js isn't hard once you know the patterns, but it's definitely not as hands-off as pushing to Vercel. The payoff is real though: you control the infrastructure, you're not locked into one vendor's pricing, and you can deploy the same container image to a €5 VPS or a Kubernetes cluster with 50 replicas. That flexibility is worth the initial setup cost, especially once you've automated the CI/CD pipeline and it all just works on every push.
