Deploying a Turborepo Monorepo: NestJS to Railway, Next.js to Vercel

Piotr Żarów

CEO at Dev and Deliver

2026-09-10

#Development

Time to read

6 mins

In this article

Introduction

Three code changes to make before going live

Deploying the API → Railway

Deploying the frontend → Vercel

FAQ

Closing: live and what's next

Share this article

Introduction

Two apps, two platforms, five errors. Here's the exact path from local to live.

apps/api goes to Railway - it has first-class monorepo support and reads a railway.json from the repo root, so there's no Dockerfile to maintain. apps/web goes to Vercel - it's built for Next.js, and the zero-config deploy works exactly as advertised once you understand one NEXT_PUBLIC_* gotcha.

Three code changes to make before going live

Before touching either platform, three things in the codebase needed fixing that only surface in production.

1. Why your CSP bakes 'localhost' into production

In next.config.ts, the Content Security Policy header had this:

1
connect-src 'self' http://localhost:3001

That worked locally, but it means the production build literally bakes localhost:3001 into the CSP header - and browsers block every API call. The fix mirrors the pattern already used for NEXT_PUBLIC_CAL_URL: derive the allowed origin from the env var at build time.

1
2
3
4
5
6
7
8
9
const apiOrigin = (() => {
    try {
        return new URL(
            process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001',
        ).origin;
    } catch {
        return 'http://localhost:3001';
    }
})();

Then use apiOrigin in the connect-src directive. Localhost never reaches production headers.

2. Rate limiting that actually keys on the client

apps/api had no rate limiting. Before exposing it to the internet we added @nestjs/throttler globally - 10 requests per 60 seconds, applied to all routes via a guard in app.module.ts:

1
ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }]),

The subtlety is what the limit is keyed on. @nestjs/throttler keys on the client IP, and the API always runs behind a reverse proxy - Railway's edge here, Caddy on the VPS in [article 6]. Behind a proxy, req.ip is the proxy's address, identical for every visitor, so the naive setup silently throttles the whole internet as one shared bucket: ten requests a minute from any single client and the waitlist form stops working for everyone.

Two pieces fix it. First, trust the proxy so Express resolves the real client from X-Forwarded-For:

1
2
// main.ts
app.set('trust proxy', 1);

Then key the throttler on that resolved req.ip rather than the default, which uses the leftmost X-Forwarded-For entry - the one a client sets and can rotate to dodge the limit:

1
2
3
4
5
6
7
8
9
10
11
12
// throttler-behind-proxy.guard.ts
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
    protected override getTracker(
        req: Record<string, unknown>,
    ): Promise<string> {
        return Promise.resolve(req['ip'] as string);
    }
}

// app.module.ts
providers: [{ provide: APP_GUARD, useClass: ThrottlerBehindProxyGuard }],

trust proxy alone would swap a global-lockout DoS for a trivial bypass; the guard is what makes the limit genuinely per-client. Setting it to 1 trusts exactly one hop - the reverse proxy - so the rightmost X-Forwarded-For entry (the one the proxy appends) is authoritative and spoofed leading values are ignored.

3. Security headers on the API

The web app already sends security headers from next.config.ts (§1). The API sent none and advertised its stack in X-Powered-By: Express. One line adds a baseline and drops the fingerprint:

1
2
// main.ts
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));

helmet's defaults set HSTS, X-Content-Type-Options: nosniff, and a referrer policy, and remove X-Powered-By. CORP is relaxed to cross-origin on purpose: the frontend reads the API from another origin, and CORS already governs who may read responses - CORP only decides whether the resource can be embedded as a subresource, which for a JSON API should not block the legitimate cross-origin fetch.

Deploying the API → Railway

Why Railway

We looked at Render and Fly.io. Railway won because it reads railway.json from the repo root with no manual UI config for build/start commands, and it handles monorepos without needing a custom Dockerfile. The $5 starter credit is enough to evaluate whether it fits before committing.

railway.json

1
2
3
4
5
6
7
8
9
10
11
12
13
{
    "$schema": "https://railway.com/railway.schema.json",
    "build": {
        "builder": "NIXPACKS",
        "buildCommand": "corepack enable && pnpm install --frozen-lockfile && pnpm turbo build --filter=@repo/api..."
    },
    "deploy": {
        "startCommand": "node apps/api/dist/main",
        "healthcheckPath": "/health",
        "restartPolicyType": "ON_FAILURE",
        "restartPolicyMaxRetries": 3
    }
}

Place this at the repo root. Railway picks it up automatically on every deploy.

A note on Nixpacks: the build logs say "Exporting to docker image format." That's expected - Nixpacks builds without a Dockerfile but packages the result as an OCI container image because that's how Railway runs everything. You don't need Docker for any of this.

Three deployment errors and their root causes

We hit all three of these in order. If you follow the same setup you will too.

Error 1: Cannot find module '/app/dist/main'

Railway runs from the repo root, which it mounts at /app. A start command of node dist/main resolves to /app/dist/main. But NestJS outputs to apps/api/dist/main, so the full path is /app/apps/api/dist/main.

Fix: use the full path from the repo root.

1
"startCommand": "node apps/api/dist/main"

Error 2: Cannot find module '@repo/types'

The build command pnpm --filter @repo/api build runs in isolation - it has no knowledge of the dependency graph. @repo/types declares "main": "./dist/index.js" in its package.json, but dist/ doesn't exist until it's built. The workspace build completes, finds @repo/types, tries to resolve ./dist/index.js, and fails.

The fix is the ... suffix in Turbo's --filter flag:

