We use cookies to enhance your experience on the site
CodeWorlds

CI/CD and deployment automation

In modern software development, Continuous Integration (CI) and Continuous Deployment (CD) are key practices that allow for faster and more reliable application delivery. Next.js, as a modern framework, integrates perfectly with various CI/CD tools, which allows automating the process of testing, building, and deploying applications.

What are CI/CD?

Continuous Integration (CI)

Continuous Integration is a practice where developers regularly integrate their code changes into the main branch of the repository. Each integration is automatically verified by:

  • Running unit and integration tests
  • Static code analysis (linting)
  • Checking code quality
  • Type checking (in the case of TypeScript)

CI allows early detection of errors and ensures that the code in the main branch remains stable.

Continuous Deployment (CD)

Continuous Deployment goes one step further - after successfully passing the CI stage, it automatically deploys changes to the production or test environment. The CD process includes:

  • Automatic application building
  • Deployment to a selected environment (staging, production)
  • Automatic E2E tests after deployment
  • Deployment monitoring

Popular CI/CD tools for Next.js applications

For Next.js applications, we can use various CI/CD tools:

  1. GitHub Actions - integrated with GitHub, offers excellent integration with the repo
  2. GitLab CI/CD - built into GitLab, allows creating CI/CD pipelines
  3. CircleCI - a popular CI/CD platform with simple configuration
  4. Jenkins - an open, extensible automation server
  5. Vercel CI/CD - native solution for Next.js applications
  6. Netlify CI/CD - an alternative with similar features
  7. Travis CI - one of the oldest CI/CD platforms

CI/CD configuration with GitHub Actions

The easiest way to implement CI/CD for a Next.js project is to use GitHub Actions if your repository is hosted on GitHub.

1. Configuring the testing workflow

Create the file

.github/workflows/ci.yml
in your repository:

1name: CI
2
3on:
4  push:
5    branches: [main, development]
6  pull_request:
7    branches: [main, development]
8
9jobs:
10  test:
11    runs-on: ubuntu-latest
12
13    strategy:
14      matrix:
15        node-version: [18.x]
16
17    steps:
18      - uses: actions/checkout@v3
19
20      - name: Use Node.js ${{ matrix.node-version }}
21        uses: actions/setup-node@v3
22        with:
23          node-version: ${{ matrix.node-version }}
24          cache: 'npm'
25
26      - name: Install dependencies
27        run: npm ci
28
29      - name: Lint
30        run: npm run lint
31        
32      - name: Type check
33        run: npm run type-check
34        
35      - name: Run tests
36        run: npm test
37        
38      - name: Build
39        run: npm run build

This workflow runs on every push to the

main
and
development
branches and when a Pull Request is created to these branches.

2. Vercel deployment workflow configuration

Create the file

.github/workflows/deploy.yml
:

1name: Deploy to Production
2
3on:
4  push:
5    branches: [main]
6    
7jobs:
8  deploy:
9    runs-on: ubuntu-latest
10    
11    steps:
12      - uses: actions/checkout@v3
13      
14      - name: Deploy to Vercel
15        uses: amondnet/vercel-action@v20
16        with:
17          vercel-token: ${{ secrets.VERCEL_TOKEN }}
18          vercel-org-id: ${{ secrets.ORG_ID }}
19          vercel-project-id: ${{ secrets.PROJECT_ID }}
20          vercel-args: '--prod'

For this to work, you need to configure the following secrets in your repository settings:

  • VERCEL_TOKEN
    - Vercel API token
  • ORG_ID
    - Vercel organization ID
  • PROJECT_ID
    - Vercel project ID

3. Workflow configuration for the staging environment

