Skip to main content
Back to Blog
By Mahmut Jomaa6 min read

Self-Host a Next.js SaaS With Docker

Build a production Docker image for a standalone Next.js SaaS, provide runtime secrets safely and run Prisma or Drizzle migrations without coupling them to the image build.

Docker gives a Next.js SaaS a repeatable runtime that can run on a virtual private server, a container platform or an orchestrator. It is useful when you want control over the server environment, predictable long-running processes or infrastructure that is not tied to one Next.js hosting provider.

This guide uses a standalone Next.js repository rather than a monorepo. The examples match the structure used by Achromatic's current Prisma and Drizzle starter kits.

What the container should do

A production application image should:

  • install dependencies from the lockfile
  • build the application in a separate stage
  • contain only the standalone server and required static files at runtime
  • run as a non-root user
  • receive secrets at runtime rather than storing them in the image
  • expose a health-checkable HTTP port

Database migrations are a deployment concern. Do not connect to the production database or mutate its schema while building the image.

Prerequisites

You need:

  • Docker with BuildKit support
  • a Next.js application that builds successfully
  • Node.js 22.21.1 for parity with the current Achromatic kits
  • access to a PostgreSQL database
  • the environment variables required by your selected integrations

Run the normal checks before containerizing the application:

Terminal
npm ci
npm run typecheck
npm run lint
npm run test
npm run build

Fix an ordinary production build before debugging a Docker build.

Enable standalone output

Next.js can trace the files needed by the production server and place them in .next/standalone. Add output: 'standalone' to the existing next.config.ts object:

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'standalone'
  // Keep the rest of your existing configuration.
};

export default nextConfig;

Do not replace the rest of the kit's configuration with this shortened example. Add the property to the existing object so MDX, Content Collections, Sentry and other wrappers remain intact.

Next.js documents standalone output as a minimal server deployment option. The generated server.js does not copy public or .next/static automatically, so the Dockerfile copies those directories explicitly.

Create .dockerignore

Keep local dependencies, build output, source control metadata and secrets out of the build context:

.dockerignore
.git
.github
.next
node_modules
coverage
playwright-report
test-results
npm-debug.log*
.DS_Store
.env
.env.*
!.env.example

The exception keeps the non-secret variable template available if the build process needs its shape. Verify that .env.example contains placeholders only.

Create the multi-stage Dockerfile

Add this Dockerfile at the repository root:

Dockerfile
FROM node:22.21.1-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1

FROM base AS dependencies
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci

FROM base AS builder
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

# Prisma only: generate the client before building.
# Remove this line from the Drizzle edition.
RUN npm run db:generate

RUN npm run build

FROM node:22.21.1-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"]

The comments mark the only ORM-specific build step. Prisma generates a client from prisma/schema.prisma. Drizzle does not require migration generation to build the application, so remove that RUN npm run db:generate line from the Drizzle image.

Do not use npm run db:generate || true. Ignoring a failed generation step can produce an image that builds incompletely and fails later.

Build the image

Build from the repository root:

Terminal
docker build --tag my-saas:local .

If application environment validation runs during npm run build, provide only the build-time values the application explicitly requires. Do not bake live secrets into the image with ARG or ENV. Build arguments and image layers are not an appropriate secret store.

For a platform that supports BuildKit secret mounts, use those for a value that is genuinely required during compilation. Prefer changing the application so server-only credentials are needed only at runtime.

Run locally

Create a local runtime environment file that is excluded from Git, then run:

Terminal
docker run --rm \
  --name my-saas \
  --env-file .env.production.local \
  --publish 3000:3000 \
  my-saas:local

Open http://localhost:3000 and test sign-in, authenticated navigation and any server route that reaches the database.

When the database runs on the host machine, localhost inside the container refers to the container itself. On Docker Desktop, use host.docker.internal in the development connection URL. In production, use the private hostname supplied by the database or container network.

Run migrations as a release step

The application image and database schema must move together, but they should not change in the same build operation.

