Deploy a Next.js + Express SaaS with Docker on Dokploy (or Railway)

Deploy a Next.js client and Express API from one monorepo with Docker: build stages, boot-time migrations, the worker, env vars and a Dokploy walkthrough.

MC
Martin Coll

A Next.js plus Express SaaS in one monorepo deploys as three containers built from two Dockerfiles: the API (apps/server/Dockerfile, default runner stage, port 3005), the web app (apps/client/Dockerfile, port 3004), and optionally a background worker built from the same server Dockerfile with --target worker. Both builds use the repository root as the Docker context, the API applies Prisma migrations when it boots, the web app needs its public URLs as build arguments, and behind a reverse proxy you must set TRUST_PROXY=1. This guide walks through that setup with useDeploy's real Dockerfiles, docker-compose.yml and railway.toml, then maps it to Dokploy. Railway is covered at the end.

What gets deployed

PieceSourcePortNotes
APIapps/server/Dockerfile, stage runner3005Express on Bun, GET /health
Webapps/client/Dockerfile3004Next.js output: 'standalone', started with node server.js
Workerapps/server/Dockerfile, --target workernoneBullMQ processor, no HTTP
Postgresmanaged or a Dokploy database5432DATABASE_URL
Redismanaged or a Dokploy database6379rate limits and BullMQ, optional to boot

Nothing here is Dokploy specific: the same images run on any Docker host.

The server image

apps/server/Dockerfile has five stages. The ones that matter for deployment:

  • deps copies package.json, bun.lock, turbo.json, every workspace's package.json, packages/ and the Prisma schema, then runs bun install --frozen-lockfile. The schema has to be there because the root postinstall runs prisma generate.
  • builder bundles the API with bun build.
  • runtime copies only what the API needs (no apps/client, no tests).
  • worker and runner both extend runtime. runner is the last stage on purpose, so a build with no --target still produces the API.

The runner's start command applies migrations, then replaces the shell with the server:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3005/health || exit 1

CMD ["sh", "-c", "echo '→ prisma migrate deploy' && bunx prisma migrate deploy && exec bun run dist/index.js"]

The && matters: if a migration fails the container exits instead of serving against a schema the code no longer matches. Only the runner migrates. The worker does not, so exactly one process owns schema rollout (Prisma also takes an advisory lock as a backstop). The Prisma migrate deploy docs say to run it as part of your CI/CD pipeline for staging, testing or production, and not from your laptop against a production database.

The worker has no HTTP, so no healthcheck

FROM runtime AS worker
HEALTHCHECK NONE
CMD ["bun", "run", "src/bootstrap/worker.ts"]

If the worker inherited the HTTP healthcheck, an orchestrator (the Dockerfile comment names Docker Swarm and Dokploy) would probe /health, get no answer, mark the task unhealthy and recycle it about every 70 seconds. HEALTHCHECK NONE is the fix, and the platform setting that matters is the build stage of the worker service: leave it empty and you build runner.

The client image

ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
RUN cd apps/client && bun run build

Next.js inlines NEXT_PUBLIC_* variables at build time. Setting them as runtime environment variables on the container does nothing for the browser bundle. Pass them as build arguments, and rebuild when they change. NEXT_PUBLIC_API_URL is the API's origin only, without /api/v1; each caller appends the prefix.

The final stage copies .next/standalone, the static files and public/, then runs node server.js as a non-root user. Because it is a monorepo, the standalone output keeps the workspace layout (.next/standalone/apps/client/server.js).

One variable is runtime only: INTERNAL_API_URL, used for server-to-server calls from the Next.js middleware. That code runs inside the container, where the browser-facing API URL may not be reachable, so on a shared Docker network you set it to the API's internal address, for example http://server:3005. If you leave it unset, the middleware falls back to NEXT_PUBLIC_API_URL, which is fine on a single host with public URLs.

Try it locally first

The repo's docker-compose.yml builds both images the way a platform would:

export DATABASE_URL=postgresql://...
export REDIS_URL=redis://...
export BETTER_AUTH_SECRET=$(openssl rand -hex 32)
docker compose up --build

It does not include Postgres or Redis; it expects managed ones. A database on your own machine is reachable from the containers as host.docker.internal, not localhost. The compose file passes DIRECT_URL to the API and defaults it to DATABASE_URL (DIRECT_URL=${DIRECT_URL:-${DATABASE_URL}}), so you only export it yourself when DATABASE_URL points at a pooler. The client waits for the server's healthcheck (depends_on: condition: service_healthy). If this works, the platform build will too. docker-compose.dev.yml is the all-in-one variant with databases and hot reload.

Environment variables

Zod rejects a missing DATABASE_URL, BETTER_AUTH_SECRET or BETTER_AUTH_URL (see apps/server/src/bootstrap/env.ts). CORS_ORIGINS, APP_URL and TRUST_PROXY have defaults that are wrong in production, and DIRECT_URL is required by the Prisma CLI that migrates at boot (Zod itself does not check it):

VariableValue
DATABASE_URLPostgres connection string
BETTER_AUTH_SECRETat least 32 characters, from openssl rand -hex 32
BETTER_AUTH_URLthe API's own public URL
CORS_ORIGINSthe web app's full origin, comma-separated if several
APP_URLthe web app's public URL, used in emails and redirects
TRUST_PROXY1 behind one reverse proxy
DIRECT_URLneeded by Prisma's CLI; same as DATABASE_URL unless you use a pooler

