Standalone Output Mode
Set output: 'standalone' in next.config.ts to produce a minimal Node.js bundle. Copy it into a Docker image, add your public/ and .next/static folders, and ship.
Self-hosting with Docker
While serverless hosting platforms like Vercel provide simplicity, certain production workloads require hosting Next.js on dedicated infrastructure—such as AWS ECS, DigitalOcean App Platform, Kubernetes, or private enterprise servers. Self-hosting Next.js gives you full control over server resources, removes gateway execution timeouts, and minimizes hosting bills at scale. To run Next.js in containerized environments, you must configure Next's standalone build mode to produce lightweight Docker images. In this lesson, we will cover how to enable standalone outputs, write a multi-stage Dockerfile, configure node user permissions, and resolve runtime static asset mapping errors.
1. Standalone Output Mode: Shrinking Your Image Size
By default, a Next.js build contains source files, page components, and developer dependencies required for configuration checks. Copying this complete folder directly into a Docker container produces images exceeding 1GB in size, which slows down container deployment cycles.
Next.js solves this with **Standalone Output Mode**. When enabled, the compiler trace analyzes your code import graph and copies only the minimal files required to run a production server—including required Node modules—into a single folder at .next/standalone/.
Enable standalone output inside your configuration file:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Instruct Next.js to compile a minimal standalone node server folder
output: 'standalone',
};
export default nextConfig;
2. Writing a Production-Grade Multi-stage Dockerfile
A multi-stage Dockerfile splits compiling into logical build steps. By discarding node compilers, package lock files, and dev-dependencies in final run stages, we compile final images under 150MB.
Create a file named Dockerfile in your project root:
# Dockerfile
# Stage 1: Install dependencies only when package.json changes
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Rebuild the source code only when needed
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set build environment variable parameters
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV production
RUN npm run build
# Stage 3: Runner stage - Copy build files and start server
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV NEXT_TELEMETRY_DISABLED 1
# Create a non-root system user for security isolation
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy the standalone compiler build output
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
# Set container user to non-root
USER nextjs
EXPOSE 3000
# Start Next.js using Node.js directly
CMD ["node", "server.js"]
3. Why We Must Copy static and public Folders Manually
If you inspect the output of .next/standalone, you will notice it contains server.js and a minimal node_modules folder, but does not include your public/ asset files (like icons and logo SVGs) or the .next/static/ folder (which houses compiled client-side JavaScript bundles).
Next.js excludes these folders from standalone output because, in production architectures, static assets are served from a global CDN or reverse proxy (like Nginx or Cloudflare) instead of hitting the Node.js server.
If you do not plan to set up a CDN immediately, you must copy these folders manually into your Docker container (as shown in Stage 3 of our Dockerfile) to prevent your website from losing CSS styling or failing to load images.
4. Building and Running the Docker Image Locally
To compile and test the Docker container on your local machine, run these terminal commands:
# Build the image and tag it as 'progrumar-app'
docker build -t progrumar-app .
# Run the container mapping host port 3000 to container port 3000
docker run -p 3000:3000 --env DATABASE_URL="mysql://..." progrumar-app
5. Setting Up a Reverse Proxy (Nginx)
In a self-hosted environment, exposing the raw Node.js container directly to the web is insecure. You should route traffic through a reverse proxy like Nginx or Caddy to handle SSL/TLS certificate handshakes, enable Gzip compression, and buffer traffic spikes:
# /etc/nginx/conf.d/nextjs.conf
server {
listen 80;
server_name progrumar.com;
# Serve static assets directly from disk to bypass Node server load
location /_next/static/ {
alias /var/www/nextjs/.next/static/;
expires 365d;
access_log off;
}
# Proxy all dynamic requests to the running Docker container
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
6. Build-Time vs. Runtime Environment Variables
A major gotcha when self-hosting Next.js in Docker is managing environment variables:
- Build-Time Variables: Next.js compiles static pages and processes parameters prefixed with
NEXT_PUBLIC_during build time (Stage 2 in the Dockerfile). If you change these variables later at runtime inside your container settings, they will not update because they are already hardcoded into the compiled HTML/JS files. - Runtime Variables: Server-side secrets (like database connection strings, Stripe keys) are parsed dynamically on every server request. You can inject these safely inside your running Docker container settings.
7. Gotchas of Docker Root Permissions
By default, Docker containers run commands using the root administrative user. If an attacker finds a remote code execution vulnerability in your application dependencies, they will inherit root access to your container filesystem.
Fix: Always declare a custom non-root system user (like nextjs) and bind folder permissions before executing startup scripts, as shown in the Dockerfile configuration.
8. Common Gotchas
- Missing public and static Folders in Standalone: Forgetting to copy public assets and static CSS files into the Docker runner stage. The page will load but it will lack CSS styling and display broken image icons.
-
Forgetting next.config.ts output Configuration: Building a Dockerfile with standalone configurations without setting
output: 'standalone'innext.config.ts. The build succeeds, but the runner stage fails because the.next/standalone/server.jsfile does not exist.
Key Takeaways
- Configure
output: 'standalone'to produce a minimal, lightweight production Node server. - Use multi-stage Docker builds to keep final images small and secure.
- Copy
public/and.next/static/folders manually into the runner container. - Inject runtime environment variables during container launch, not build compilation.
- Expose Docker containers behind a secure reverse proxy like Nginx to manage SSL certs.
- Avoid running Docker containers as the root user for enhanced security.
Self-hosting with Docker gives you complete infrastructure flexibility. However, deploying new container tags manually on code updates is inefficient. In the next lesson, we explore **CI/CD with GitHub Actions**, building automated pipelines that run test suites and compile Docker containers automatically on push.