1
"buildCommand": "corepack enable && pnpm install --frozen-lockfile && pnpm turbo build --filter=@repo/api..."

--filter=@repo/api... means "this package and all of its local dependencies." Turbo resolves the graph, builds @repo/types first, then @repo/api. The dist/ directory exists by the time @repo/api needs it.

Error 3: Health check hanging - "Starting Container" forever

The service would sit in "Starting Container" for about five minutes, then Railway would restart it. No crash, no error - just hanging.

Railway sends an HTTP request to healthcheckPath to decide whether the container is healthy. We had healthcheckPath: "/", but AppController only maps GET /health. Every health check request got a 404, the container never passed, Railway kept restarting it.

Fix: point the health check at the actual endpoint.

1
"healthcheckPath": "/health"

Railway UI: step by step

  1. New Project → Deploy from GitHub repo → select your repo
  2. Railway may auto-detect apps/web and create a second service for it. Delete it: Settings → Danger Zone → Delete Service. Only the api service should remain.
  3. Variables tab - add:
    1
    2
    3
    NODE_ENV=production
    RESEND_API_KEY=re_...
    RESEND_AUDIENCE_ID=...
  4. Click Deploy. Railway reads railway.json automatically - no manual build or start command entry needed.
  5. Settings → Networking → Generate Domain → copy the URL. This becomes your NEXT_PUBLIC_API_URL.

One more note: Swagger is available at /docs locally (http://localhost:3001/docs) but is disabled in production. main.ts gates the DocumentBuilder block behind NODE_ENV !== 'production', so the docs endpoint never exists on Railway.

Deploying the frontend → Vercel

Step by step

  1. vercel.com → Add New Project → Import Git Repository → select your repo
  2. Vercel may detect the wrong framework. Because apps/api lives in the repo, we saw it suggest NestJS. Change this manually to Next.js.
  3. Set Root Directory to apps/web
  4. Add environment variables before clicking Deploy.

This last point matters more than it looks. NEXT_PUBLIC_* variables are baked into the JavaScript bundle at build time, not injected at runtime. If you deploy first and add the variables after, the built bundle has the literal string undefined compiled in. You'll need a full redeploy - not just a restart. Set them before the first deploy:

1
2
3
4
5
NEXT_PUBLIC_API_URL=https://your-api.up.railway.app
NEXT_PUBLIC_CAL_URL=https://your-cal-instance.com
NEXT_PUBLIC_CAL_LINK=your-cal-username
CONTENTFUL_SPACE_ID=your-space-id
CONTENTFUL_ACCESS_TOKEN=your-access-token
  1. Click Deploy.

Why you get a CORS error after deploying

Even with everything configured, the first live request from Vercel to Railway fails:

1
2
Access-Control-Allow-Origin header has a value 'http://localhost:3000'
that is not equal to the supplied origin

main.ts already has:

1
2
3
app.enableCors({
    origin: process.env.ALLOWED_ORIGIN ?? 'http://localhost:3000',
});

The fallback is localhost:3000, which is what Railway used when no ALLOWED_ORIGIN was set. Fix: go back to Railway → Variables → add: ALLOWED_ORIGIN=https://your-project.vercel.app

Then trigger a Railway redeploy manually - Railway doesn't auto-deploy on variable changes. If you add a custom domain to Vercel later, update this value and redeploy Railway again.

FAQ

Why does Railway say 'Cannot find module '/app/dist/main''?

Railway runs from the repo root, mounted at /app, but NestJS outputs to apps/api/dist/main. Use the full path from the repo root in startCommand.

Why can't my deployed API find '@repo/types'?

Because a filtered build runs in isolation and doesn't build local dependencies. Add the ... suffix - --filter=@repo/api... - which means "this package and all of its local dependencies", so Turbo builds @repo/types first.

Why does my Railway container hang on "Starting Container"?

The health check is hitting a path that doesn't exist. Railway requests healthcheckPath to decide the container is healthy; if that returns 404, it never passes and Railway restarts it in a loop. Point it at a route the app actually maps.

Why doesn't changing an environment variable update my Vercel site?

NEXT_PUBLIC_* values are inlined at build time. Changing the variable does nothing until you redeploy. Railway is similar: it doesn't auto-deploy on variable changes.

Is Swagger exposed in production?

No. main.ts gates the DocumentBuilder block behind NODE_ENV !== 'production', so /docs exists locally and never on the deployed API.

Closing: live and what's next

The demo is live at turborepobydnd-web.vercel.app. Vercel stays free indefinitely on the hobby tier. Railway runs on a $5 starter credit - enough for about 30 days of low traffic. After that, the API goes down until you move it.

If you're running multiple backend services, Railway's pay-as-you-go pricing adds up quickly. A Hetzner or DigitalOcean VPS at $4-6/month runs everything for less - and Oracle Cloud's Always Free tier runs it for nothing at all. That's the path Article 6 takes: a single VPS running the NestJS API with PM2 and Caddy, with deploys from a GitHub Action. The credit did run out, and the API did go down, which is what prompted it.

The full starter is on GitHub: github.com/DevAndDeliver/turborepo. Everything shown across this series - the monorepo scaffold, the landing page, Contentful integration, the Cal.diy embed, and the deployment config - is in the repo as it stood when each article was written.

Dev and Deliver is a software agency based in Kraków, Poland. We build production web and mobile products for startups and scale-ups. devanddeliver.com

Piotr Żarów

CEO at Dev and Deliver

Share this post

Related posts

Want to light up your ideas with us?

Kickstart your new project with us in just 1 step!

Prefer to call or write a traditional e-mail?