What is Deployment?
Deployment is the process of taking code from a developer's machine and running it reliably where users can reach it. Modern deployment is automated, repeatable, and reversible — the same commit should produce the same artifact every time, ship through a pipeline of automated checks, and be rolled back in seconds if something breaks.
Environments: Dev, Staging, Prod
Code flows through a series of increasingly production-like environments. Each catches a different class of problem before it reaches users.
| Environment | Purpose | Data |
|---|---|---|
| Development | Local machine, fast iteration, hot reload | Seed / mock data |
| Staging | Production mirror for QA and integration tests | Anonymised copy of prod |
| Production | Live traffic serving real users | Real user data |
The golden rule: environments should differ only in configuration, never in code. Build one artifact, promote the same artifact through each tier, and inject environment-specific values (URLs, credentials, feature flags) at runtime.
Build Time vs Runtime
Understanding when a value is resolved is critical to avoiding config bugs and leaked secrets.
- Build time — compilation, bundling, and transpilation. Values baked in here are frozen into the artifact and shipped to every environment. In Next.js, anything prefixed
NEXT_PUBLIC_is inlined at build time and exposed to the browser. - Runtime — when the process actually starts and handles requests. Server-only secrets should be read from
process.envat runtime so the same artifact behaves correctly in staging and prod.
Never inline a secret at build time
A value baked into a client bundle at build time is public forever — it ships in the JavaScript users download. API keys, database URLs, and tokens must only ever be read at runtime on the server. If you see a secret behind NEXT_PUBLIC_, it is leaked.
Environment Variables & Secrets
Configuration lives in environment variables, not in code. Non-sensitive config (log level, feature flags) can sit in a committed .env.example. Secrets (API keys, DB passwords) must never be committed — store them in a secrets manager and inject them at deploy time.
# .env.local (git-ignored — never commit real secrets)
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_SECRET_KEY=sk_live_xxx
NEXT_PUBLIC_API_URL=https://api.example.com # safe: public by design
# .env.example (committed — documents required keys, no values)
DATABASE_URL=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_API_URL=
In production, use a dedicated secrets manager rather than plaintext files: AWS Secrets Manager, HashiCorp Vault, GitHub Actions secrets, or the platform's built-in store (Vercel/Netlify environment variables). Rotate secrets regularly and scope each credential to the least privilege it needs.
CI/CD Pipelines
Continuous Integration (CI) means every push is automatically built and tested, so integration problems surface immediately. Continuous Delivery/Deployment (CD) means those validated builds are automatically shipped to staging (delivery) or straight to production (deployment). A pipeline is an ordered set of stages that fail fast — later stages only run if earlier ones pass.
| Stage | What it does |
|---|---|
| Lint | Static analysis, formatting, type checks — catches style and type errors |
| Test | Unit + integration tests, coverage gates |
| Build | Produce the deployable artifact (bundle or container image) |
| Deploy | Push the artifact to the target environment |
GitHub Actions Example
A workflow lives in .github/workflows/ci.yml. This one lints, tests, and builds on every push and PR, then deploys only when main passes.
name: CI/CD
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- run: npm run build
deploy:
needs: build-and-test # only runs if build-and-test passed
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # gated env, can require approval
steps:
- uses: actions/checkout@v4
- name: Deploy
run: ./scripts/deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Fail fast, cache aggressively
Order stages cheapest-first (lint before the slow build) so failures return quickly. Cache dependencies (cache: npm) and use npm ci not npm install for reproducible, lockfile-exact builds.
Docker & Containers
A container packages your app with its exact runtime, dependencies, and OS libraries so it runs identically everywhere — solving "works on my machine." An image is the immutable blueprint (built once, stored in a registry); a container is a running instance of that image. You can start many containers from one image.
Multi-Stage Dockerfile
Multi-stage builds use a heavy builder stage to compile, then copy only the finished artifacts into a slim runtime stage. The final image excludes build tools and dev dependencies — smaller, faster to pull, and a smaller attack surface.
# ---- Stage 1: build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # produces .next/
# ---- Stage 2: runtime ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
USER node # never run as root
CMD ["npm", "start"]
Use a .dockerignore (mirror your .gitignore) so node_modules, .git, and .env never get copied into the image.
docker-compose for Local Multi-Service
Compose defines a multi-container stack (app + database + cache) in one file so a whole environment starts with docker compose up.
services:
app:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://user:pass@db:5432/app
depends_on: [db]
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: pass
volumes: ["pgdata:/var/lib/postgresql/data"]
volumes:
pgdata:
Container Registries
Built images are pushed to a registry — a versioned store that deploy targets pull from. Common registries: Docker Hub, GitHub Container Registry (ghcr.io), and AWS ECR. Tag images with the git SHA (not just latest) so every deploy is traceable and rollback-able.
docker build -t ghcr.io/acme/app:$GIT_SHA .
docker push ghcr.io/acme/app:$GIT_SHA
docker pull ghcr.io/acme/app:$GIT_SHA # on the target host
Deploying to Vercel (Next.js)
Vercel is the platform built by the creators of Next.js and offers the smoothest path for it. You connect a Git repository and Vercel deploys automatically on every push — no servers to manage.
- Git integration — every push to any branch triggers a build. A push to the production branch (
main) creates a production deployment. - Preview deploys — every pull request gets its own isolated URL with production-identical infrastructure, so reviewers test the real thing before merge.
- Environment variables — set per-environment (Development / Preview / Production) in the dashboard; injected at build and runtime, never committed.
- Instant rollback — every deployment is immutable and kept, so you promote a previous one in one click.
npm i -g vercel
vercel # deploy a preview
vercel --prod # promote to production
vercel env add DATABASE_URL production
Cloud Deployment Models
Beyond managed platforms, you deploy to a cloud provider (AWS, GCP, Azure) using one of three models, trading control for operational burden.
| Model | You manage | Example |
|---|---|---|
| VMs | OS, runtime, scaling, patching | AWS EC2 |
| Containers | Image + orchestration config | ECS, Kubernetes (EKS) |
| Serverless | Just the function code | AWS Lambda, Cloud Run |
Serverless auto-scales to zero and bills per request — great for spiky or low-traffic workloads, at the cost of cold starts. Containers give portability and predictable performance for steady traffic. VMs give full control when you need it. Most teams reach for containers or serverless first.
Deployment Strategies
How you replace the old version with the new one determines your downtime and blast radius when something goes wrong.
| Strategy | How it works | Trade-off |
|---|---|---|
| Rolling | Replace instances a few at a time | No downtime, but two versions run at once |
| Blue-Green | Run new (green) alongside old (blue), switch traffic at once | Instant rollback, but double the infra cost |
| Canary | Route a small % to the new version, ramp up if healthy | Limits blast radius, but needs metrics + automation |
| Recreate | Stop old, start new | Simple, but causes downtime |
Rollbacks
A rollback reverts to a known-good version fast. Because immutable artifacts are versioned in a registry (or kept by the platform), rolling back means re-pointing traffic at the previous image or deployment — not rebuilding.
Two rules make rollbacks safe: keep deployments immutable so the old version is still deployable, and make schema/database migrations backward-compatible — deploy additive changes (new nullable columns) before code that uses them, so rolling back code never breaks against the new schema.
Monitoring, Observability & Health Checks
Deployment doesn't end at "it's live." Observability is built on three pillars: metrics (numbers over time — latency, error rate, throughput), logs (discrete events), and traces (a request's path across services). Tools like Datadog, Grafana, Sentry, and OpenTelemetry collect these.
A health check is an endpoint the platform polls to decide if an instance is alive and ready for traffic. If it fails, the orchestrator stops routing to that instance (and can auto-restart it). Distinguish liveness (is the process up?) from readiness (can it serve requests — DB connected, caches warm?).
// app/api/health/route.ts (Next.js route handler)
export async function GET() {
try {
await db.query("SELECT 1"); // check dependencies
return Response.json({ status: "ok" }, { status: 200 });
} catch {
return Response.json({ status: "unhealthy" }, { status: 503 });
}
}
HTTPS & DNS
DNS maps your domain to your deployment: an A/AAAA record points to an IP, a CNAME points to another hostname (how you attach a custom domain to Vercel). Changes propagate subject to the record's TTL.
HTTPS encrypts traffic with a TLS certificate. Managed platforms provision and auto-renew certificates for you (via Let's Encrypt); self-managed setups terminate TLS at a load balancer or reverse proxy. Always redirect HTTP to HTTPS and enable HSTS.
Practice Exercises
- Write a GitHub Actions workflow that runs lint, tests, and build on every PR, and deploys to production only on merge to
main. - Convert a single-stage Dockerfile to a multi-stage build and measure the reduction in final image size.
- Set up a
docker-compose.ymlthat runs your app plus a Postgres database, and connect them via an environment variable. - Deploy a Next.js app to Vercel, add a preview-only environment variable, and verify it appears in a PR preview but not in production.
- Add a readiness health-check endpoint that checks a database connection, and explain how a canary deploy would use it.
- Describe the exact steps to roll back a bad deployment that included a database migration, keeping the migration backward-compatible.