1name: Deploy to Staging
2
3on:
4  push:
5    branches: [development]
6    
7jobs:
8  deploy:
9    runs-on: ubuntu-latest
10    
11    steps:
12      - uses: actions/checkout@v3
13      
14      - name: Deploy to Vercel (Preview)
15        uses: amondnet/vercel-action@v20
16        with:
17          vercel-token: ${{ secrets.VERCEL_TOKEN }}
18          vercel-org-id: ${{ secrets.ORG_ID }}
19          vercel-project-id: ${{ secrets.PROJECT_ID }}
20          github-comment: true

CI/CD configuration with GitLab CI

If you use GitLab, you can configure CI/CD using

.gitlab-ci.yml
:

1image: node:18-alpine
2
3stages:
4  - test
5  - build
6  - deploy_staging
7  - deploy_production
8
9cache:
10  key: ${CI_COMMIT_REF_SLUG}
11  paths:
12    - node_modules/
13    - .next/cache/
14
15test:
16  stage: test
17  script:
18    - npm ci
19    - npm run lint
20    - npm run type-check
21    - npm test
22  except:
23    - tags
24
25build:
26  stage: build
27  script:
28    - npm ci
29    - npm run build
30  artifacts:
31    paths:
32      - .next/
33  except:
34    - tags
35
36deploy_staging:
37  stage: deploy_staging
38  script:
39    - npm install -g vercel
40    - vercel --token ${VERCEL_TOKEN} --confirm
41  environment:
42    name: staging
43    url: ${CI_PROJECT_NAME}-git-${CI_COMMIT_REF_NAME}-${CI_PROJECT_NAMESPACE}.vercel.app
44  only:
45    - development
46
47deploy_production:
48  stage: deploy_production
49  script:
50    - npm install -g vercel
51    - vercel --token ${VERCEL_TOKEN} --prod --confirm
52  environment:
53    name: production
54    url: ${CI_PROJECT_NAME}.vercel.app
55  only:
56    - main

CI/CD configuration for a Next.js application with CircleCI

To configure CircleCI, create the file

.circleci/config.yml
:

1version: 2.1
2
3orbs:
4  node: circleci/node@5.0.3
5
6jobs:
7  test:
8    docker:
9      - image: cimg/node:18.15.0
10    steps:
11      - checkout
12      - node/install-packages:
13          pkg-manager: npm
14      - run:
15          name: Run tests
16          command: npm test
17      - run:
18          name: Run linter
19          command: npm run lint
20      - run:
21          name: Check types
22          command: npm run type-check
23
24  build:
25    docker:
26      - image: cimg/node:18.15.0
27    steps:
28      - checkout
29      - node/install-packages:
30          pkg-manager: npm
31      - run:
32          name: Build application
33          command: npm run build
34      - persist_to_workspace:
35          root: .
36          paths:
37            - .next
38            - node_modules
39            - package.json
40
41  deploy-staging:
42    docker:
43      - image: cimg/node:18.15.0
44    steps:
45      - checkout
46      - attach_workspace:
47          at: .
48      - run:
49          name: Install Vercel CLI
50          command: npm install -g vercel
51      - run:
52          name: Deploy to Staging
53          command: vercel --token ${VERCEL_TOKEN} --confirm
54
55  deploy-production:
56    docker:
57      - image: cimg/node:18.15.0
58    steps:
59      - checkout
60      - attach_workspace:
61          at: .
62      - run:
63          name: Install Vercel CLI
64          command: npm install -g vercel
65      - run:
66          name: Deploy to Production
67          command: vercel --token ${VERCEL_TOKEN} --prod --confirm
68
69workflows:
70  version: 2
71  test-build-deploy:
72    jobs:
73      - test
74      - build:
75          requires:
76            - test
77      - deploy-staging:
78          requires:
79            - build
80          filters:
81            branches:
82              only: development
83      - deploy-production:
84          requires:
85            - build
86          filters:
87            branches:
88              only: main

CI/CD configuration with Jenkins

Jenkins is a powerful CI/CD tool, but requires more configuration. Here is an example

Jenkinsfile
for a CI/CD pipeline for a Next.js application:

