Full-Stack Questions

Deployment, CI/CD and Monitoring

Vercel deploys, Docker for Node, GitHub Actions, feature flags, safe rollouts and migrations, Sentry, OpenTelemetry and SLOs.

Lesson 2643 min read#deploy#monitoring

On this page

  1. Words used in this lesson
  2. The Vercel deployment model
  3. Docker for a Next.js app (built and tested)
  4. CI/CD with GitHub Actions
  5. Environments and feature flags
  6. Zero-downtime deploys, rollbacks and database migrations
  7. Error tracking with Sentry
  8. Logs, metrics, traces and OpenTelemetry
  9. Alerting and SLOs
  10. Common mistakes
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: shipping is part of the job. A good answer explains how code gets from a pull request to users safely (build once, test, deploy, watch, roll back in seconds), and how you know it is healthy (errors, logs, metrics, traces and an alert that wakes the right person).

At 4+ years, interviewers stop asking "have you used Vercel?" and start asking "your deploy broke checkout for 3% of users; what do you do in the first ten minutes?" This lesson gives you the vocabulary and the answers. It does not repeat the hands-on AWS steps. For those, read DevOps, Lesson 9 (Docker), Lesson 14 (CI/CD with GitHub Actions) and Lesson 17 (Monitoring with CloudWatch). For structured logs and correlation IDs, read Backend, Lesson 5. Here we focus on what is asked in the room: the Vercel model, a tested Docker setup, pipeline design, release safety, Sentry, OpenTelemetry and SLOs.

I ran the Docker build and the sample code in this lesson myself (Node 22, Next.js 16, Docker 29). Where I describe a Vercel feature, I only state what the platform documents; numbers such as time limits change, so check your plan.

Words used in this lesson

  • Deployment is one built, versioned copy of your app that is running somewhere. Every push can create a new one.
  • CI (continuous integration) is a habit and a tool that builds and tests every change automatically. CD (continuous delivery or deployment) carries a tested build to an environment, sometimes with a human click, sometimes without.
  • An environment is a named place the app runs, such as production, staging or preview, each with its own settings and data.
  • A container image is a read-only package with your app and everything it needs to run. A container is a running copy of an image. Think of the image as a recipe card and the container as the meal.
  • A rollback is going back to the previous good version. A rollout is moving traffic to a new version.
  • Observability means you can understand what a running system is doing from the data it emits. The three common kinds of data are logs, metrics and traces (below).
  • An SLI (service level indicator) is a number that measures user happiness, such as "the share of requests that succeed". An SLO (service level objective) is the target for it, such as "99.9% over 30 days". An error budget is the allowed failure left: 100% minus the SLO.

1. The Vercel deployment model

Vercel is a hosting platform that builds your repository on every push and serves the result from a global network. The ideas matter more than the buttons.

IdeaWhat it means
Immutable deploymentEvery build is a new deployment with its own URL. Nothing is edited in place.
Preview deploymentA push to any non-production branch (or a pull request) gets its own URL. Reviewers click it before merge.
Production deploymentA deployment that your production domain points to. Usually it is the build of your main branch.
Promote / rollbackProduction is just a pointer. You move the pointer to another deployment. No rebuild, so it takes seconds.
EnvironmentsProduction, Preview and Development by default, plus custom ones (for example staging). Each can have different variables.

Because deployments are immutable, a rollback is a pointer move, not a rebuild. That is the main reason Vercel feels safe. You can also build a preview, test it, and promote exactly that build to production, so what you tested is what you ship.

Environment variables: build time or run time?

An environment variable is a named setting, such as DATABASE_URL, that lives outside your code. Three rules decide most bugs:

  1. Server-only by default. A variable is only readable on the server unless its name starts with NEXT_PUBLIC_.
  2. NEXT_PUBLIC_ values are inlined at build time. The build replaces process.env.NEXT_PUBLIC_X with the literal text in the browser bundle. Changing the variable later changes nothing until you build again. This also means that one Docker image built once cannot serve different public values in different environments.
  3. Changes apply to new deployments. On Vercel, editing a variable does not change a deployment that already exists. You must redeploy.

A secret with the NEXT_PUBLIC_ prefix is not a secret. It is printed into a JavaScript file that every visitor downloads. Mark real secrets as sensitive on Vercel, so the value cannot be read back from the dashboard after you save it.

Use the VERCEL_ENV system variable (production, preview or development) to branch behaviour, for example to send analytics only in production. Do not use NODE_ENV for that: Vercel builds previews with NODE_ENV=production too.

Functions, cold starts and the edge

A serverless function is code that runs only when a request arrives, on a machine you do not manage. Your Route Handlers, Server Actions and server-rendered pages become functions on Vercel.

A cold start is the extra delay when a request needs a new instance that must start up, load your code and open connections. A warm start reuses an instance that is already running. Think of a food truck: the first order of the day needs the stove lit.

What reduces cold-start pain:

  • Smaller code to load. Avoid importing huge libraries on the hot path. Use dynamic import() for rare features.
  • Reuse instances. Vercel's Fluid compute lets one instance handle many requests at once and stay alive between them, so cold starts happen less often.
  • Put the function near the database. One request often makes several database calls. If the function runs in Frankfurt and the database is in Virginia, every call pays about 80 ms or more. Set the function region to match the database.
  • Pool connections. Many instances opening many database connections can exhaust the database. Use a pooler or the database's serverless driver. In Fluid compute, Vercel's attachDatabasePool helper makes sure idle pool clients are released before an instance is suspended.

