Self-hosting Next.js in Docker has become the gold standard for indie hackers and engineering teams looking to break free from costly managed cloud tiers. However, containerizing Next.js is notoriously prone to pitfalls: bloated 1.5GB image sizes, broken static file routes (/_next/static 404s), and missing automated HTTPS reverse proxies.
This battle-tested production architecture pairs Next.js in standalone output mode (producing lean ~120MB Alpine containers) with a hardened PostgreSQL 16 database, Redis cache, and Caddy v2 for automatic Let's Encrypt SSL.
This configuration spins up Next.js on port 3000, persistent PostgreSQL, Redis, and Caddy with automatic HTTPS termination and health checks.
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
- DATABASE_URL=postgresql://db_user:db_password@postgres:5432/app_db
- REDIS_URL=redis://redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
networks:
- app_network
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: db_user
POSTGRES_PASSWORD: db_password
POSTGRES_DB: app_db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U db_user -d app_db"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app_network
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks:
- app_network
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
networks:
- app_network
networks:
app_network:
driver: bridge
volumes:
pgdata:
redisdata:
caddy_data:
caddy_config:
Leverages Next.js output file tracing to copy only the files strictly needed by the server. Drops container size from ~1.5GB to under ~120MB, running under an unprivileged non-root user.
# --- Stage 1: Install Dependencies ---
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
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 npm install; \
fi
# --- Stage 2: Build Application ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Stage 3: Minimal Production Runner ---
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy static assets and traced standalone bundle
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"]
Ensure your next.config.js enables standalone output tracing. This creates the self-contained .next/standalone folder required by the Dockerfile:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
};
module.exports = nextConfig;
Replaces complex Nginx proxy configs with clean, automatic TLS certificate management and HTTP/3 support.
yourdomain.com {
encode gzip zstd
# Route all requests to the Next.js standalone container
reverse_proxy app:3000
# Security Headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
}
}