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.
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
| Piece | Source | Port | Notes |
|---|---|---|---|
| API | apps/server/Dockerfile, stage runner | 3005 | Express on Bun, GET /health |
| Web | apps/client/Dockerfile | 3004 | Next.js output: 'standalone', started with node server.js |
| Worker | apps/server/Dockerfile, --target worker | none | BullMQ processor, no HTTP |
| Postgres | managed or a Dokploy database | 5432 | DATABASE_URL |
| Redis | managed or a Dokploy database | 6379 | rate 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:
depscopiespackage.json,bun.lock,turbo.json, every workspace'spackage.json,packages/and the Prisma schema, then runsbun install --frozen-lockfile. The schema has to be there because the rootpostinstallrunsprisma generate.builderbundles the API withbun build.runtimecopies only what the API needs (noapps/client, no tests).workerandrunnerboth extendruntime.runneris the last stage on purpose, so a build with no--targetstill 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 buildNext.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 --buildIt 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):
| Variable | Value |
|---|---|
DATABASE_URL | Postgres connection string |
BETTER_AUTH_SECRET | at least 32 characters, from openssl rand -hex 32 |
BETTER_AUTH_URL | the API's own public URL |
CORS_ORIGINS | the web app's full origin, comma-separated if several |
APP_URL | the web app's public URL, used in emails and redirects |
TRUST_PROXY | 1 behind one reverse proxy |
DIRECT_URL | needed 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
- Source: your repository and branch.
- Build type Dockerfile: Dockerfile Path
apps/server/Dockerfile, Docker Context Path.(the repo root, as the Dockerfile header requires), Docker Build Stage empty. - Environment: the variables from the table above.
- Domain: host
api.example.com, container port3005, 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
- Same repository. Dockerfile Path
apps/client/Dockerfile, Context Path.. - Environment, as build-time arguments:
NEXT_PUBLIC_API_URL=https://api.example.comandNEXT_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. - Domain: host
app.example.com, container port3004, HTTPS on.
Worker (if you use background jobs)
- Same Dockerfile as the API, Docker Build Stage
worker, the same environment as the API. - 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 # 200The 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
| Symptom | Cause |
|---|---|
Web app calls localhost:3005 in production | NEXT_PUBLIC_API_URL set at runtime, not as a build argument. Rebuild. |
| Worker service serves HTTP and jobs never run | Build stage left empty, so Docker built runner (the API). Set it to worker. |
| Rate limiter blocks everyone at once | TRUST_PROXY is 0 behind a proxy. |
| Login works, session is lost after the redirect | BETTER_AUTH_URL or CORS_ORIGINS do not match the real domains, or the cookie cannot cross origins. |
Cannot find module '@app/contracts' at build | packages/ copied after bun install, or the Docker context is not the repo root. |
| Boot fails with a Zod error | A 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.