The edge means running code on servers close to the user, in many cities. The old Edge Runtime had a small API (no full Node.js) and a quick start.

Do not tell an interviewer "use the edge for speed" as a rule. In current Next.js docs, export const runtime = 'edge' is marked deprecated, nodejs is the default, and the renamed proxy file (formerly middleware) is the place for fast redirects and rewrites. Edge code is fast to start but far from your database, so for data-heavy routes it is often slower overall. Choose by where the data lives.

ISR and caching on Vercel

ISR (Incremental Static Regeneration) serves a prebuilt page from cache, and rebuilds it in the background after a time you choose or when you ask. The full Next.js story is in Next.js, Lesson 5. The deployment questions are different:

  • On Vercel, the prebuilt page and the cache live in the platform, shared by all users, and revalidateTag / revalidatePath purge them for you.
  • Self-hosted, the default cache is on the local disk and in memory of each server instance. With three containers, revalidateTag on one container does not clear the other two. You need a shared cache handler (for example one backed by Redis) and tag coordination. The docs say this plainly.
  • Static files with a hash in the name get Cache-Control: public, max-age=31536000, immutable. Pages that read request data get private, no-cache, no-store, so a CDN will not keep them.

Version skew

Version skew is the mismatch when a browser still runs the old JavaScript while the server has the new version. Symptoms: a missing chunk file, or "Failed to find Server Action". Vercel's Skew Protection routes a client to the deployment it started on. Self-hosted, set deploymentId in next.config and use one shared NEXT_SERVER_ACTIONS_ENCRYPTION_KEY across all instances.

2. Docker for a Next.js app (built and tested)

Docker is a tool that builds container images and runs containers. The hands-on setup on a server is in DevOps, Lesson 9. Here is what an interviewer wants to hear: why each line exists.

I used output: "standalone" in next.config.ts. Standalone output makes next build write a small folder with a server.js and only the node_modules files the app really needs. Then I built this Dockerfile:

Shell
# syntax=docker/dockerfile:1# 1) deps: only the package files, so this layer is cached until they changeFROM node:22-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm npm ci# 2) build: compile the app (standalone output)FROM node:22-alpine AS buildWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .ENV NEXT_TELEMETRY_DISABLED=1RUN npm run build# 3) runtime: only what is needed to run, as a non-root userFROM node:22-alpine AS runtimeWORKDIR /appENV NODE_ENV=production \    NEXT_TELEMETRY_DISABLED=1 \    PORT=3000 \    HOSTNAME=0.0.0.0RUN addgroup -S app && adduser -S app -G appCOPY --from=build --chown=app:app /app/.next/standalone ./COPY --from=build --chown=app:app /app/.next/static ./.next/staticUSER appEXPOSE 3000HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \  CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"CMD ["node", "server.js"]

And a .dockerignore:

TEXT
node_modules.next.git.env*Dockerfilenpm-debug.log

What I observed when I ran it:

  • The container reported healthy after its start period, and /api/health returned {"ok":true}.
  • docker exec <id> id printed uid=100(app), so the process is not root.
  • The final image was about 330 MB on disk, of which about 240 MB is the Node base image. The app added roughly 90 MB. The build stage, with the compiler and all dev dependencies, is not in the final image.
  • I started the same image twice with -e APP_ENV=staging and -e APP_ENV=prod. The page showed the different value each time, because the page reads process.env at request time (it calls connection() first, so it is rendered per request). One image, many environments.
  • docker stop finished in about 0.2 seconds. node server.js is the main process, receives SIGTERM and exits. If you start the app through a shell script or npm start, the signal may never reach Node, and Docker waits 10 seconds and then kills it.
LineWhy it is there
Multi-stage (AS deps, build, runtime)Build tools stay out of the final image: smaller, fewer security holes.
Copy package*.json firstDocker caches each layer. Dependencies rebuild only when the lockfile changes, not on every code edit.
npm ciInstalls exactly what the lockfile says and fails if it disagrees. npm install may change versions.
.dockerignoreKeeps node_modules, .git and .env files out of the build context. Without it, a local .env can be copied into an image you push.
USER appIf the app is hacked, the attacker is not root inside the container.
HEALTHCHECKLets Docker (and some orchestrators) mark the container unhealthy.
alpineA small base. It uses musl instead of glibc, which rarely, but sometimes, breaks native modules. -slim (Debian) is the safe middle choice.

Never put a secret in a RUN, ENV or ARG line. Anything you put in a layer stays in the image history. Pass secrets at run time, or use a BuildKit secret mount for build-time needs. Also remember that NEXT_PUBLIC_* values are baked in at build, so pass those as build arguments.

Docker's HEALTHCHECK and a Kubernetes readiness probe are different. Docker's check only labels the container. Kubernetes uses a liveness probe ("restart me if I am stuck") and a readiness probe ("send me traffic only when I am ready"). Keep them separate: a slow database should make the app not ready, not dead.

3. CI/CD with GitHub Actions