1pipeline {
2    agent {
3        docker {
4            image 'node:18-alpine'
5        }
6    }
7    
8    stages {
9        stage('Install Dependencies') {
10            steps {
11                sh 'npm ci'
12            }
13        }
14        
15        stage('Lint') {
16            steps {
17                sh 'npm run lint'
18            }
19        }
20        
21        stage('Type Check') {
22            steps {
23                sh 'npm run type-check'
24            }
25        }
26        
27        stage('Test') {
28            steps {
29                sh 'npm test'
30            }
31        }
32        
33        stage('Build') {
34            steps {
35                sh 'npm run build'
36            }
37        }
38        
39        stage('Deploy to Staging') {
40            when {
41                branch 'development'
42            }
43            steps {
44                sh 'npm install -g vercel'
45                sh 'vercel --token ${VERCEL_TOKEN} --confirm'
46            }
47        }
48        
49        stage('Deploy to Production') {
50            when {
51                branch 'main'
52            }
53            steps {
54                sh 'npm install -g vercel'
55                sh 'vercel --token ${VERCEL_TOKEN} --prod --confirm'
56            }
57        }
58    }
59    
60    post {
61        always {
62            cleanWs()
63        }
64    }
65}

Testing in the CI/CD process

An important element of the CI/CD process is automatic testing. For Next.js applications, we can configure different types of tests:

1. Unit tests with Jest and React Testing Library

1// __tests__/components/Header.test.tsx
2import { render, screen } from '@testing-library/react';
3import Header from '@/components/Header';
4
5describe('Header Component', () => {
6  it('renders the logo', () => {
7    render(<Header />);
8    const logo = screen.getByAltText('Logo');
9    expect(logo).toBeInTheDocument();
10  });
11  
12  it('has the correct navigation links', () => {
13    render(<Header />);
14    expect(screen.getByText('Home')).toBeInTheDocument();
15    expect(screen.getByText('About')).toBeInTheDocument();
16    expect(screen.getByText('Contact')).toBeInTheDocument();
17  });
18});

2. Integration tests for API Routes

1// __tests__/api/user.test.ts
2import { createMocks } from 'node-mocks-http';
3import userHandler from '@/app/api/user/route';
4
5describe('/api/user endpoint', () => {
6  it('returns a user when provided with a valid ID', async () => {
7    const { req, res } = createMocks({
8      method: 'GET',
9      query: { id: '123' },
10    });
11    
12    await userHandler(req, res);
13    
14    expect(res._getStatusCode()).toBe(200);
15    expect(JSON.parse(res._getData())).toEqual(
16      expect.objectContaining({
17        id: '123',
18        name: expect.any(String),
19      })
20    );
21  });
22  
23  it('returns 404 for non-existent user', async () => {
24    const { req, res } = createMocks({
25      method: 'GET',
26      query: { id: 'non-existent' },
27    });
28    
29    await userHandler(req, res);
30    
31    expect(res._getStatusCode()).toBe(404);
32  });
33});

3. End-to-End tests with Cypress

1// cypress/integration/navigation.spec.js
2describe('Navigation', () => {
3  it('should navigate to the about page', () => {
4    cy.visit('/');
5    
6    cy.get('a[href*="about"]').click();
7    
8    cy.url().should('include', '/about');
9    cy.get('h1').contains('About');
10  });
11  
12  it('should navigate to the contact page', () => {
13    cy.visit('/');
14    
15    cy.get('a[href*="contact"]').click();
16    
17    cy.url().should('include', '/contact');
18    cy.get('h1').contains('Contact');
19  });
20});

Add Cypress configuration to GitHub Actions:

1# .github/workflows/e2e-tests.yml
2name: E2E Tests
3
4on:
5  deployment_status:
6
7jobs:
8  e2e-tests:
9    if: github.event.deployment_status.state == 'success'
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v3
13      
14      - name: Install dependencies
15        run: npm ci
16      
17      - name: Cypress run
18        uses: cypress-io/github-action@v5
19        with:
20          record: true
21        env:
22          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
23          CYPRESS_BASE_URL: ${{ github.event.deployment_status.target_url }}