For the Prisma kit, apply committed migrations with:

Terminal
npm run db:migrate

This maps to prisma migrate deploy. It applies pending migrations and does not create new ones.

For the Drizzle kit, generate and review migrations during development:

Terminal
npm run db:generate

Commit the generated files in lib/db/migrations/, then apply them in the release environment:

Terminal
npm run db:migrate

Use one of these production patterns:

  1. A CI release job runs migrations once before replacing application containers.
  2. An orchestrator runs a one-off migration task using the same release revision.
  3. A single-server deployment runs migrations explicitly before restarting the service.

Avoid running migrations independently in every horizontally scaled application container. Multiple replicas can start simultaneously and make deployment behavior harder to reason about.

Provide runtime configuration safely

The current kits validate environment variables. Use .env.example as the inventory, then store real values in the platform's secret manager.

Typical server-only values include:

  • DATABASE_URL
  • BETTER_AUTH_SECRET
  • Stripe secret and webhook credentials
  • Resend credentials
  • monitoring credentials

Variables prefixed with NEXT_PUBLIC_ are included in browser-facing bundles when referenced by client code. Never put a database password, Stripe secret key or authentication secret behind that prefix.

Different platforms inject secrets differently. Docker Compose can use an uncommitted env file for a private server. Cloud Run, ECS and managed container platforms provide dedicated secret integrations. Kubernetes commonly uses Secrets mounted as environment variables or files.

Add a health check

A container platform needs a route that confirms the HTTP process is ready. If the application already has a status endpoint, configure the platform to request it. Otherwise, add a minimal route that does not disclose configuration:

app/api/health/route.ts
import { NextResponse } from 'next/server';

export function GET(): NextResponse {
  return NextResponse.json({ status: 'ok' });
}

An HTTP-only check confirms the application process is serving requests. A deeper readiness check may verify required dependencies, but it should use a short timeout and avoid causing meaningful database load.

Put a proxy in front of the container

On a virtual private server, terminate TLS with a reverse proxy such as Caddy, Traefik or Nginx. The proxy should:

  • obtain and renew the HTTPS certificate
  • forward the original host and protocol headers
  • redirect HTTP to HTTPS
  • impose sensible request-size and timeout limits
  • send traffic only to healthy application instances

Keep port 3000 private. Expose only the proxy's ports 80 and 443 to the internet.

Production checklist

Before sending customer traffic to the container:

  • build and scan the final image in CI
  • pin the Node image to the version used by the repository
  • run as a non-root user
  • inject secrets at runtime
  • apply committed database migrations once
  • configure HTTPS and the public application URL
  • update OAuth callback URLs and Stripe webhook destinations
  • verify email delivery from the production domain
  • configure logs, error monitoring and backups
  • test shutdown and rollback behavior

Common failures

server.js is missing

Confirm output: 'standalone' is present in the existing Next.js configuration and that the builder completed npm run build.

Static assets return 404

Confirm both public and .next/static are copied into the runner stage. The standalone server does not copy them for you.

The database is unreachable

Check the hostname from inside the container, the database firewall and TLS requirements. Do not use localhost unless PostgreSQL runs in the same container, which is not recommended for production.

The build asks for secrets

Review the environment validation and any build-time data fetching. Keep server credentials at runtime where possible. Never solve the problem by committing an environment file or placing a live secret in the Dockerfile.

A deployment starts before migrations finish

Make the release pipeline wait for the one-off migration command to succeed before directing traffic to the new application revision.

Where to deploy

The same image can run on a VPS, Fly.io, Railway, Render, AWS ECS, Google Cloud Run, Azure Container Apps or a Kubernetes cluster. The image is portable, but the operational responsibilities are not. Compare managed TLS, health checks, secret storage, rollback support, persistent logs and database networking before choosing the cheapest compute price.

For kit-specific details, continue with the Prisma Docker documentation or Drizzle Docker documentation. For a wider deployment decision, read the production deployment guide.