GitHub Actions is GitHub's built-in automation. A workflow is a YAML file in .github/workflows/. It has jobs, and each job has steps that run on a runner (a fresh virtual machine). The AWS version of this pipeline is in DevOps, Lesson 14. This is the shape I would draw on a whiteboard (I parsed it to check it is valid YAML):

YAML
name: cion:  pull_request:  push:    branches: [main]concurrency:  group: ci-${{ github.ref }}  cancel-in-progress: truepermissions:  contents: readjobs:  test:    runs-on: ubuntu-latest    strategy:      fail-fast: false      matrix:        node: [22, 24]    steps:      - uses: actions/checkout@v6      - uses: actions/setup-node@v7        with:          node-version: ${{ matrix.node }}          cache: npm      - run: npm ci      - run: npm run lint      - run: npm test      - uses: actions/cache@v6        with:          path: .next/cache          key: next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}          restore-keys: |            next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-      - run: npm run build  deploy:    needs: test    if: github.ref == 'refs/heads/main'    runs-on: ubuntu-latest    environment: production    permissions:      id-token: write      contents: read    steps:      - uses: aws-actions/configure-aws-credentials@v6        with:          role-to-assume: ${{ vars.DEPLOY_ROLE_ARN }}          aws-region: eu-west-1      - run: aws sts get-caller-identity

What each part buys you:

  • concurrency with cancel-in-progress cancels the old run when you push again to the same branch. It saves minutes. Do not cancel a deploy job halfway; give deploys their own group without cancel.
  • permissions sets what the automatic GITHUB_TOKEN can do. Start with read-only and add only what a job needs. This limits the damage if a step is compromised.
  • A matrix runs the same job for many values (here, two Node versions) in parallel. fail-fast: false lets the others finish, so you see every failure, not only the first.
  • Caching. setup-node with cache: npm caches the npm download folder, keyed by the lockfile. The extra .next/cache cache is what Next.js documents for faster builds. The key uses the lockfile hash and the source hash; the restore-keys line falls back to the nearest older cache.
  • needs and if order the jobs and make deploy run only for main.
  • environment: production attaches the job to a GitHub Environment. You can require a reviewer to approve, and the environment holds its own secrets and variables.

Secrets and OIDC

A secret is a stored value, such as an API token, that GitHub hides in logs. Long-lived keys in secrets are a risk: if they leak, they work until someone notices.

OIDC (OpenID Connect) lets the workflow prove who it is without a stored key. GitHub issues a short-lived signed token for each run. The cloud (AWS, Google Cloud, Azure) checks that token against a trust rule such as "only repository acme/shop, environment production" and hands back temporary credentials. That is why the deploy job needs permissions: id-token: write. You store only a role name, which is not secret. A leaked role name is useless without a matching run.

Two common mistakes. First, a trust rule that allows every repository in your organization. Make it name one repository and one environment or branch. Second, secrets and pull_request from forks: secrets are not given to workflows from forked pull requests, and you should not work around that with pull_request_target plus a checkout of the fork's code. That pattern has caused real leaks.

Third-party actions run with your token. Pin important ones to a full commit SHA, not only to a moving tag such as v6, and let a bot (Dependabot) propose updates.

Build once, promote the same artifact

Do not rebuild for each environment. Build one artifact (a Docker image tagged with the commit SHA, or a Vercel deployment), then promote that same artifact from staging to production. If you rebuild, the production build can differ from the one you tested (a new transitive dependency, a different NEXT_PUBLIC_ value). The one exception, as seen above, is NEXT_PUBLIC_* values; this is why teams read public settings from a small /config endpoint at runtime if they need one image everywhere.

4. Environments and feature flags

A common setup is development (your laptop), preview (one per pull request), staging (a copy of production, shared) and production. The rules I follow:

  • Each environment gets its own database and its own secrets. A preview deployment that points at the production database is an incident waiting to happen.
  • Staging is only useful if it is similar to production: same Node version, same build, similar data shape. Not the same data. Never copy real personal data into lower environments without masking it.
  • Keep config in environment variables, not in if (env === "staging") branches all over the code.

A feature flag is a switch in config that turns a feature on or off without a new deploy. It separates deploy (code is on the servers) from release (users can see it). That is how you merge unfinished work daily and still ship safely.

A percentage rollout needs a stable bucket for each user: the same user must get the same answer every time. I ran this:

