czay.dev
Writing

Self-Hosting Next.js with Dokploy and Docker

When does it make sense to move off Vercel? Let's look at output standalone, multi-stage Dockerfiles, why database migrations belong in the entrypoint rather than the build step, and a single line of code that prevents SIGTERM from being swallowed.

Furkan ÖzaySeptember 2, 2026 · 7 min read

Vercel is the right choice for most projects: zero configuration, automatic previews, and a global edge network. This post isn't telling you to leave it. But if there comes a time when you have to—whether server costs have become unpredictable, your database needs to live on the same machine, or data must stay within local borders—this post explains what that migration actually looks like.

The following is what I encountered while self-hosting student.czay.dev on my own server. The easy part is writing the Docker image; the hard part is the container's startup sequence.

What does Dokploy do?

It's a self-hosted PaaS: you connect your Git repository, and it builds and deploys your image on every push. It uses Docker and Traefik under the hood—domain routing and Let's Encrypt SSL certificates are fully automated. Essentially, it does most of Vercel's job on a single VPS.

Explanation: What you gain, what you lose

Pros: Flat, predictable costs, having your database and app on the same network, full control over data residency, and no execution time limits.

Cons: No global edge network, scaling is on you, and server updates and backups are your responsibility. A single machine is a single point of failure.

1. output: "standalone"

Without this line, your image will unnecessarily weigh hundreds of megabytes.

TypeScript
// next.config.ts
const nextConfig = {
	output: "standalone" as const,
};

This tells Next.js to trace and bundle only the actually used node_modules, producing a self-contained directory under .next/standalone. You no longer need to copy the entire node_modules folder into your runner image.

If you're in a monorepo, you need one more line:

TypeScript
import path from "node:path";
 
const nextConfig = {
	output: "standalone" as const,
	outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};

If you skip this, your image will build successfully but crash at runtime with a "module not found" error. This happens because pnpm hoists dependencies to the repository root, while Next.js only looks inside the application directory by default.

2. Multi-stage Dockerfile

Three stages: dependencies, build, and runner.

Example: Dependency stage

dockerfile
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /repo
 
FROM base AS deps
# Copy only the manifest files first: if source code changes, this layer
# is served from cache and pnpm install does not run again.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
 
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --filter student...

Two details bring build times down from minutes to seconds:

  • Copy manifests first. Docker's layer cache checks for file modifications. If you copy your source code before running pnpm install, every single code change will invalidate the cache and trigger a full reinstall of all dependencies.
  • Use --mount=type=cache to persist the pnpm store across builds.

3. Runner image: small and unprivileged

dockerfile
FROM node:22-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
ENV PORT=3003
ENV HOSTNAME=0.0.0.0
 
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
 
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
 
USER nextjs
EXPOSE 3003

HOSTNAME=0.0.0.0 is a must. If it defaults to localhost, the server will only be accessible from inside the container, preventing Traefik from routing to it. This is the most common reason behind the infamous "container is running but I get a 502" error.

USER nextjs is equally important: running containers as root determines how severe a breach will be in case of an exploit.

Warning: An error that only shows up on a clean clone

My public/ folder was empty, and Git does not track empty directories. Everything worked locally because the folder existed; but on the clean clone on the server, the directory was missing entirely, causing the COPY instruction above to fail with a "not found" error. I added a single line to the build stage to fix this:

dockerfile
RUN mkdir -p apps/student/public

The delta between your local workspace and a clean clone explains a good portion of the bugs caught during deployment.

4. The real challenge: startup sequence

If your application has a database, when do you run your schema migrations?

Not during the build stage. You don't have database access during the build phase, nor should you; the same image should be deployable against any database instance. Furthermore, Dokploy's restarts, scaling, and rollbacks all reuse the same image—you cannot bake the database schema into the static image.

The right approach is a startup entrypoint script:

Example: docker-entrypoint.sh

Bash
#!/bin/sh
set -e
 
cd /app/apps/student
 
echo "[entrypoint] running schema migrations…"
node scripts/migrate.cjs
 
# These two do NOT fail the deployment
set +e
node scripts/seed-content.cjs --dagitim
node scripts/create-admin.cjs --dagitim
set -e
 
echo "[entrypoint] starting server…"
cd /app
exec node apps/student/server.js

All three decisions here are deliberate:

Which errors should fail the deployment? If the schema migration fails, the container should not start at all—running the app on an outdated schema will crash on the very first request, and the root cause will be harder to debug. But if content seeding or admin account creation fails, the deployment should proceed; an empty dashboard is still accessible, and both can be fixed manually later.

set -e / set +e is what enforces this separation: any error in the first block halts the script immediately, while errors in the second block are ignored.

Caution: Without exec, Dokploy will kill the container

The exec in the last line is not just cosmetic. Without exec, Node runs as a subprocess of the shell script, meaning Docker's SIGTERM signal goes to the shell and never reaches the Node process.

As a result, the stop request times out, and Docker kills the container using SIGKILL. Your app never shuts down gracefully during deployments—leaving open database connections and hanging requests in limbo.

exec replaces the process: Node becomes PID 1 and receives the signal directly.

5. Healthcheck must wait for migrations

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3003)+'/giris').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

--start-period=40s is critical: the application cannot respond while migrations are running. Without this grace period, Dokploy will flag the container as "unhealthy" and restart it, restarting the migrations, trapping you in an endless loop.

Requesting an actual page for the check is also deliberate: the root path might return a redirect, which doesn't prove the app is fully operational.

6. Environment variables: Build-time or runtime?

This distinction causes a lot more headaches in Docker.

Important: NEXT_PUBLIC_ is baked in at build time

Variables prefixed with NEXT_PUBLIC_ are injected into the client bundle at build time. Passing them to the container at runtime has absolutely no effect—the compiled JavaScript inside the image will stick to its old values.

You must pass these as build arguments (build args) in Dokploy. If you change them, you must trigger a full rebuild; a simple restart is not enough.

Conversely, server-side variables (DATABASE_URL, API keys) must be provided at runtime and should never make their way into the image.

This is why .dockerignore is so important:

plaintext
node_modules
**/node_modules
**/.next
**/.turbo
.git
 
# Environment files are not included in the image
.env
.env.*
**/.env
**/.env.*
!**/.env.example

If you copy your .env file into the image, your secrets will be baked into the image layers—meaning they persist in the registry history even if you delete the final image.

Checklist

Summary: Before launching

  • Is output: "standalone" enabled? What about outputFileTracingRoot if you are in a monorepo?
  • Is HOSTNAME=0.0.0.0 set? (Otherwise, you will get a 502)
  • Is the container running as root? (It shouldn't)
  • Are schema migrations running at startup or build time? (Should be at startup)
  • Does the last line use exec? (Otherwise, expect SIGKILL on every deploy)
  • Is --start-period long enough for your migrations to finish?
  • Are NEXT_PUBLIC_* variables passed as build args?
  • Is .env included in .dockerignore?
  • Do you have database backups set up? (This wasn't your job on Vercel, but it is now)

The last point is the most frequently overlooked. Moving to your own server cuts down on costs, but shifts all the responsibility onto your shoulders: updates, backups, and monitoring are now your job. If you don't have the bandwidth for this, staying on Vercel is actually cheaper—not in terms of dollars, but in terms of time.

Related post: Monorepo management with Turborepo