Vercel makes deploying Next.js apps ridiculously easy until you get your first surprise bill. However, one viral post later, and you might be facing a anxiety-inducing invoice. A popular app serving millions of requests can easily cost thousands per month on Vercel, while the same traffic can run comfortably on a $20 VPS with Docker.
Beyond cost savings, Docker gives you complete deployment flexibility. Deploy to any cloud provider, use specific Node.js versions, set up custom caching strategies, or integrate with your company's existing infrastructure. Docker packages your Next.js app with everything needed to run consistently anywhere, eliminating deployment surprises while keeping your hosting costs predictable and under your control.
By the end of this guide, you will have learnt how to deploy nextjs to a Docker container and have it production-ready in no time.
Prerequisites
Before you start, make sure you have:
- A VPS running Linux (Ubuntu/Debian recommended).
- Docker and Docker Compose installed.
- A Next.js app (you can generate one with
npx create-next-app@latest
).
Setting next.js to standalone mode
Before writing our Dockerfile, your Next.js application needs one critical configuration change. Open your next.config.js
file and enable standalone output mode:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone'
}
module.exports = nextConfig
The standalone output tells Next.js to bundle everything needed to run your application into a single directory. This eliminates dependency resolution nightmares and dramatically reduces your container's final size.
Create a Production-Ready Dockerfile
For our tutorial, we'll be following Vercel's recommended multi-stage build dockerfile. Multi-stage build processes ensure we're separating build time dependencies from runtime dependencies which typically reduces image size, resulting in faster deployments and lower storage costs.
# syntax=docker.io/docker/dockerfile:1
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
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 --from=builder /app/public ./public
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 build process in this Dockerfile is designed for both speed and security. By copying only the package files first, it takes advantage of Docker’s layer caching, meaning future builds are much faster when dependencies haven’t changed. Using the lightweight node:20-alpine
base image keeps the build size small, which translates into quicker deployments.
Building and Testing Your Container
Build and run your container to verify everything works:
# Build the Docker image
docker build -t my-nextjs-app .
# Run the container
docker run -p 3000:3000 my-nextjs-app
Open http://localhost:3000
to verify your app loads correctly.
Production Optimizations
Now that our base image is up and running, we can optimize a bit further for a production environment. Firstly, we'll add a .dockerignore
file to the project root which will tell Docker not to include any files or folders listed in it when building images, similar to a .gitignore
file.
node_modules
.next
.git
.env.local
README.md
Dockerfile
Secondly, we'll add a restart policy either in your docker run
command or docker-compose.yml
file to always restart the container if it crashes. This is because the Dockerfile runs Next.js as PID 1, which means when the Node.js process exits, the entire container exits too:
docker run -d -p 3000:3000 --restart unless-stopped my-nextjs-app
Common Issues and Solutions
Static assets not loading:
Ensure your public
folder copies correctly in the Dockerfile. Consider using a CDN for production static assets.
API routes returning 404:
Verify your API routes are included in the build. Check that pages/api
or app/api
directories are copied properly.
Memory issues: Set Node.js memory limits for production containers:
ENV NODE_OPTIONS="--max_old_space_size=1024"
Container exits immediately:
Check the container's logs with docker logs my-nextjs-app
Shipping Your Image to GitHub Container Registry
Once your Docker image works locally, you'll want to store it somewhere your production servers can access. GitHub Container Registry (GHCR) is free for public repositories and offers generous storage for private ones.
First, build and tag your image for GHCR:
# Build with GHCR tag
docker build -t ghcr.io/yourusername/my-nextjs-app:latest .
# Login to GitHub Container Registry
echo $GITHUB_TOKEN | docker login ghcr.io -u yourusername --password-stdin
# Push the image
docker push ghcr.io/yourusername/my-nextjs-app:latest
You'll need a GitHub Personal Access Token with write:packages
permission. Create one in GitHub Settings > Developer Settings > Personal Access Tokens.
Using Your GHCR Image in Production
With the image created and pushed to GHCR, we can now use in a docker compose file. Our example below shows setting the nextjs app up as a service alongside a PostgreSQL database.
version: '3.8'
services:
app:
image: ghcr.io/yourusername/my-nextjs-app:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://postgres:password@db:5432/myapp
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=myapp
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
This kind of setup means you can run your nextjs app alongside any other services you need, such as a database, caching, or other apps completely independent of Vercel's platform and pricing.
Beyond the Basics
This Docker setup gives you complete control over your Next.js deployment. Not only does it run consistently across environments, but it also scales predictably, and costs a fraction of platform-as-a-service alternatives. More importantly, you own your deployment pipeline and can customize it however your project needs.
Managing Docker containers across multiple servers can get complex as you scale. If you're running production workloads and want the benefits of containerization without the operational overhead, Serversinc handles container orchestration, multiple environments, monitoring and more. Get all the cost savings of self-hosted Docker with the simplicity of a managed platform.