Deploy integrations: Vercel, Railway, Fly, and more
Drop-in recipes for uploading source maps and tagging releases as part of every deploy on the platforms vibecoders use.
Deploy integrations: Vercel, Railway, Fly, and more
The Netwarden CLI is great for ad-hoc uploads, but you do not want to remember to run it. The whole point of source-map upload is that it happens every time you ship, not the times you remember to. This page is a set of copy-paste recipes that wire upload-sourcemaps and releases create into the deploy pipelines of the platforms most teams ship to today.
Why this matters
When a runtime error fires in a browser, the stack trace points into your minified bundle: at e (main.4f8a2b.js:1:48211). That is unreadable. Source maps translate those positions back into the original file and line, so you can actually fix things.
Two things have to be true for that to work:
- The source map has to be uploaded to Netwarden.
- The release version the SDK reports at runtime has to match the release the source map was uploaded under.
If either is missing, you get raw minified frames in the dashboard. The SDK does not warn you about this — your build keeps succeeding, your deploys keep going out, and the day you actually need symbolication you find out you do not have it. The recipes below close that gap by making upload a deploy-time step.
What you need before starting
- A DSN for the project, copied from
/apps/[id]/settings. It looks likehttps://[email protected]/proj_xxx. - A release version scheme. Two good defaults: the short Git SHA (
git rev-parse --short HEAD) or theversionfield frompackage.json. Whichever you pick, the SDK'sinit({ release })call must produce the exact same string the CLI uploads under. @netwarden/sdkinstalled in your project (npm install @netwarden/sdk). The recipes invoke the CLI vianpx @netwarden/cli, which works whether the package is a runtime dep or a devDependency.
Two environment variables are referenced everywhere below: NETWARDEN_DSN and NETWARDEN_RELEASE. Set them once per platform; the recipe scripts read them.
Vercel
Vercel is the easiest case because if you are using Next.js, the bundler plugin does the right thing automatically — you just need to wire up two env vars.
1. Set environment variables
In the Vercel dashboard, go to Settings → Environment Variables for the project and add:
NETWARDEN_DSN— your project DSN, scoped to Production (and Preview if you want preview deploys symbolicated too).NETWARDEN_RELEASE— leave this unset. Vercel exposesVERCEL_GIT_COMMIT_SHAto every build, and the recipes below use it. Setting it manually means every deploy uses the same release string, which defeats the purpose.
2. Path A: Next.js with the plugin (recommended)
If your app is Next.js, wrap your config with withNetwarden:
js// next.config.js const { withNetwarden } = require('@netwarden/sdk/plugins/next'); module.exports = withNetwarden({ reactStrictMode: true, productionBrowserSourceMaps: true, }, { dsn: process.env.NETWARDEN_DSN, release: process.env.VERCEL_GIT_COMMIT_SHA, });
That is the entire integration. After every Vercel build, the plugin discovers .map files in .next/static/, uploads them, and registers the manifest. There is nothing to add to vercel.json.
Make sure the SDK's runtime init uses the same release string:
js// app/layout.tsx (or wherever you bootstrap the SDK) import { init } from '@netwarden/sdk/browser'; init({ dsn: process.env.NEXT_PUBLIC_NETWARDEN_DSN, release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, });
The NEXT_PUBLIC_ prefix is what makes Vercel inline the value into the browser bundle.
3. Path B: non-Next builds with a script
For Vite, Astro, Remix, or any other framework on Vercel, run the recipe script as part of the build command. In package.json:
json{ "scripts": { "build": "vite build && bash ./scripts/vercel-build-script.sh" } }
Drop vercel-build-script.sh into your repo (see recipes/vercel-build-script.sh in @netwarden/sdk). It reads NETWARDEN_DSN and VERCEL_GIT_COMMIT_SHA, then calls upload-sourcemaps and releases create against your built output.
Vercel's build environment is ephemeral but supports arbitrary post-build scripts. The build-output API is what powers the plugin path; the script path uses the older but equally reliable approach of running after vite build (or whatever your bundler is) finishes.
Railway
Railway uses Nixpacks by default, which honours package.json build scripts. The cleanest hook is the postbuild npm lifecycle script — Nixpacks runs it automatically after build.
1. Set environment variables
In the Railway dashboard, open Variables and add:
NETWARDEN_DSN— your project DSN.RAILWAY_GIT_COMMIT_SHA— Railway exposes this automatically; you do not set it. The recipe reads it directly.
2. Path A: postbuild script (recommended)
In your package.json:
json{ "scripts": { "build": "vite build", "postbuild": "bash ./scripts/railway-postdeploy.sh" } }
Drop the railway-postdeploy.sh recipe into ./scripts/. Nixpacks will invoke npm run build followed by npm run postbuild, and your source maps will upload as part of every successful deploy.
2. Path B: explicit railway.json
If you prefer to be explicit (or you are not using Nixpacks):
json{ "$schema": "https://railway.app/railway.schema.json", "build": { "builder": "NIXPACKS", "buildCommand": "npm run build && bash ./scripts/railway-postdeploy.sh" }, "deploy": { "startCommand": "npm start" } }
Both approaches end up doing the same thing. The postbuild lifecycle is slightly more portable because it survives if you switch builders later.
Notes
- Railway runs the build inside the deploy container, so
npx @netwarden/cliresolves against your project'snode_modules. If@netwarden/sdkis adevDependency, setNIXPACKS_NO_DEV_DEPS_PRUNE=trueso Railway does not strip it before the script runs. - Railway will redeploy on every push to your default branch by default. The recipe is idempotent — the platform dedups source maps by content hash, so re-running on the same commit is safe.
Fly.io
Fly's deploy model is Dockerfile-based. The natural place for source-map upload is after the image builds but before traffic shifts, which is exactly what [deploy] release_command is for.
1. Set secrets
bashfly secrets set NETWARDEN_DSN=https://[email protected]/proj_xxx
Fly does not natively expose a commit SHA inside the container, so add it at build time:
dockerfile# Dockerfile ARG GIT_SHA ENV NETWARDEN_RELEASE=$GIT_SHA
And pass it from CI:
bashfly deploy --build-arg GIT_SHA=$(git rev-parse --short HEAD)
2. Wire up release_command
In fly.toml:
toml[deploy] release_command = "bash /app/scripts/fly-release-command.sh"
Drop fly-release-command.sh into your repo at scripts/fly-release-command.sh and make sure your Dockerfile copies it into the image. The release_command runs in a one-off ephemeral machine that has access to your secrets and your built artifacts, then exits before the new release is rolled out to the live machines.
3. Multi-stage builds
Fly's standard pattern is a multi-stage Dockerfile: a builder stage that installs deps and runs the build, then a runtime stage that copies only the artifacts. The source-map upload needs the builder stage's node_modules and .map files, which the runtime stage usually does not carry.
The cleanest pattern is to upload during the build itself, before the artifact split. In your builder stage:
dockerfileRUN npm run build RUN bash /app/scripts/fly-release-command.sh
…and skip the [deploy] release_command entry. The trade-off: you need the DSN available at docker build time, which means passing it as a --build-secret rather than an env var. Pick whichever fits your secret-management story better.
Notes
release_commandhas a 5-minute timeout on most Fly plans. Source-map upload for a typical SPA finishes in under 30 seconds, but very large bundles (10+ MB of maps) on a slow link can brush against the limit. If you hit it, move the upload into the build step.- The release command runs once per deploy, not once per machine. You will not get duplicate uploads from Fly scaling out.
Cloudflare Pages
Cloudflare Pages is structurally similar to Vercel: ephemeral build environment, configurable build command, env vars set in the dashboard. The recipe is the same shape.
1. Set environment variables
In the Pages project settings, under Settings → Environment variables, add:
NETWARDEN_DSN— your project DSN.- Cloudflare exposes
CF_PAGES_COMMIT_SHAautomatically; the recipe reads it.
2. Build command
In Settings → Builds & deployments, set the build command to:
bashnpm run build && bash ./scripts/cloudflare-pages-build.sh
Drop cloudflare-pages-build.sh into the repo. It mirrors the Vercel script but reads CF_PAGES_COMMIT_SHA instead.
Caveats
- Cloudflare Pages' build environment ships with Node 18 by default. If your build needs a newer version, set
NODE_VERSIONas a build env var. The CLI itself supports Node 18+. - Outbound HTTPS to your Netwarden host is allowed from the build sandbox; no allow-listing is required.
- If you use Pages' Git integration, the script runs on every push to your watched branches. There is no separate "post-deploy" hook — everything happens during the build phase.
Netlify
Netlify supports two integration points: a postbuild npm script (same as Railway) or a Netlify Build plugin. The npm script is simpler and works for 95% of cases.
1. Set environment variables
In the Netlify dashboard, Site configuration → Environment variables, add:
NETWARDEN_DSN— your project DSN.- Netlify exposes
COMMIT_REFautomatically; the recipe reads it.
2. postbuild script
In package.json:
json{ "scripts": { "build": "vite build", "postbuild": "bash ./scripts/netlify-postbuild.sh" } }
Or, if you do not want to touch package.json, set the build command in netlify.toml:
toml[build] command = "npm run build && bash ./scripts/netlify-postbuild.sh" publish = "dist"
Drop netlify-postbuild.sh into the repo. Same shape as the Vercel and Cloudflare scripts.
Notes
- Netlify has a separate Build plugins API if you need anything more sophisticated (running on specific events, gating by deploy context). For a single source-map upload step, it is overkill.
COMMIT_REFis the full SHA, not the short form. That is fine for the release string as long as your runtime SDK init uses the full SHA too.
Generic CI (GitHub Actions, GitLab CI)
For everything else — self-hosted CI, GitHub Actions runners, GitLab CI, CircleCI, Drone — the pattern is the same: build, then call the CLI, then deploy.
GitHub Actions
yaml# .github/workflows/deploy.yml name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '20' - run: npm ci - run: npm run build - name: Upload source maps to Netwarden env: NETWARDEN_DSN: ${{ secrets.NETWARDEN_DSN }} run: | npx @netwarden/cli upload-sourcemaps \ --release=$GITHUB_SHA \ --path=./dist \ --dsn=$NETWARDEN_DSN - name: Tag release env: NETWARDEN_DSN: ${{ secrets.NETWARDEN_DSN }} run: | npx @netwarden/cli releases create \ --release=$GITHUB_SHA \ --commit=$GITHUB_SHA \ --dsn=$NETWARDEN_DSN - name: Deploy run: ./deploy.sh # whatever your deploy step is
GitLab CI
yaml# .gitlab-ci.yml deploy: image: node:20 script: - npm ci - npm run build - npx @netwarden/cli upload-sourcemaps --release=$CI_COMMIT_SHA --path=./dist --dsn=$NETWARDEN_DSN - npx @netwarden/cli releases create --release=$CI_COMMIT_SHA --commit=$CI_COMMIT_SHA --dsn=$NETWARDEN_DSN - ./deploy.sh only: - main
The pattern is portable: any CI runner that can run npx and reach your Netwarden host can do this.
Troubleshooting
The SDK is silent in production but builds succeed.
The DSN is unset. The SDK fails open by design — it would rather drop events than crash your app. Verify NETWARDEN_DSN is set in the build environment and that the runtime SDK init has its own DSN (often the public-key half via NEXT_PUBLIC_NETWARDEN_DSN or equivalent).
Stack traces are still minified in the dashboard.
The release string the SDK reports at runtime does not match what the CLI uploaded under. Open the affected event in the dashboard and check the release field; cross-check it against what upload-sourcemaps --release=... was given. They must be byte-for-byte identical, including any prefix like v1.2.3 vs 1.2.3.
npx @netwarden/cli upload-sourcemaps reports "no .map files found".
Source maps are not being generated. Common fixes:
- Next.js: set
productionBrowserSourceMaps: trueinnext.config.js. - Vite: set
build.sourcemap: trueinvite.config.js. - webpack: set
devtool: 'source-map'for production builds.
After rebuilding, confirm .map files exist in your output directory before re-running the CLI.
The --path is wrong.
The CLI scans recursively from --path for .map files. If your output directory is .next/, dist/, or build/, point --path at the right one. Most build tools log the output directory at the end of the build — check the build log if in doubt.
The sign endpoint returns 401.
The DSN's public key has been revoked or rotated. Generate a fresh DSN at /apps/[id]/settings and update the build env var. Old uploads under the previous DSN are unaffected.
Uploads succeed but the release does not appear in the dashboard.
The manifest registration failed silently after the file uploads succeeded — re-run releases create --release=<v> --dsn=<dsn> to register the release explicitly. The CLI is idempotent; re-running is safe.