Railway Deployment for Persimmon Projects
CRITICAL — Read Before Touching Railway
- Next 16 prerenders by default. Any page that reads the DB or
auth()MUST exportconst dynamic = "force-dynamic". Otherwise the build container has no DB network path and the build crashes. - Default container port is 8080, not 3000. The custom domain
targetPortmust match. - Railway writes config into per-environment drafts. Older drafts can revert newer mutations if committed. When automation silently stops,
Settings → Source → Disconnect → Reconnectdrains the draft. - Managed buckets stay staged after API creation until the dashboard "Deploy" is clicked once. After the first manual deploy, automation works.
- NextAuth v5 behind Railway's edge requires
trustHost: trueinauth.tsandx-forwarded-hostin middleware redirects. Otherwise prod users bounce to*.up.railway.app.
If any of these surprise you, stop and re-read them before continuing.
0. Prerequisites
# Install CLI
npm i -g @railway/cli
# Authenticate (opens browser)
railway login
# Verify
railway whoami
You must be a member of the Persimmon-Automation-Labs team in Railway. Ask the project lead to invite you if railway list doesn't show the team's projects.
1. Create a Railway Project
Do this from the local repo root.
cd <repo-root>
railway init
Prompts:
- Team:
Persimmon Automation Labs - Project name:
<client>-web(e.g.,piccino-web,aslan-web). Never generic names. - Environment:
production(default). Staging is added later as needed.
This writes .railway/project.json — commit this to git. Subsequent CLI calls auto-resolve the project from it.
Verify:
railway status
2. Link GitHub Repo for Auto-Deploy
Do this in the Railway dashboard the first time. CLI alone doesn't wire webhooks reliably.
- Open the Railway project in the dashboard.
- Click the service → Settings → Source → Connect Repo.
- Select the Persimmon-Automation-Labs GitHub org and the repo.
- Production branch:
main. - Root directory: leave blank unless the repo is a monorepo.
- PR previews: enable (creates an ephemeral environment per PR, inherits production env vars by default — override sensitive ones).
Verify the webhook by pushing a trivial commit to main and watching the Deployments tab pick it up within ~30 seconds.
If auto-deploy silently stops after a repo transfer
This is the serviceConnect / deploymentTriggerUpdate drift bug. Fix:
- Settings → Source → Disconnect.
- Click Deploy on the dashboard to flush the staged draft.
- Settings → Source → Connect Repo again, pointing to the new location.
- Push a commit to main, confirm a new deploy triggers.
Do NOT just update the trigger via API — the draft baseline will revert your live config the next time it commits.
3. Provision Postgres
railway add
Select PostgreSQL. Railway spins up the DB and auto-injects DATABASE_URL into every service in the same environment via reference variables.
DO NOT copy the literal URL into .env.production. Use Railway's service reference so password rotation is transparent:
DATABASE_URL=${{Postgres.DATABASE_URL}}
Add the pgvector extension (required for any RAG project):
railway connect Postgres
Inside the psql session:
CREATE EXTENSION IF NOT EXISTS vector;
\q
Then push the Prisma schema:
railway run npx prisma db push
railway run executes locally but injects the service's env vars, so DATABASE_URL resolves to the Railway Postgres.
4. Provision a Tigris Managed Bucket
Railway's managed Tigris integration is S3-compatible. Creation order matters:
- Dashboard → + New → Database → Tigris (or
railway addif the CLI exposes it — as of writing, use the dashboard). - Click Deploy once in the dashboard. The bucket will not provision until this manual deploy commits the staged config. Skipping this step means every
@aws-sdk/client-s3call willNoSuchBucketfor hours with no obvious cause. - Railway injects these into the app service:
TIGRIS_ACCESS_KEY_IDTIGRIS_SECRET_ACCESS_KEYTIGRIS_ENDPOINT_URL_S3(e.g.,https://fly.storage.tigris.dev)BUCKET_NAME
- Configure CORS on the bucket via the
infra-s3-uploadsskill — Railway does NOT do this for you.
5. Env Vars — File-Based Sync
Manage env vars via a committed-but-gitignored .env.railway file so the state is reproducible.
Pattern
# .env.railway (gitignored)
# Server-side only. Never prefix with NEXT_PUBLIC_.
ANTHROPIC_API_KEY=sk-ant-xxx
NEXTAUTH_SECRET=<openssl rand -base64 32>
NEXTAUTH_URL=https://sistema.client.com.br
NEXTAUTH_TRUST_HOST=true
NODE_ENV=production
# Reference variables (do NOT hardcode — Railway resolves these)
DATABASE_URL=${{Postgres.DATABASE_URL}}
TIGRIS_ACCESS_KEY_ID=${{Tigris.TIGRIS_ACCESS_KEY_ID}}
TIGRIS_SECRET_ACCESS_KEY=${{Tigris.TIGRIS_SECRET_ACCESS_KEY}}
TIGRIS_ENDPOINT_URL_S3=${{Tigris.TIGRIS_ENDPOINT_URL_S3}}
BUCKET_NAME=${{Tigris.BUCKET_NAME}}
Sync to Railway
# Set one at a time
railway variables --set "ANTHROPIC_API_KEY=sk-ant-xxx"
# Bulk load from file (recommended)
set -a; source .env.railway; set +a
while IFS='=' read -r key value; do
[[ "$key" =~ ^#.*$ || -z "$key" ]] && continue
railway variables --set "$key=$value"
done < .env.railway
Copy between environments (prod → preview)
railway variables --environment production --kv > prod.env
railway variables --environment preview --set-from-file prod.env
# Then manually override sensitive vars for preview (e.g., ANTHROPIC_API_KEY with a separate dev key)
Gitignore rule
Add to .gitignore:
.env.railway
.env.railway.*
6. Build & Start Commands
Set these in the service Settings → Build and Settings → Deploy. Or commit them in railway.toml (preferred).
Build command
npx prisma generate && npm run build
prisma generate MUST run before next build. Otherwise Next's typecheck fails on missing @prisma/client types.
If you use prisma db push on deploy (for small projects without proper migrations):
npx prisma generate && npx prisma db push --accept-data-loss && npm run build
--accept-data-loss is required for non-interactive runs. Only use this pattern if you've decided per-project that migrations are overkill. Otherwise run prisma migrate deploy in a separate Railway one-off job.
Start command
npx next start -p $PORT
$PORT is 8080 on Railway, not 3000. Hardcoding -p 3000 makes the container healthcheck fail and Railway marks the deploy as "Crashed" within 2 minutes.
7. railway.toml Template
Commit this at the repo root. Settings here override the dashboard on every deploy, so the dashboard and the file must not drift.
# railway.toml — Persimmon Next.js 16 default
[build]
builder = "NIXPACKS"
buildCommand = "npx prisma generate && npm run build"
watchPatterns = ["**/*.ts", "**/*.tsx", "prisma/schema.prisma"]
[deploy]
startCommand = "npx next start -p $PORT"
healthcheckPath = "/api/health"
healthcheckTimeout = 30
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3
numReplicas = 1
[deploy.multiRegionConfig]
# Single-region until client needs otherwise. us-east4 is cheapest latency for Brazilian clients on Railway.
"us-east4-eqdc4a" = { numReplicas = 1 }
Add a /api/health route that returns 200 without touching the DB:
// src/app/api/health/route.ts
export const dynamic = "force-dynamic";
export async function GET() {
return Response.json({ ok: true, ts: Date.now() });
}
Health checks that hit the DB will create a thundering herd on cold start — keep them dumb.
8. Custom Domain
railway domain
Prompt: Add custom domain → enter e.g. sistema.client.com.br. Railway gives you a CNAME target like hdg8e7wz.up.railway.app.
At the DNS provider (usually Registro.br or the client's DNS panel):
sistema.client.com.br CNAME hdg8e7wz.up.railway.app. 300
Railway provisions a Let's Encrypt cert within ~2 minutes of CNAME propagation. Verify with:
curl -I https://sistema.client.com.br
# Expect HTTP/2 200 and valid cert chain
If the domain shows *.up.railway.app instead of itself
Check middleware — the redirect URL must use x-forwarded-host:
// middleware.ts
const host = req.headers.get("x-forwarded-host") ?? req.nextUrl.host;
const proto = req.headers.get("x-forwarded-proto") ?? "https";
return NextResponse.redirect(new URL("/login", `${proto}://${host}`));
Also check auth.ts:
export const authConfig = {
trustHost: true, // REQUIRED on Railway
// ...
};
And NEXTAUTH_URL must be the custom domain, not the Railway internal URL.
9. dynamic = "force-dynamic" Rule
This is the single biggest cause of Railway build failures on Next 16 projects.
Every page or route handler that reads the DB or session at request time needs:
// src/app/(authed)/dashboard/page.tsx
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const session = await auth();
const stats = await db.process.count({ where: { userId: session.user.id } });
return <Dashboard stats={stats} />;
}
Pages that are safe WITHOUT force-dynamic:
/login— static, no auth call/api/health— but still add it for safety- Purely presentational pages with no
auth()ordb.*
Audit command
Before pushing, run this grep to find unprotected DB/auth pages:
grep -rL 'dynamic = "force-dynamic"' src/app \
| xargs grep -l 'from "@/lib/db"\|from "@/lib/auth"' \
| grep -v node_modules
Any file printed is a build-failure risk.
10. PR Previews
With PR previews enabled (step 2), every PR gets its own ephemeral environment at https://<pr-hash>-<service>-pr-preview.up.railway.app.
Env var inheritance
- Non-sensitive vars inherit from production by default.
- Sensitive vars (
ANTHROPIC_API_KEY,NEXTAUTH_SECRET, DB URLs to prod DB) — override in the preview environment so PRs don't hit prod data.
Shared preview DB pattern
Provision a second Postgres named Postgres-Preview. In the preview environment, set:
DATABASE_URL=${{Postgres-Preview.DATABASE_URL}}
This isolates preview writes from production.
CORS for previews
Every preview URL that uploads files must be added to the Tigris bucket CORS list. See infra-s3-uploads skill — use a wildcard like https://*-pr-preview.up.railway.app where supported, or accept the cost of updating CORS per long-lived preview.
11. Logs
# Tail app service logs
railway logs
# Specific service
railway logs --service piccino-web
# Build logs from last deployment
railway logs --deployment
Filter locally:
railway logs | grep -i "error\|prisma\|anthropic"
For persistent log inspection, use Railway's Observability tab — it retains 7 days on the Team plan.
12. Troubleshooting
Build fails: PrismaClientInitializationError: Can't reach database server
- Cause: A page is prerendering during build and trying to hit the DB.
- Fix: Add
export const dynamic = "force-dynamic"to the offending file. The build log names the file. Audit with the grep in §9.
Container crashes: Error: listen EADDRINUSE: address already in use :::3000
- Cause: Start command hardcoded
-p 3000. Railway already bound 8080 to the edge. - Fix: Change to
npx next start -p $PORT.
Container crashes: healthcheck failed after 30s
- Cause:
/api/healthdoes a DB roundtrip and the DB is warming up, OR the health path doesn't exist. - Fix: Make
/api/healthreturn 200 without external calls. Or raisehealthcheckTimeoutto 60 inrailway.toml.
Custom domain serves the *.up.railway.app cert
- Cause: DNS hasn't propagated, or the CNAME points to the wrong Railway target.
- Fix:
dig sistema.client.com.br CNAMEand verify it matches Railway's target exactly. Wait 10 minutes after a DNS change. Force re-issuance by removing and re-adding the domain.
Managed bucket returns NoSuchBucket
- Cause: Bucket was created via API but the dashboard "Deploy" was never clicked, so it's stuck in staged state.
- Fix: Open the dashboard, click Deploy on the bucket service, wait 1 minute, retry.
Browser upload fails net::ERR_FAILED with no server log
- Cause: Tigris CORS doesn't include the origin.
- Fix: See
infra-s3-uploadsskill. Add the origin and re-runPutBucketCorsCommand.
Auto-deploy stopped working after repo transfer
- Cause:
serviceConnect/deploymentTriggerUpdatedraft drift. - Fix: Settings → Source → Disconnect → Reconnect. Do not try to API-patch it.
Env vars reverted after a bucket creation
- Cause: A stale draft was committed, reverting your live var edits to the draft baseline.
- Fix: Disconnect/Reconnect to drain the draft, re-sync vars from
.env.railway.
Prisma client cache stale after db push
- Cause: The running container has the pre-push generated client.
- Fix: Redeploy. Locally: restart
npm run dev.
UntrustedHost error in prod logs
- Cause: NextAuth v5 without
trustHost: true. - Fix: Set
trustHost: trueinauth.tsconfig, also setNEXTAUTH_URLto the custom domain.
next start exits immediately on deploy
- Cause:
.next/wasn't produced — build silently skipped. Usually the build command is wrong ornext builderrored earlier. - Fix: Read the full build log, confirm
✓ Compiled successfullyappears, confirm the build command includesnpm run build(ornext build).
13. Post-Deploy Verification Checklist
-
curl -I https://<custom-domain>→ HTTP/2 200, valid cert -
/api/health→ 200 JSON -
/loginrenders - Signed-in user can reach a DB-reading page without 500
- File upload works end-to-end (if storage wired)
-
railway logsshows no recurring errors - Auto-deploy: push a trivial commit, confirm new deploy within 30s
- PR preview: open a PR, confirm preview env spins up
- Custom domain redirects preserve host (no
*.up.railway.appleaks)
Only mark the project "deployed" when every box is checked.