Learn/software dev/Deployment
Intermediate~20 min read

Deployment

Environments, build vs runtime, secrets management, CI/CD pipelines, Docker and registries, deploying to Vercel and the cloud, deployment strategies, rollbacks, and monitoring.

CI/CDDockerVercelGitHub Actions

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.

EnvironmentPurposeData
DevelopmentLocal machine, fast iteration, hot reloadSeed / mock data
StagingProduction mirror for QA and integration testsAnonymised copy of prod
ProductionLive traffic serving real usersReal 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.env at 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.

bash
# .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.

StageWhat it does
LintStatic analysis, formatting, type checks — catches style and type errors
TestUnit + integration tests, coverage gates
BuildProduce the deployable artifact (bundle or container image)
DeployPush 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.

yaml
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.

dockerfile
# ---- 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.

yaml
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.

bash
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.
bash
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.

ModelYou manageExample
VMsOS, runtime, scaling, patchingAWS EC2
ContainersImage + orchestration configECS, Kubernetes (EKS)
ServerlessJust the function codeAWS 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.

StrategyHow it worksTrade-off
RollingReplace instances a few at a timeNo downtime, but two versions run at once
Blue-GreenRun new (green) alongside old (blue), switch traffic at onceInstant rollback, but double the infra cost
CanaryRoute a small % to the new version, ramp up if healthyLimits blast radius, but needs metrics + automation
RecreateStop old, start newSimple, 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?).

typescript
// 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

  1. Write a GitHub Actions workflow that runs lint, tests, and build on every PR, and deploys to production only on merge to main.
  2. Convert a single-stage Dockerfile to a multi-stage build and measure the reduction in final image size.
  3. Set up a docker-compose.yml that runs your app plus a Postgres database, and connect them via an environment variable.
  4. 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.
  5. Add a readiness health-check endpoint that checks a database connection, and explain how a canary deploy would use it.
  6. Describe the exact steps to roll back a bad deployment that included a database migration, keeping the migration backward-compatible.

Section navigation