Automation of development tasks

Beyond CI/CD, we can automate various other development tasks:

1. Automatic code formatting with Husky and lint-staged

Install the required packages:

1npm install --save-dev husky lint-staged prettier

Update

package.json
:

1{
2  "scripts": {
3    "prepare": "husky install"
4  },
5  "lint-staged": {
6    "*.{js,jsx,ts,tsx}": [
7      "prettier --write",
8      "eslint --fix"
9    ],
10    "*.{json,md}": [
11      "prettier --write"
12    ]
13  }
14}

Create a pre-commit hook:

1npx husky add .husky/pre-commit "npx lint-staged"

2. Automatic versioning with Semantic Release

Install the required packages:

1npm install --save-dev semantic-release @semantic-release/git @semantic-release/changelog

Create the file

.releaserc.json
:

1{
2  "branches": ["main"],
3  "plugins": [
4    "@semantic-release/commit-analyzer",
5    "@semantic-release/release-notes-generator",
6    "@semantic-release/changelog",
7    "@semantic-release/npm",
8    ["@semantic-release/git", {
9      "assets": ["package.json", "CHANGELOG.md"],
10      "message": "chore(release): ${nextRelease.version} [skip ci]
11
12${nextRelease.notes}"
13    }],
14    "@semantic-release/github"
15  ]
16}

Add a workflow for semantic-release:

1# .github/workflows/release.yml
2name: Release
3
4on:
5  push:
6    branches: [main]
7
8jobs:
9  release:
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v3
13      - name: Setup Node.js
14        uses: actions/setup-node@v3
15        with:
16          node-version: '18'
17          cache: 'npm'
18      - name: Install dependencies
19        run: npm ci
20      - name: Release
21        env:
22          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
24        run: npx semantic-release

3. Automatic documentation generation

For TypeScript projects, you can automate documentation generation:

1# .github/workflows/docs.yml
2name: Generate Documentation
3
4on:
5  push:
6    branches: [main]
7
8jobs:
9  docs:
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v3
13      - name: Setup Node.js
14        uses: actions/setup-node@v3
15        with:
16          node-version: '18'
17          cache: 'npm'
18      - name: Install dependencies
19        run: npm ci
20      - name: Generate docs
21        run: npx typedoc --out docs src
22      - name: Deploy docs to GitHub Pages
23        uses: peaceiris/actions-gh-pages@v3
24        with:
25          github_token: ${{ secrets.GITHUB_TOKEN }}
26          publish_dir: ./docs

Deployment and rollback strategies

When deploying applications, it's worth implementing safe deployment strategies:

1. Blue-Green Deployment

Blue-Green deployment involves maintaining two identical production environments, of which only one is active. In the case of Vercel, you can use their built-in "Preview Deployments" feature and domain aliasing:

