Continuous Deployment
Continuous Deployment (CD) extends Continuous Integration by automatically deploying every change that passes the CI pipeline to production — without manual intervention. While Continuous Delivery stops at a deployable artifact, Continuous Deployment pushes that artifact live.
CD requires deep confidence in your test suite, robust rollback mechanisms, and deployment strategies that minimize risk. This guide covers the strategies, environments, and tooling needed to deploy safely and frequently.
The core of CD is the pipeline that takes code from merge to production. Here is a typical automated deployment pipeline:
| 1 | # GitHub Actions — Auto-Deploy to Production |
| 2 | name: Deploy Production |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | |
| 7 | jobs: |
| 8 | deploy: |
| 9 | runs-on: ubuntu-latest |
| 10 | environment: production |
| 11 | steps: |
| 12 | - uses: actions/checkout@v4 |
| 13 | - uses: actions/setup-node@v4 |
| 14 | with: |
| 15 | node-version: 20 |
| 16 | cache: npm |
| 17 | - run: npm ci |
| 18 | - run: npm run build |
| 19 | |
| 20 | - name: Deploy to Vercel |
| 21 | uses: amondnet/vercel-action@v25 |
| 22 | with: |
| 23 | vercel-token: ${{ secrets.VERCEL_TOKEN }} |
| 24 | vercel-org-id: ${{ secrets.ORG_ID}} |
| 25 | vercel-project-id: ${{ secrets.PROJECT_ID}} |
| 26 | vercel-args: --prod |
| 27 | |
| 28 | - name: Run Smoke Tests |
| 29 | run: | |
| 30 | curl -f https://example.com/api/health |
| 31 | curl -f https://example.com |
| 32 | |
| 33 | - name: Notify Team |
| 34 | uses: slackapi/slack-github-action@v1 |
| 35 | with: |
| 36 | payload: | |
| 37 | { "text": "Deployed main to production ✅" } |
info
Preview deployments create ephemeral environments for every pull request, allowing reviewers to test changes in a production-like setting before merging.
| Feature | Vercel Preview | Netlify Deploy Preview |
|---|---|---|
| URL Pattern | project-[hash].vercel.app | [hash]--project.netlify.app |
| Auto-Created | On every PR push | On every PR push |
| Environment Variables | Separate preview env vars | Deploy context env vars |
| Password Protection | Basic auth or Vercel Password | Basic auth |
| Teardown | Auto on branch delete | Auto on PR close |
| 1 | # Trigger Vercel Preview from CLI |
| 2 | vercel --prebuilt |
| 3 | |
| 4 | # Deploy to a specific environment |
| 5 | vercel --environment preview |
| 6 | |
| 7 | # List all preview deployments |
| 8 | vercel list |
| 9 | |
| 10 | # Alias a preview URL for testing |
| 11 | vercel alias set https://project-hash.vercel.app staging.example.com |
A mature CD pipeline promotes code through multiple environments, each with increasing confidence requirements:
| Environment | Trigger | Gate | Data |
|---|---|---|---|
| Development | Code push | None | Mocked / local |
| Preview | PR created | CI passes | Sandbox / staging DB |
| Staging | Merge to main | Code review + CI | Anonymized production snapshot |
| Production | Deploy trigger | Manual approval + smoke tests | Real user data |
best practice
Every deployment must have a rollback plan. The best rollback is the one you never need, but when things go wrong, speed matters.
Vercel Instant Rollback
Vercel keeps deployment history. Rollback is a one-click operation that redeploys a previous build.
| 1 | # List deployment history |
| 2 | vercel list --all |
| 3 | |
| 4 | # Rollback to a specific deployment |
| 5 | vercel rollback <deployment-url-or-id> |
| 6 | |
| 7 | # Rollback to the previous production deployment |
| 8 | vercel rollback --confirm |
Git-Based Rollback
For self-hosted deployments, revert the commit and redeploy the previous version.
| 1 | # Revert to the previous commit |
| 2 | git revert HEAD --no-edit |
| 3 | git push origin main |
| 4 | |
| 5 | # Or checkout a known-good tag |
| 6 | git checkout tags/v1.2.3 |
| 7 | npm run build |
| 8 | npm run deploy |
danger
Blue-Green Deployment
Two identical environments (Blue and Green) run side by side. At any time, one serves production traffic. The new version is deployed to the inactive environment, then traffic is switched instantaneously.
| 1 | # Blue = current live (v1) |
| 2 | # Green = new version (v2) |
| 3 | |
| 4 | # 1. Deploy v2 to Green environment |
| 5 | kubectl apply -f deployment-v2.yaml --namespace=green |
| 6 | |
| 7 | # 2. Run smoke tests against Green |
| 8 | curl -f https://green.internal.example.com/health |
| 9 | |
| 10 | # 3. Switch load balancer to Green |
| 11 | kubectl patch service app -p '{"spec":{"selector":{"version":"v2"}}}' |
| 12 | |
| 13 | # 4. Monitor for errors. If issues: |
| 14 | kubectl patch service app -p '{"spec":{"selector":{"version":"v1"}}}' |
pro tip
Canary Deployment
The new version is gradually rolled out to a small subset of users (the canaries) before expanding to 100%. Traffic shifting is controlled by a load balancer or feature flag.
| 1 | # Istio VirtualService for canary routing |
| 2 | apiVersion: networking.istio.io/v1beta1 |
| 3 | kind: VirtualService |
| 4 | metadata: |
| 5 | name: app-canary |
| 6 | spec: |
| 7 | hosts: |
| 8 | - app.example.com |
| 9 | http: |
| 10 | - match: |
| 11 | - headers: |
| 12 | x-canary: "true" |
| 13 | route: |
| 14 | - destination: |
| 15 | host: app |
| 16 | subset: v2 |
| 17 | - route: |
| 18 | - destination: |
| 19 | host: app |
| 20 | subset: v1 |
| 21 | weight: 95 |
| 22 | - destination: |
| 23 | host: app |
| 24 | subset: v2 |
| 25 | weight: 5 |
warning
Feature flags (toggles) decouple deployment from release. Code can be deployed to production but remain hidden behind a flag, enabling trunk-based development and gradual rollouts.
| 1 | // LaunchDarkly or Flagsmith flag evaluation |
| 2 | import { useFlags } from '@launchdarkly/react-client-sdk'; |
| 3 | |
| 4 | function NewCheckoutFlow() { |
| 5 | const { newCheckout } = useFlags(); |
| 6 | |
| 7 | if (!newCheckout) { |
| 8 | return <LegacyCheckout />; |
| 9 | } |
| 10 | |
| 11 | return <ModernCheckout />; |
| 12 | } |
| 13 | |
| 14 | // Server-side flag evaluation |
| 15 | const ldClient = LaunchDarkly.init(LAUNCHDARKLY_SDK_KEY); |
| 16 | |
| 17 | async function handler(req, res) { |
| 18 | const user = { key: req.user.id, email: req.user.email }; |
| 19 | const showFeature = await ldClient.variation( |
| 20 | "new-checkout-flow", |
| 21 | user, |
| 22 | false // default value |
| 23 | ); |
| 24 | |
| 25 | if (showFeature) { |
| 26 | return renderNewCheckout(res); |
| 27 | } |
| 28 | return renderLegacyCheckout(res); |
| 29 | } |
best practice