REDIS_URL is optional to boot, but without it rate limits are per-instance and background jobs do not run. If you use Polar with NODE_ENV=production, set POLAR_SERVER explicitly (sandbox or production); the server refuses to start otherwise rather than silently defaulting to the sandbox.

TRUST_PROXY deserves a sentence. It is the number of reverse proxies in front of Express. Behind Traefik (Dokploy), Railway or Cloudflare, if you leave it at its default of 0, every visitor appears to come from the proxy's IP and all visitors share one rate-limit bucket. Set TRUST_PROXY=1 on the API service. See Better Auth rate limiting for what else depends on the client IP.

Deploy on Dokploy

Dokploy builds applications from a Git repository or an image and fronts them with Traefik. The build type docs list the options; for this repo you want Dockerfile, which has three fields. Create one application per service:

API

  1. Source: your repository and branch.
  2. Build type Dockerfile: Dockerfile Path apps/server/Dockerfile, Docker Context Path . (the repo root, as the Dockerfile header requires), Docker Build Stage empty.
  3. Environment: the variables from the table above.
  4. Domain: host api.example.com, container port 3005, HTTPS on with Let's Encrypt. Per the domain docs, the port is used for internal routing by Traefik and is not published to the internet, and your DNS must point the host at the Dokploy server.

Web

  1. Same repository. Dockerfile Path apps/client/Dockerfile, Context Path ..
  2. Environment, as build-time arguments: NEXT_PUBLIC_API_URL=https://api.example.com and NEXT_PUBLIC_APP_NAME. Dokploy's docs recommend build-time secrets for anything sensitive, because arguments persist in the image; public variables like these are not secrets.
  3. Domain: host app.example.com, container port 3004, HTTPS on.

Worker (if you use background jobs)

  1. Same Dockerfile as the API, Docker Build Stage worker, the same environment as the API.
  2. No domain.

Order matters once: set the API's BETTER_AUTH_URL and CORS_ORIGINS to the final domains, then deploy the web app with the API URL as a build argument. If you change the API domain later, the web app needs a rebuild.

Verify

curl -i https://api.example.com/health   # 200

The deploy logs of the API should show → prisma migrate deploy before the server starts. GET /ready is the deeper check (it exercises dependencies) and is the one to alert on, while /health is only liveness. On a fresh install the web app redirects every route to /setup, a one-time wizard that creates the first platform admin, so complete it immediately after the first green deploy.

Deploy on Railway

The repo ships two config files. The root railway.toml points at the server Dockerfile with healthcheckPath = "/health", a 100 second timeout and ON_FAILURE restarts (10 at most). For the web service, set Config Path to apps/client/railway.toml. The shape is four services: API, web, and Railway's Postgres and Redis plugins; the DATABASE_URL=${{Postgres.DATABASE_URL}} reference syntax keeps variables linked to the plugin. bun run deploy:railway automates the variable wiring.

The first deploy fails, on purpose: Zod rejects a missing DATABASE_URL, BETTER_AUTH_SECRET or BETTER_AUTH_URL before the HTTP listener binds, and Railway's healthcheck reports the service as unavailable. A missing DIRECT_URL fails one step later, when the Prisma CLI loads the schema to migrate, and aborts the boot the same way. It succeeds after you set the variables and redeploy; the error message names the variable.

Common errors

SymptomCause
Web app calls localhost:3005 in productionNEXT_PUBLIC_API_URL set at runtime, not as a build argument. Rebuild.
Worker service serves HTTP and jobs never runBuild stage left empty, so Docker built runner (the API). Set it to worker.
Rate limiter blocks everyone at onceTRUST_PROXY is 0 behind a proxy.
Login works, session is lost after the redirectBETTER_AUTH_URL or CORS_ORIGINS do not match the real domains, or the cookie cannot cross origins.
Cannot find module '@app/contracts' at buildpackages/ copied after bun install, or the Docker context is not the repo root.
Boot fails with a Zod errorA required variable is missing. Read the message, it names it.

Frequently asked questions

Do I need Docker Compose on Dokploy?

No. Separate Application services from the Dockerfiles are simpler. Dokploy can also run Compose projects, but you would have to bring your own database services and labels.

Why is the Docker context the repo root?

The images need bun.lock, the root package.json and packages/* to resolve workspace:* dependencies. A context of apps/server would miss them.

Where do migrations run?

In the API container at boot, via prisma migrate deploy. The worker never runs them. If you scale the API to several replicas, Prisma's advisory lock keeps them from migrating at the same time.

Why is my NEXT_PUBLIC_ change not showing up?

Those values are baked into the JavaScript bundle at build time. Change the build argument and redeploy so the image rebuilds.

Can I run the worker inside the API process?

useDeploy keeps it separate so job load cannot starve HTTP requests, and so the worker can have no healthcheck. For a tiny deployment you can skip the worker, at the cost of background jobs not running.


useDeploy ships both Dockerfiles, the compose files, the Railway configs and the deploy guides this walkthrough is based on. See the Docker deploy docs and the Better Auth integration.