1// scripts/blue-green-deploy.js
2const { execSync } = require('child_process');
3
4// Deploy to new environment (but not live yet)
5const output = execSync('vercel --prod').toString();
6const deploymentUrl = output.match(/https://[w.-]+/)[0];
7
8// Run smoke tests against new deployment
9const smokeTestResult = execSync(`npm run test:smoke -- --url=${deploymentUrl}`).toString();
10
11if (smokeTestResult.includes('All tests passed')) {
12  // Switch traffic to new deployment
13  execSync(`vercel alias set ${deploymentUrl} your-production-domain.com`);
14  console.log('Successfully switched to new deployment');
15} else {
16  console.error('Smoke tests failed, aborting deployment');
17  process.exit(1);
18}

2. Canary Releases

In the Canary strategy, a new version is gradually released, initially only for a small percentage of users. In Next.js, you can achieve this using feature flags and traffic splitting:

1// lib/feature-flags.ts
2import { useEffect, useState } from 'react';
3
4const CANARY_PERCENTAGE = 10; // 10% of users will get the new version
5
6export function useCanaryFeature(featureName: string): boolean {
7  const [isEnabled, setIsEnabled] = useState(false);
8  
9  useEffect(() => {
10    // Generate a random number for the user and save it in local storage
11    let userBucket = localStorage.getItem('userBucket');
12    if (!userBucket) {
13      userBucket = Math.floor(Math.random() * 100).toString();
14      localStorage.setItem('userBucket', userBucket);
15    }
16    
17    // Check if the user is in the canary group
18    const inCanaryGroup = parseInt(userBucket) < CANARY_PERCENTAGE;
19    
20    // Check if the feature is active for this user
21    fetch('/api/feature-flags')
22      .then(res => res.json())
23      .then(flags => {
24        // If the flag exists and the user is in the canary group, enable the feature
25        if (flags[featureName] && inCanaryGroup) {
26          setIsEnabled(true);
27        }
28      });
29  }, [featureName]);
30  
31  return isEnabled;
32}

3. Automatic rollback

Automatic rollback deployment when issues arise is a key practice:

1# .github/workflows/deploy-monitor-rollback.yml
2name: Deploy, Monitor & Rollback
3
4on:
5  push:
6    branches: [main]
7
8jobs:
9  deploy:
10    runs-on: ubuntu-latest
11    outputs:
12      deployment_id: ${{ steps.deploy.outputs.deployment_id }}
13    steps:
14      - uses: actions/checkout@v3
15      - name: Deploy to Vercel
16        id: deploy
17        uses: amondnet/vercel-action@v20
18        with:
19          vercel-token: ${{ secrets.VERCEL_TOKEN }}
20          vercel-org-id: ${{ secrets.ORG_ID }}
21          vercel-project-id: ${{ secrets.PROJECT_ID }}
22          vercel-args: '--prod'
23          
24  
25  monitor:
26    needs: deploy
27    runs-on: ubuntu-latest
28    steps:
29      - name: Wait for 2 minutes
30        run: sleep 120
31      
32      - name: Check for errors
33        id: check_errors
34        run: |
35          ERROR_COUNT=$(curl -s "https://api.example.com/monitoring/errors?since=$(date -u -d '2 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" | jq '.count')
36          echo "::set-output name=error_count::$ERROR_COUNT"
37          if [ "$ERROR_COUNT" -gt "10" ]; then
38            echo "Error count too high ($ERROR_COUNT). Failing job."
39            exit 1
40          fi
41      
42      - name: Monitor Core Web Vitals
43        id: web_vitals
44        run: |
45          LCP=$(curl -s "https://api.example.com/monitoring/web-vitals" | jq '.lcp')
46          if [ "$LCP" -gt "2500" ]; then
47            echo "LCP too high ($LCP ms). Failing job."
48            exit 1
49          fi
50  
51  rollback:
52    needs: [deploy, monitor]
53    runs-on: ubuntu-latest
54    if: ${{ failure() }}
55    steps:
56      - name: Rollback to previous deployment
57        uses: amondnet/vercel-action@v20
58        with:
59          vercel-token: ${{ secrets.VERCEL_TOKEN }}
60          vercel-org-id: ${{ secrets.ORG_ID }}
61          vercel-project-id: ${{ secrets.PROJECT_ID }}
62          vercel-args: '--prod --scope myorg'  # Rollback to previous deployment

Managing environment variables

Secure management of environment variables is a critical aspect of CI/CD:

1. Storing environment variables in the CI/CD system

Most CI/CD systems offer secure storage of secrets, e.g., GitHub Secrets:

1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5  push:
6    branches: [main]
7
8jobs:
9  deploy:
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v3
13      
14      - name: Create .env file
15        run: |
16          cat > .env.production << EOL
17          DATABASE_URL=${{ secrets.DATABASE_URL }}
18          API_KEY=${{ secrets.API_KEY }}
19          NEXT_PUBLIC_ANALYTICS_ID=${{ secrets.NEXT_PUBLIC_ANALYTICS_ID }}
20          EOL
21      
22      - name: Deploy
23        run: npm run deploy

2. Automatic rotation of secrets

For critical applications, it's worth implementing automatic rotation of secrets:

1# .github/workflows/rotate-secrets.yml
2name: Rotate Secrets
3
4on:
5  schedule:
6    - cron: '0 0 1 * *'  # Every month
7
8jobs:
9  rotate:
10    runs-on: ubuntu-latest
11    steps:
12      - name: Generate new API key
13        id: new_key
14        run: echo "::set-output name=api_key::$(openssl rand -base64 32)"
15      
16      - name: Update API service with new key
17        run: |
18          curl -X POST -H "Authorization: Bearer ${{ secrets.ADMIN_TOKEN }}"           -d '{"api_key": "${{ steps.new_key.outputs.api_key }}"}'           https://api.example.com/admin/update-key
19      
20      - name: Update GitHub secret
21        uses: gliech/create-github-secret-action@v1
22        with:
23          name: API_KEY
24          value: ${{ steps.new_key.outputs.api_key }}
25          pa_token: ${{ secrets.PA_TOKEN }}

Deployment monitoring

A key element of the CI/CD process is deployment monitoring to quickly detect and respond to potential problems:

1. Integration with monitoring services

1# .github/workflows/deploy-and-monitor.yml
2name: Deploy and Monitor
3
4on:
5  push:
6    branches: [main]
7
8jobs:
9  deploy:
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v3
13      - name: Deploy to Vercel
14        uses: amondnet/vercel-action@v20
15        with:
16          vercel-token: ${{ secrets.VERCEL_TOKEN }}
17          vercel-org-id: ${{ secrets.ORG_ID }}
18          vercel-project-id: ${{ secrets.PROJECT_ID }}
19          vercel-args: '--prod'
20      
21      - name: Notify Datadog of deployment
22        run: |
23          curl -X POST "https://api.datadoghq.com/api/v1/events"           -H "Content-Type: application/json"           -H "DD-API-KEY: ${{ secrets.DATADOG_API_KEY }}"           -d '{"title":"Deployment to production","text":"Deployed commit ${{ github.sha }} to production","tags":["environment:production","team:frontend"],"alert_type":"info"}'

2. Automatic post-deployment tests

1# .github/workflows/post-deploy-tests.yml
2name: Post-deployment Tests
3
4on:
5  deployment_status:
6
7jobs:
8  smoke-tests:
9    if: github.event.deployment_status.state == 'success'
10    runs-on: ubuntu-latest
11    steps:
12      - uses: actions/checkout@v3
13      
14      - name: Run smoke tests
15        run: |
16          npm ci
17          npm run test:smoke -- --url=${{ github.event.deployment_status.target_url }}
18      
19      - name: Post results to Slack
20        if: always()
21        uses: slackapi/slack-github-action@v1
22        with:
23          payload: |
24            {
25              "text": "Smoke test results: ${{ job.status }}
26Deployment URL: ${{ github.event.deployment_status.target_url }}"
27            }
28        env:
29          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Summary

Implementing CI/CD for Next.js applications is a key element of the modern software development process that brings many benefits:

  1. Faster deployment of changes - process automation reduces the time needed to deliver new features
  2. Higher code quality - automatic tests and code analysis ensure higher quality
  3. Lower risk of errors - automation eliminates human errors
  4. Faster feedback - developers receive immediate feedback on code quality
  5. Increased productivity - developers focus on feature development, not on manual processes

A properly configured CI/CD pipeline is an investment that pays off in time savings, higher code quality, and more reliable deployments. Regardless of the chosen platform (GitHub Actions, GitLab CI, CircleCI, Jenkins), the key is to adapt the process to the specific needs of the project and team.

Go to CodeWorlds