JavaScript
import { createHash } from "node:crypto";export function bucket(flag, userId) {  const h = createHash("sha256").update(`${flag}:${userId}`).digest();  return h.readUInt32BE(0) % 100;          // 0..99}export const isOn = (flag, userId, percent) => bucket(flag, userId) < percent;

For 10,000 users, 10% turned on 1,020 users and 50% turned on 4,933. Everyone who was on at 10% was still on at 50%, and the same user got the same answer on repeat calls. Hashing the flag name together with the user ID means different flags pick different 10% groups, so the same unlucky users are not always the guinea pigs.

Flags are debt. Each flag doubles the paths to test. Give every flag an owner and a removal date, delete the old branch of code after full rollout, and keep a kill switch for risky features. Do not evaluate a flag only in the browser: hide server work behind it on the server too, or users can call the hidden API directly.

5. Zero-downtime deploys, rollbacks and database migrations

Zero-downtime deployment means users do not see errors while a new version replaces the old one. Four common strategies:

StrategyHowTrade-off
RollingReplace instances one by oneCheap; old and new versions run together for a while
Blue-greenRun a full new copy (green), then switch traffic from the old one (blue)Instant switch and instant rollback; needs double capacity
CanarySend a small share (for example 5%) of traffic to the new version, watch, then increaseCatches problems with few users hurt; needs good metrics
RecreateStop old, start newSimple; has downtime

Vercel's immutable deployments behave like blue-green: the old deployment still exists after the switch, so rollback is quick. Vercel also has rolling releases for production, which move traffic in stages (such as 10%, 50%, then 100%) with approval steps.

For your own servers or containers, three things make a rolling update safe:

  1. A readiness check. The load balancer sends traffic only to instances that say they are ready.
  2. Graceful shutdown. On SIGTERM, the instance stops accepting work, finishes requests in flight, then exits. The step-by-step code is in Node.js and Express, Lesson 6. Next.js also finishes in-flight requests and pending after() callbacks when it gets SIGTERM or SIGINT, so give the platform a drain period (the Next.js docs suggest 10 to 30 seconds).
  3. Backward-compatible clients and servers, because for a short time the browser and server versions differ (version skew, above).

Rollback: what you can and cannot undo

Rolling back code is easy. Rolling back data is not. If the new version wrote rows in a new shape, going back to old code may break. So the rule is: make every change safe to roll back, or make it small enough to roll forward fast.

Database migrations in deploys

A migration is a versioned script that changes the database structure. With Prisma, the production command is prisma migrate deploy. It applies pending migration files, does not use a shadow database, does not check for drift, and does not reset anything. The Prisma docs say to run it from an automated pipeline, not from a laptop. It takes an advisory lock with a 10 second timeout, so two deploys do not migrate at the same time; if it times out, you run it again. It is not supported on MongoDB, where Prisma uses db push and there is no migration history, so schema changes there need your own care.

Where do you run it? Not in the container's start command. If five instances start together, they all try to migrate. Run it once, as its own pipeline step or a one-off job, before the new code goes live. That means the database must work with both the old and the new code. This is the expand and contract pattern:

  1. Expand: add the new column as nullable (or add a new table). Old code ignores it. Deploy the migration.
  2. Deploy new code that writes both old and new fields and reads the new one.
  3. Backfill old rows in small batches.
  4. Contract: after the old code is gone for good, make the column required and drop the old one, in a later deploy.

To rename a column safely, never run a plain rename in one step. That breaks the old code the moment it runs. Add the new column, copy, switch, and remove the old one later.

Adding an index or changing a column type can lock a big table and block writes. Test the migration on a copy with production-sized data, and for PostgreSQL use the non-blocking forms (for example CREATE INDEX CONCURRENTLY) when the table is busy.

6. Error tracking with Sentry

Sentry is a service that collects errors from your app and groups them into issues, with the stack trace, the browser, the user (if you set one), the release and the steps before the error (breadcrumbs). It also records performance traces. Compare with logs: a log is a line you wrote; Sentry is the safety net for what you did not expect.

Core terms:

  • A DSN is the URL that tells the SDK where to send events. It is not a secret (it ships to the browser), but you should still limit what it can do through project settings.
  • A release is a version label, usually the git commit SHA. Sentry uses it to say "this error started in release a1b2c3" and to show regressions.
  • An environment is a tag such as production or preview, so preview noise does not hide real problems. The default is production, so set it explicitly.
  • A source map is a file that maps the minified, bundled code in production back to your original files and line numbers. Without it, a stack trace says a.js:1:48210.

For Next.js, the @sentry/nextjs package wraps your config. Source maps are uploaded at build time using an auth token that lives in CI only. I checked these option names in the package's type definitions:

JavaScript
// next.config.tsimport { withSentryConfig } from "@sentry/nextjs";export default withSentryConfig(nextConfig, {  org: "acme",  project: "shop",  authToken: process.env.SENTRY_AUTH_TOKEN, // CI only, never NEXT_PUBLIC  widenClientFileUpload: true,              // also upload maps for dependencies (slower build)  tunnelRoute: "/monitoring",               // send events through your server to dodge ad blockers  sourcemaps: { deleteSourcemapsAfterUpload: true }, // default true: maps are not served to users});
JavaScript
// instrumentation-client.ts (browser)import * as Sentry from "@sentry/nextjs";Sentry.init({  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,  environment: process.env.NEXT_PUBLIC_VERCEL_ENV ?? "development",  tracesSampleRate: 0.1,   // keep 10% of traces  sampleRate: 1.0,         // keep all errors (the default)});
JavaScript
// instrumentation.ts (server)import * as Sentry from "@sentry/nextjs";export const onRequestError = Sentry.captureRequestError;

onRequestError is the Next.js hook that gets server errors from Server Components, Route Handlers and Server Actions; Sentry provides a ready function for it.

Sampling means keeping only a share of events to control cost and noise. Two knobs matter:

  • sampleRate applies to error events. Default is 1.0 (keep all). You rarely lower it.
  • tracesSampleRate applies to performance traces. Set it low on busy sites. If you also set tracesSampler (a function), the function wins and tracesSampleRate is ignored. Use the function to keep all checkout traces and drop health-check traces.

Source maps have two opposite risks. If you do not upload them, errors are unreadable. If you serve them publicly, anyone can read your original source. The standard answer: generate them, upload to Sentry in CI, and delete them from the deployed output (the option above does this).

Good Sentry hygiene: tag releases with the commit SHA, set the user ID (not email) with Sentry.setUser, scrub personal data in beforeSend, ignore known noise (browser extension errors, cancelled requests), and route alerts by ownership, not to everyone.

7. Logs, metrics, traces and OpenTelemetry

Three kinds of data answer three questions:

SignalWhat it isBest forWeak at
LogsTimestamped lines about events"What exactly happened in this request?"Trends; cost at high volume
MetricsNumbers over time (counts, rates, latency percentiles)"Is something wrong now? How bad? Since when?"Explaining why
TracesA tree of timed steps (spans) for one request across services"Where did the time go?"Cost, so they are usually sampled

Detailed log design is in Backend, Lesson 5. The CloudWatch version of metrics and alarms is in DevOps, Lesson 17.

Percentiles matter more than averages. p95 means 95% of requests were faster than this number. An average of 200 ms can hide a p99 of 6 seconds that hurts your best customers.

OpenTelemetry (OTel) is a vendor-neutral standard, with libraries, for producing traces, metrics and logs. You instrument once, then choose where to send the data (Sentry, Grafana, Datadog, Honeycomb) by changing an exporter. Terms:

  • A span is one timed step (for example "db.insert order") with attributes (key and value pairs).
  • A trace is all the spans for one request, joined by a trace ID.
  • Context propagation means passing the trace ID from service to service in an HTTP header (the standard header is traceparent), so spans from different services join into one trace.
  • An exporter sends data out. A collector is an optional separate process that receives, filters and forwards telemetry.

I ran this with @opentelemetry/sdk-node and printed the spans to the console:

JavaScript
const tracer = trace.getTracer("orders");await tracer.startActiveSpan("POST /orders", async (parent) => {  await tracer.startActiveSpan("db.insert order", async (child) => {    await db.insert(order);    child.end();  });  try { await charge(card); }  catch (e) {    parent.recordException(e);    parent.setStatus({ code: SpanStatusCode.ERROR });  }  parent.end();});

The output showed both spans with the same traceId, the child pointing at the parent through parentSpanContext, the service name orders-api on the resource, and the failed parent carrying an exception event with the message and stack and status: { code: 2 } (error).

In Next.js you create an instrumentation.ts file with a register() function. It runs once when a server instance starts. The simplest setup is registerOTel('next-app') from @vercel/otel, which also works in the edge runtime. For full control you configure NodeSDK yourself, and you must import it only when process.env.NEXT_RUNTIME === 'nodejs', because it does not run on the edge. Next.js already creates spans for requests, rendering and fetch calls; set NEXT_OTEL_VERBOSE=1 to see more.

Sentry and OTel are not rivals. Sentry is best at "which error, which release, who is affected". OTel is the open standard for traces and metrics and keeps you free of one vendor. Many teams use both.

8. Alerting and SLOs

A bad alert system has two failures: nobody is told about a real outage, or everyone is told about everything and learns to ignore it. Alert fatigue is when so many alerts fire that people stop reacting.

Rules that work:

  • Alert on symptoms users feel (error rate, slow checkout), not on causes (CPU at 80%). CPU at 80% may be perfectly healthy.
  • Every page must be actionable and urgent. If it can wait until morning, send a ticket instead of a page.
  • Link each alert to a runbook: a short document with what it means, how to check, and how to fix.
  • Use the four golden signals as a starting set: latency, traffic, errors and saturation (how full the system is).

The SLO gives you a calm way to decide how much failure is acceptable. I computed the error budget for a 30-day month:

SLOAllowed downtime (30 days)
99%432 minutes (7.2 hours)
99.9%43.2 minutes
99.95%21.6 minutes
99.99%4.3 minutes

Each extra "9" costs far more than the one before. Most product teams do not need 99.99%, and a third-party service you depend on may not offer it anyway.

Burn rate is how fast you spend the error budget compared with an even spend. A burn rate of 1 uses the whole budget in exactly the full window. A burn rate of 14.4 for one hour uses 2% of a 30-day budget; 6 for six hours uses 5%; 1 for three days uses 10%. A well-known pattern pages on a fast burn (such as 14.4 over 1 hour) and opens a ticket on a slow burn. This catches a sudden outage in minutes but ignores a one-minute blip.

If the budget is nearly gone, the team slows feature releases and spends time on reliability. If plenty is left, ship faster. That turns "reliability versus speed" from an argument into a number.

Common mistakes

  • Rebuilding per environment, so production runs something nobody tested.
  • Running prisma migrate deploy in every container's start command.
  • A dropped or renamed column in the same deploy as the code that stops using it.
  • NEXT_PUBLIC_ on a secret, or editing a variable and expecting the live site to change without a redeploy.
  • Running the container as root, copying .env into the image, or using latest as the only tag. Tag images with the commit SHA.
  • Uploading source maps from your laptop, or serving them publicly.
  • Alerting on CPU instead of user pain; no runbook; one shared channel nobody reads.
  • Treating a health endpoint as "returns 200 always". A health check that never checks anything cannot warn you.

Interview questions

(Must know) What happens on Vercel when I push to a branch, and how is that different from a normal server deploy?

Vercel builds the commit and creates a new, immutable deployment with its own URL. A push to a non-production branch makes a preview deployment, and I get the link on the pull request. A push to the production branch makes a production deployment. Nothing is changed in place; production is a pointer to one deployment.

On a normal server I would usually update files or containers on a machine that is already serving users, so old and new states mix and rollback means redeploying. On Vercel, rollback is moving the pointer back to an older deployment, which takes seconds and needs no rebuild. The trade-off: the platform decides how functions and caching work, so I give up some control. If my app needs long-lived connections or background workers, a container platform fits better.

(Must know) What is the difference between build-time and run-time environment variables in Next.js?

Build-time values are read during next build and baked into the output. All NEXT_PUBLIC_* variables are build-time: Next.js replaces them with literal text in the browser JavaScript. Run-time values are read when a request is handled, for example process.env.DATABASE_URL in a Route Handler or in a page that is rendered per request.

The consequence: if I change a NEXT_PUBLIC_ value I must build again. If I build one Docker image and promote it from staging to production, public values stay frozen from build time, so I either build per environment (and accept that production is not the exact tested artifact) or serve public settings from a small runtime endpoint. On Vercel I also have to redeploy after changing any variable, because existing deployments keep the values they were built with. And a secret must never use the NEXT_PUBLIC_ prefix.

(Must know) What is a cold start, and how do you reduce its effect?

A cold start is the extra time when a request needs a new function instance that has to boot, load code and often open a database connection. A warm instance skips that.

I reduce it in four ways. First, load less code on the hot path: smaller bundles and dynamic imports for rare features. Second, reuse instances: Fluid compute lets one instance serve many requests and stay alive, so cold starts are rarer. Third, keep the function in the same region as the database, because the network distance often costs more than the cold start. Fourth, pool or reuse connections. I also measure first: I look at p95 latency split by cold and warm requests before I change anything. For a page that rarely changes, the best fix is not to run a function at all, and to serve it from the cache with ISR.

(Senior signal) Someone says "run it on the edge so it is faster". What do you answer?

I ask where the data is. The edge is close to the user, but the database is usually in one region. A route that makes three database calls from an edge location far away pays the long round trip three times, so it can be slower than a function placed next to the database.

Edge code also has limits: it was a smaller runtime without all Node APIs. In current Next.js docs the edge option for runtime is marked deprecated and Node.js is the default. Where edge-like speed helps is work that needs no database: redirects, rewrites, header changes and simple checks. In Next.js that logic goes in the proxy file, and static or cached pages already come from the CDN. So my rule is: serve cached content from the network edge, run data-heavy code near the data.

(Must know) Why use a multi-stage Dockerfile for a Next.js app? Walk me through yours.

A multi-stage build uses several FROM stages in one file and copies only the needed result into the last one. My first stage installs dependencies from the lockfile with npm ci. The second stage copies the source and runs next build with output: "standalone", which makes a small server.js plus only the needed node_modules files. The last stage starts from node:22-alpine, creates a non-root user, copies the standalone folder and .next/static, and runs node server.js.

I built this. The container reported healthy, ran as uid=100, and the image was about 330 MB, of which about 240 MB was the base. The compiler and dev dependencies stayed out. Copying package*.json before the source means the dependency layer is cached until the lockfile changes. Trade-off: Alpine uses musl, which can break some native packages (sharp and others usually have prebuilt musl versions, but I would test), so node:22-slim is the safer fallback.

(Senior signal) Why must the container run as non-root, and what else is in your container checklist?

If an attacker gets code execution inside the app, running as root gives them more power: writing anywhere in the container, and an easier path to escape through a kernel or runtime bug. A normal user limits the damage. I add USER app after copying files with --chown.

My checklist: a .dockerignore so .env, .git and node_modules never enter the build context; no secrets in ENV, ARG or RUN (they stay in image history); pinned base image version (and a scan for known vulnerabilities in CI); a HEALTHCHECK; the app as PID 1 started with node directly so SIGTERM reaches it (I measured docker stop at about 0.2 s; through a shell wrapper it may wait 10 s and be killed); a read-only filesystem and dropped capabilities where the platform allows; and memory limits so a leak kills one container, not the host.

(Trap) You set the health check to return 200 always. Is that okay?

No, it only proves the process answers HTTP. It cannot catch "the app is up but the database is down". But the opposite mistake is also bad: if the liveness check calls the database and the database blips, every instance fails liveness and the orchestrator restarts all of them at once, which turns a small database problem into a full outage.

So I split two endpoints. Liveness (/healthz) is cheap and only says the process is not stuck. Readiness (/readyz) checks what this instance needs to serve traffic, with a short timeout, and returns 503 when it is not ready or when shutdown has started. A failing readiness check removes the instance from the load balancer without killing it. I keep the checks fast and never call slow third-party services in them.

(Must know) How do you speed up a GitHub Actions pipeline?

First, cache dependencies: actions/setup-node with cache: npm caches the npm download folder keyed on the lockfile. Second, cache the Next.js build cache in .next/cache, with a key from the lockfile and source hashes and a restore-keys fallback, because Next.js uses that folder to skip work. Third, run independent jobs in parallel (lint, unit tests, type check) and use a matrix only where the variation is real. Fourth, cancel superseded runs with concurrency and cancel-in-progress. Fifth, run only what changed in a monorepo, and put the slow end-to-end tests after the fast checks so cheap failures come first. For Docker builds, use layer caching with BuildKit.

I measure before and after. A cache that restores 400 MB can be slower than reinstalling a small dependency tree, so a bigger cache is not always faster.

(Senior signal) How do you deploy to AWS from GitHub Actions without storing AWS keys? What can go wrong?

I use OIDC. The job asks for permissions: id-token: write; GitHub issues a short-lived signed token that states the repository, branch and environment. In AWS I create an identity provider for GitHub and an IAM role whose trust policy accepts that token only when the sub claim matches, for example repo:acme/shop:environment:production. The workflow assumes the role with aws-actions/configure-aws-credentials and gets temporary credentials. There is no long-lived key to leak or rotate. The role has only the permissions the deploy needs.

What goes wrong: a trust policy that is too broad (any repo in the organization, or any branch), so a pull request from a feature branch can assume the production role; forgetting id-token: write, which gives a confusing "could not load credentials" error; and giving the role admin rights. I tie the production role to a GitHub Environment with required reviewers.

(Must know) What is the difference between continuous delivery and continuous deployment?

Both automate everything up to a production-ready build. In continuous delivery, a human decides when to release, usually with an approval click. In continuous deployment, every change that passes the checks goes to production automatically. The second needs strong tests, feature flags, good monitoring and fast rollback, because nobody stops a bad change by hand. Most teams I have worked in used delivery for risky systems (payments) and deployment for lower-risk ones. A GitHub Environment with a required reviewer is a simple way to add the human gate.

(Senior signal) Design a safe release process for a Next.js app with a database.

I would work in this order. On every pull request: lint, types, unit and integration tests, build, and a preview deployment with its own database branch or a seeded test database. On merge to main: build one artifact tagged with the commit SHA, deploy it to staging, run smoke tests, then promote the same artifact to production, behind a manual approval if the risk is high.

For the database I use expand and contract: migrations run as their own step before the new code, and each migration works with both the old and new code. The new feature goes behind a feature flag, off by default. I release by rolling traffic gradually (canary or rolling release), watching error rate and latency for the new version. If a number crosses my rollback rule (for example error rate 2x baseline for five minutes), I roll back automatically or by one command. After that I turn the flag on for 1%, 10%, 50%, 100%. The flag lets me undo the feature without undoing the deploy.

(Trap) Your deploy ran a migration that dropped a column, and now you need to roll back. What happened and how do you avoid it?

The code rollback is easy, but the data is gone. The old code expects the column, so rolling back the code alone fails, and a restore from backup would lose the writes since the deploy.

The root cause was doing a destructive change in the same release as the code change. The fix is process: split it into expand (add new things), migrate code to use them, and contract (remove old things) only in a later deploy, after the old code is gone and the new code has been stable for a while. Before a destructive step I take a backup or snapshot and practise the restore. In this incident, I would first stop the damage (a flag or a quick forward fix that re-adds the column), then restore the data from the latest backup into a side table and copy back what is missing. I would write a post-incident review.

(Senior signal) Why not run prisma migrate deploy when the container starts?

With more than one instance, they all start at once and each tries to migrate. Prisma takes an advisory lock with a 10 second timeout to avoid corruption, but the instances that lose the race wait or fail, which causes slow or flapping starts. A migration can also take longer than your readiness or startup timeout, so the orchestrator kills the container in the middle of a migration. And an app instance should not need a database user with schema-change rights at all.

So I run it once, as its own pipeline step or a one-off job, before the rollout, using a database user that can alter the schema, while the app itself uses a user that can only read and write rows. If the step fails, the deploy stops and the old version keeps running. With MongoDB there is no migrate deploy in Prisma (it uses db push), so I write versioned migration scripts myself and apply them in the same pipeline step.

(Must know) What is Sentry for, and how does it differ from logs?

Sentry captures unhandled and handled errors with the stack trace, release, environment, browser, user and breadcrumbs, and groups thousands of identical errors into one issue. It tells me what is new, what is growing, who is affected, and which release introduced it. Logs are lines I chose to write in advance. They are great for the story of one request but hard to scan for "what is new since yesterday's deploy".

I use both and join them: the Sentry event ID or trace ID goes into the log line, and the request ID goes into the Sentry tags. For design of logs, see Backend, Lesson 5. A trade-off to mention: Sentry events cost money, and errors in a loop can flood the quota, so I set rate limits and ignore noise.

(Trap) Your production stack traces in Sentry look like app.min.js:1:48210. What is wrong and how do you fix it?

The source maps were not uploaded, or were uploaded for the wrong release. The minified code cannot be mapped back. The fix lives in the build. The release name used when the app runs must be identical to the release used when maps were uploaded, usually the commit SHA. With @sentry/nextjs, withSentryConfig uploads the maps during next build using an auth token in CI.

Then I check four things: the token and project names are set in the CI environment (not the browser); the build used the same commit SHA as release; widenClientFileUpload is on if the trace points into dependency code; and the maps were generated at all. I also make sure the maps are deleted from the deployed output (the SDK does this by default after upload), so users cannot read my source. I confirm by throwing a test error on a preview deployment and reading the trace.

(Senior signal) How do you sample traces without losing the traces you care about?

Keeping every trace is expensive on a busy site, so I sample. With a fixed rate (tracesSampleRate: 0.1) I keep 10% at random, which is cheap and unbiased but may miss rare slow requests. With a sampler function (tracesSampler) I decide per request: always keep checkout, login and any request that already has an error; keep 1% of reads; drop health checks. In Sentry, if both options are set, the function is used and the rate is ignored.

In distributed tracing, the decision should be made once at the start and then followed by the other services (parent-based sampling), or you end up with half-complete traces. A more advanced option is tail sampling in an OpenTelemetry collector: it sees the whole trace first, then keeps the slow or failed ones. It needs more memory and a collector that sees all spans of a trace. Error events usually stay at 100%, because they are the reason I installed Sentry.

(Must know) What are logs, metrics and traces, and when do you reach for each?

Logs are event records, metrics are numbers over time, traces are timed trees of one request across services. When an alert says "error rate is up", I begin with metrics: how big, since when, which route or version. Then I use traces to find which service or call is slow or failing, for example one database query inside one downstream call. Then I use logs to read the exact error and the data around it. Sentry gives me the grouped error with context along the way.

The common trap is high-cardinality labels on metrics (a metric per user ID), which explodes the cost. User IDs belong in logs and traces, not in metric labels. A tiny example to remember: a metric tells me p95 latency doubled at 14:05, a trace shows 90% of the time is in SELECT on orders, and the log shows a missing index warning after the 14:03 deploy.

(Senior signal) How would you add OpenTelemetry to a Next.js app, and what does it cost you?

I add an instrumentation.ts file at the project root and export register(). Next.js calls it once per server start, before serving requests. The fast path is registerOTel('next-app') from @vercel/otel. If I need my own exporter, sampler or the Node SDK, I configure NodeSDK and import that file only when process.env.NEXT_RUNTIME === 'nodejs'. Next.js emits spans for requests, rendering and fetch already; I add custom spans around business steps (a payment call) and set service.name and the environment as resource attributes. I propagate the traceparent header on outgoing calls so traces continue into other services.

Costs: extra CPU and memory for the SDK; more network for exports (so I batch and sample); more data to store; and discipline about attribute names and PII. I would not put emails or tokens in span attributes. I run a collector next to the app so the app only needs to send to one local address, and I can change the vendor without touching code.

(Senior signal) What is an SLO, and how would you use an error budget on your team?

An SLO is a target for a user-facing measure over a window, for example "99.9% of checkout requests succeed in under 1 second, over 30 days". The error budget is what is left: 0.1% is about 43 minutes of full failure in a 30-day month. I pick the SLI by looking at what users feel (success rate and latency of key journeys), not at server health.

I use the budget as a decision tool. If we have plenty left, we ship faster and take more risk. If it is nearly spent, we pause risky releases and fix reliability. For alerting I use burn rate: page when the budget is burning at 14.4x for an hour (2% of the month gone), and open a ticket for a slow burn like 1x for three days. I would not promise 99.99% (4.3 minutes a month) unless the business needs it and pays for it, and I remember that my SLO cannot be better than the dependencies I rely on.

(Trap) It is 3 a.m. and the pager says error rate is 8%. What do you do in the first ten minutes?

First I confirm it is real and see the size: is it all users or one route, one region, one release? Second, stop the bleeding before finding the cause. If the alert started right after a deploy, I roll back (on Vercel, promote the previous deployment) or turn off the feature flag. A rollback in two minutes beats a perfect diagnosis in forty. Third, I tell the team in the incident channel what I see and what I am doing, so others do not change things at the same time.

Then I look for the cause. The deploy marker on the dashboard, then new issues in Sentry for that release, then a trace for a failing request, then the logs from that minute. I check dependencies too: database connections, third-party status pages. After recovery I watch the metrics until they are normal, keep the evidence, and write a short review with a timeline, the cause, why the tests and alerts did not catch it earlier, and one or two follow-up actions. A review is about the system, not about blaming a person.

Key takeaways

  • A deployment is immutable. Rollback is moving a pointer or a flag, not rebuilding. Build once and promote the same artifact.
  • NEXT_PUBLIC_* is baked in at build. Secrets stay server-side. Variable changes need a redeploy.
  • Run data-heavy code near the data. The edge is for light, data-free work, and the Edge Runtime option is deprecated in current Next.js.
  • A good Dockerfile is multi-stage, uses the lockfile, copies the least it can, runs as a non-root user, starts node directly, and has a real health check. A .dockerignore is part of it.
  • In CI: cache, parallelize, cancel old runs, give each job least-privilege permissions, and use OIDC instead of stored cloud keys with a narrow trust rule.
  • Migrations run once, before the new code, as expand then contract. Never drop or rename in the same release.
  • Feature flags separate deploy from release. Use stable hashing for percentages, keep a kill switch and delete old flags.
  • Sentry shows the error, the release and who is hit. Upload source maps in CI and do not serve them. tracesSampler overrides tracesSampleRate.
  • Logs tell the story, metrics tell you something is wrong, traces tell you where. OpenTelemetry keeps you free of one vendor.
  • Alert on user pain, not on causes. SLOs and burn rates turn "is this bad enough?" into a number.

What's next

To build the same pipeline on real servers, follow the DevOps series, starting with Docker, CI/CD and Monitoring. For log design, revisit Backend, Lesson 5. Then practise answering Q20 out loud, with a timer, using one of your own past incidents.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Part of the study guide

Related series