26 Oct 2026 · Docker & Infra
Next.js 15 Docker Compose & Caddy: Production SaaS Deployment
A production blueprint for running a Next.js 15 SaaS on Docker Compose with Caddy reverse proxy, zero-buffer SSE streaming, and standalone builds.
This is the production layout: Next.js 15 output: standalone, a multi-stage Alpine image, Docker Compose, Caddy as the public TLS terminator, and Postgres on the same internal network. The public internet never talks to port 3000.
AgenticPacks OSS ships the app side: Next, Compose, Postgres, Auth.js. Caddy, Terraform, and the Hetzner TLS path are in Pro. Clone the MIT app in a second tab while you follow this guide: Agentic-Packs/agenticpacks-oss.
HTTPS :443
|
+----+----+
| Caddy | Let's Encrypt / ZeroSSL
| auto-SSL|
| flush |
| -1 SSE |
+----+----+
| Docker bridge (no published 3000)
+------------+------------+
| |
+------+-------+ +------+-------+
| Next.js 15 | | PostgreSQL |
| standalone | | 5432 |
| node | | (internal) |
| server.js | +--------------+
| :3000 |
+--------------+
Why the naive image fails
A one-shot FROM node:22 plus COPY . . and npm run build typically lands you with:
- A 1.5GB+ image (
node_modules,.next/cache, source, and the toolchain all stay in the final layer). - Secrets in the image if
DATABASE_URL/AUTH_SECRETwere set withENVbeforenext build, or if.envwas copied in. - OOM during
next buildon a 4 to 5 euro VPS. Next 15 plus the type checker will getSIGKILLon a 2 to 4GB node with no swap.
output: standalone plus a three-stage Dockerfile fixes the first two. Swap plus sequential builds fixes the third.
next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
};
export default nextConfig;
That emits .next/standalone: a traced Node server plus the minimum node_modules. The final image copies that, not the whole repo.
Step 1: Multi-stage standalone Dockerfile
# Multi-stage production image (Next.js standalone).
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# NEXT_PUBLIC_* are inlined at `next build`. Pass them as build args.
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SUPPORT_EMAIL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SUPPORT_EMAIL=$NEXT_PUBLIC_SUPPORT_EMAIL
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
RUN addgroup --system --gid 1001 nodejs \
&& 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
CMD ["node", "server.js"]
Notes that matter:
deps/builder/runnerkeep compilers andnpm ciout of the running image. Expect about 150 to 250MB instead of 1.5GB.USER nextjs. Do not run as root.- Don't use
COPY .envinto the image. Runtime secrets belong in Composeenv_fileorenvironment. NEXT_PUBLIC_*must exist asARG/ENVbeforenpm run build. Changing a public URL later requires a rebuild. Server secrets (DATABASE_URL,AUTH_SECRET, webhook secrets) do not.
.dockerignore at minimum:
node_modules
.next
.git
.env
.env.*
*.tfstate
terraform/.terraform
Step 2: Environment variables, build-time vs runtime
Next 15 is hybrid. next build evaluates Server Components, prerenders what it can, and string-replaces process.env.NEXT_PUBLIC_* into both client and server bundles. Everything else is read when node server.js starts. For a deep dive on why build-time variables break client bundles, see our guide on Next.js 15 Docker environment variables.
| Variable | When it is baked | How to inject |
|---|---|---|
NEXT_PUBLIC_SITE_URL | Docker build time | Docker build.args / Compose |
NEXT_PUBLIC_* (analytics, public keys) | Docker build time | Docker build.args / Compose |
DATABASE_URL | Container startup | Runtime env_file / Compose environment |
AUTH_SECRET | Container startup | Runtime env_file / .env.production |
STRIPE_SECRET_KEY and webhook secrets | Container startup | Runtime env_file / .env.production |
HOSTNAME, PORT | Container startup | Dockerfile ENV or Compose |
If a NEXT_PUBLIC_* value is missing at build, the client bundle contains undefined forever for that image tag. Server secrets are different: Compose injects them when the container starts. Next still boots if DATABASE_URL is empty. Prisma or Stripe then fails on the first real request. Catching that earlier is application code, not Docker: at the top of the server, read process.env and call process.exit(1) if a required key is missing.
Compose wires both:
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
NEXT_PUBLIC_SUPPORT_EMAIL: ${NEXT_PUBLIC_SUPPORT_EMAIL}
env_file:
- .env
--env-file .env on docker compose build is what actually expands ${NEXT_PUBLIC_SITE_URL} into the build args. The env_file: argument in the docker on the service in the docker compose yaml file is runtime only.
Step 3: Production Compose, web, db, Caddy, healthchecks
Public ports: 80 and 443 only. Next.js and Postgres stay on the default Compose network.
services:
web:
image: app-web:prod
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
NEXT_PUBLIC_SUPPORT_EMAIL: ${NEXT_PUBLIC_SUPPORT_EMAIL}
restart: always
env_file:
- .env
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"]
interval: 15s
timeout: 5s
retries: 5
start_period: 40s
expose:
- "3000"
db:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
expose:
- "5432"
caddy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
DOMAIN: ${DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
web:
condition: service_healthy
volumes:
pgdata:
caddy_data:
caddy_config:
wget is not in the Alpine runner by default. Either add apk add --no-cache wget in the runner stage or healthcheck with node -e "fetch('http://127.0.0.1:3000').then(r=>process.exit(r.ok?0:1))". Avoid publishing 3000:3000 or 5432:5432 on a public VPS.
Tired of writing Compose files and setting up VPS firewalls?
The runnable Next.js 15 + Postgres container setup is open-source: Agentic-Packs/agenticpacks-oss.
For Hetzner Terraform with Caddy SSL already wired, see Compose, Caddy SSL, and Terraform in Pro.
Step 4: Caddyfile with zero-buffering SSE
Reverse proxies coalesce chunked responses so they can gzip larger packets. For LLM token streams (Vercel AI SDK toDataStreamResponse(), SSE from a Route Handler) that means the browser sits idle until the model finishes, then dumps the whole answer at once. We explain the exact buffering mechanics in Configuring Caddy for Next.js 15 SSE Streaming.
flush_interval -1 disables that buffer.
{
email {$ACME_EMAIL}
}
www.{$DOMAIN} {
redir https://{$DOMAIN}{uri}
}
{$DOMAIN} {
reverse_proxy web:3000 {
flush_interval -1
}
encode gzip zstd
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
}
Caddy resolves web via Docker DNS. ACME HTTP-01 needs port 80 reachable. If you sit behind Cloudflare, grey-cloud the hostname until the first cert is issued, then orange-cloud with SSL set to Full (strict).
Step 5: 2GB swap on a small Hetzner node
next build on a CX22 / CX23 / CAX11 will get SIGKILL without swap. Add 2GB before the first image build:
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
Confirm with free -h. Do not run two next builds at once on a 4GB node. Build images sequentially.
Step 6: One-command deploy and rolling replace
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [[ ! -f .env ]]; then
echo "Missing .env" >&2
exit 1
fi
# Sequential: one standalone build at a time on a small VPS.
docker compose --env-file .env build web
# Recreate only the app. Caddy keeps :443 up. In-flight HTTP/2
# connections drain onto the new container once it is healthy.
docker compose --env-file .env up -d --no-deps --wait web
docker compose --env-file .env up -d caddy
docker image prune -f
echo "Done."
--wait honors the healthcheck. Caddy's upstream is the Compose service name, so the new container inherits web without a DNS change. This is not multi-replica blue/green. It is a single-node rolling replace with TLS left standing. For true zero-downtime you need two app replicas behind Caddy. On a 5 euro node the script above is the honest version.
chmod +x deploy.sh
./deploy.sh
Where to Go From Here
You do not have to wire this infrastructure by hand. Pick a path based on where the project is.
Option A: Free open-source baseline (MIT)
If you want to clone this verified Docker setup and run it locally:
- GitHub: github.com/Agentic-Packs/agenticpacks-oss
- What is included: multi-stage standalone Dockerfile, Docker Compose, PostgreSQL 16, Auth.js v5, and single-plan ($19/mo) Stripe checkout with webhooks and a credit counter. No Caddy; that is Pro.
- Cost: free, MIT. Star the repo if this guide saved you a debugging session.
Option B: Ship with AgenticPacks Pro ($149 one-time)
If you are building a commercial AI app and would rather not spend a weekend on the VPS:
- Automated Hetzner provisioning: Run
terraform applyfor a hardened VPS, firewall, Caddy SSL, and Compose. - Production multi-agent core: LangGraph workflows, pgvector RAG, Redis queues.
- Usage & Unit Economics: Stripe subscriptions, webhooks, and real-time token-to-dollar cost ledger.
