Skip to content
Back to Build Tutorials

Deployment Prep

Prepare the app for production: build optimization, env vars, health checks, and rollback.

Why

The first deploy is where 'works in dev' meets 'works in prod' — and the gap is always larger than expected. Production needs a optimized build (minified, code-split, tree-shaken), the right env vars (no dev defaults leaking), a health check for the load balancer, and a rollback strategy for when the deploy breaks. Without these, the first deploy is a 4-hour debugging session: the bundle is 5MB, the app connects to the dev DB, the LB marks it unhealthy because there's no health endpoint, and when it crashes there's no way back. Preparation turns the first deploy from a crisis into a 5-minute routine.

When

When the app is feature-complete for v1. Takes 2-3 h. Skip never — every app deploys eventually.

How

1. Run the production build and inspect the output
   - next build → check .next/static for bundle size; aim < 200KB initial JS
   - Use @next/bundle-analyzer to find heavy imports; code-split routes

2. Set all env vars in the platform (Vercel, Cloudflare, Fly)
   - DATABASE_URL, NEXTAUTH_SECRET, etc. — never commit .env to prod
   - Use a different DATABASE_URL for prod (not the dev DB)

3. Add a /api/health endpoint that checks DB + critical services
   - Return 200 if healthy, 503 if any dependency is down
   - The load balancer uses this to drain traffic from a bad instance

4. Set up a rollback mechanism: keep the previous deploy, swap on failure
   - Vercel: instant rollback to the previous deployment
   - Docker: keep the previous image tag, redeploy on health check failure

5. Do a staging deploy first and run the e2e suite against it
   - Never deploy to prod without a staging verification
   - Use the same build artifact for staging and prod (build once, deploy twice)

Example

// /api/health — checks DB + cache
export async function GET() {
  const checks = await Promise.allSettled([
    prisma.$queryRaw`SELECT 1`,           // DB
    fetch(env.REDIS_URL + "/ping"),         // cache
  ]);
  const healthy = checks.every((c) => c.status === "fulfilled");
  return new Response(healthy ? "ok" : "degraded", { status: healthy ? 200 : 503 });
}

// next.config.js — bundle analyzer in CI
const withBundleAnalyzer = require("@next/bundle-analyzer")({ enabled: process.env.ANALYZE === "true" });
module.exports = withBundleAnalyzer({ experimental: { optimizePackageImports: ["lucide-react"] } });

// Deploy script (build once, deploy to staging then prod)
// npm run build && vercel deploy --prebuilt --target staging && vercel promote staging

Common Problems

  • ⚠Deploying the dev DB URL to prod, so the app reads and writes dev data in production.
  • ⚠No health check, so the LB keeps sending traffic to a crashed instance until a human notices.
  • ⚠No rollback, so a bad deploy stays up for hours while someone figures out how to revert.
  • ⚠A 5MB initial bundle because no routes are code-split, so the landing page takes 8s to load.

Best Practices

  • ✓Build once and deploy the same artifact to staging and prod — eliminates 'works in staging, breaks in prod' from build differences.
  • ✓Set up automatic rollback on health check failure (Vercel, Cloudflare) — a bad deploy reverts in 60s, not 60min.
  • ✓Run Lighthouse in CI on the built site and fail if the performance score drops below 80.
  • ✓Keep a runbook for deploys: the command, the expected duration, the rollback command, and the on-call contact.