ServerlessBase Blog
  • Build Stage Best Practices

    Master build stage best practices for CI/CD pipelines to improve efficiency, reliability, and code quality

    Build Stage Best Practices

    You've probably spent hours debugging a build failure that could have been caught earlier. A slow build wastes developer time. A flaky build breaks deployments. A build that doesn't catch bugs ships them to production. The build stage is where your CI/CD pipeline earns its keep—if you design it right.

    This guide covers practical build stage best practices that improve speed, reliability, and quality. No theory, no fluff. Just actionable patterns that work in real projects.

    Understanding the Build Stage

    The build stage transforms your source code into deployable artifacts. It compiles, bundles, tests, and packages your application. This is where bugs become visible and where performance bottlenecks emerge. A poorly designed build stage creates a bottleneck that slows down your entire development workflow.

    Think of the build stage as a factory assembly line. If any station is slow or unreliable, the whole line suffers. Your goal is to optimize each station while maintaining quality.

    Parallel Execution for Speed

    Sequential build steps are the enemy of speed. Every command that waits for another command to finish is wasted time. Parallel execution runs independent tasks simultaneously, cutting build times in half or more.

    Identify Parallelizable Tasks

    Look for tasks that don't depend on each other:

    • Unit tests across different modules
    • Linting multiple files
    • Static analysis on different source files
    • Dependency installation for different packages
    • Build steps for different applications in a monorepo

    Implement Parallel Execution

    Most CI/CD platforms support parallel execution through matrix builds, job arrays, or parallel steps. Here's how to structure parallel execution in common tools:

    GitHub Actions:

    jobs:
      test:
        strategy:
          matrix:
            node-version: [16, 18, 20]
        steps:
          - uses: actions/checkout@v3
          - name: Install dependencies
            run: npm ci
          - name: Run tests
            run: npm test

    GitLab CI:

    test:
      parallel:
        matrix:
          - node_version: [16, 18, 20]
      script:
        - npm ci
        - npm test

    Jenkins:

    pipeline {
        agent any
        stages {
            stage('Test') {
                parallel {
                    stage('Node 16') {
                        steps {
                            sh 'npm ci && npm test'
                        }
                    }
                    stage('Node 18') {
                        steps {
                            sh 'npm ci && npm test'
                        }
                    }
                    stage('Node 20') {
                        steps {
                            sh 'npm ci && npm test'
                        }
                    }
                }
            }
        }
    }

    CircleCI:

    version: 2.1
    jobs:
      test:
        parameters:
          node_version:
            type: string
        docker:
          - image: node:<< parameters.node_version >>
        steps:
          - checkout
          - run: npm ci
          - run: npm test
    workflows:
      version: 2
      build:
        jobs:
          - test:
              node_version: "16"
          - test:
              node_version: "18"
          - test:
              node_version: "20"

    Parallel execution reduces build time from 30 minutes to 10 minutes by running three test suites simultaneously. That's 20 minutes saved per build.

    Caching Dependencies

    Dependency installation is often the slowest build step. Installing thousands of packages from scratch takes minutes. Caching dependencies means installing from a local cache instead of downloading from the network.

    Cache Strategy

    Store cached dependencies in a predictable location. Use the same cache key across builds to maximize cache hits. Invalidate the cache when dependencies change.

    GitHub Actions:

    - name: Cache node modules
      uses: actions/cache@v3
      with:
        path: ~/.npm
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
        restore-keys: |
          ${{ runner.os }}-node-

    GitLab CI:

    cache:
      key: ${CI_COMMIT_REF_SLUG}
      paths:
        - node_modules/

    Jenkins:

    stage('Install Dependencies') {
        steps {
            cache(path: 'node_modules/', key: 'node_modules')
            sh 'npm ci'
        }
    }

    CircleCI:

    - restore_cache:
        keys:
          - node-modules-{{ checksum "package-lock.json" }}
          - node-modules-
     
    - run: npm ci
     
    - save_cache:
        key: node-modules-{{ checksum "package-lock.json" }}
        paths:
          - node_modules/

    Caching reduces dependency installation time from 3 minutes to 30 seconds. That's 2.5 minutes saved per build.

    Incremental Builds

    Full rebuilds waste time rebuilding unchanged code. Incremental builds only rebuild what changed, saving significant time on large projects.

    Use Build Tools with Incremental Support

    Most modern build tools support incremental builds out of the box:

    Webpack:

    module.exports = {
      // Enable caching
      cache: {
        type: 'filesystem',
        buildDependencies: {
          config: [__filename],
        },
      },
      // Use incremental compilation
      optimization: {
        moduleIds: 'deterministic',
        runtimeChunk: 'single',
        splitChunks: {
          chunks: 'all',
        },
      },
    };

    Vite:

    // Vite automatically uses incremental builds
    // No configuration needed

    Next.js:

    // Next.js uses incremental compilation by default
    // Configure in next.config.js if needed
    module.exports = {
      experimental: {
        // Enable incremental static regeneration
        isrMemoryCacheSize: 0,
      },
    };

    Maven:

    <project>
      <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.11.0</version>
            <configuration>
              <!-- Incremental compilation -->
              <useIncrementalCompilation>true</useIncrementalCompilation>
            </configuration>
          </plugin>
        </plugins>
      </build>
    </project>

    Gradle:

    tasks.withType(JavaCompile).configureEach {
      options.incremental = true
      options.fork = true
    }

    Incremental builds reduce build time from 15 minutes to 5 minutes by only rebuilding changed modules.

    Artifact Management

    Build artifacts are the output of your build stage—compiled binaries, bundled JavaScript, Docker images, or deployment packages. Proper artifact management ensures artifacts are stored securely, retrieved quickly, and don't accumulate indefinitely.

    Store Artifacts in a Central Repository

    Use a dedicated artifact repository instead of storing artifacts in the CI/CD system's storage. This provides better reliability, access control, and search capabilities.

    GitHub Packages:

    - name: Build and publish
      run: |
        docker build -t myapp:${{ github.sha }} .
        echo ${{ secrets.GITHUB_TOKEN }} | docker login -u ${{ github.actor }} --password-stdin ghcr.io
        docker push ghcr.io/${{ github.repository }}:${{ github.sha }}

    Docker Registry:

    - name: Build and push
      run: |
        docker build -t myapp:${{ github.sha }} .
        docker tag myapp:${{ github.sha }} myapp:latest
        echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin
        docker push myapp:${{ github.sha }}
        docker push myapp:latest

    Nexus Repository:

    - name: Build and deploy
      run: |
        mvn clean package
        curl -u ${{ secrets.NEXUS_USER }}:${{ secrets.NEXUS_PASSWORD }} \
          --upload-file target/myapp.war \
          https://nexus.example.com/repository/maven-releases/com/example/myapp/${{ github.sha }}/myapp-${{ github.sha }}.war

    Artifactory:

    - name: Build and deploy
      run: |
        mvn clean package
        curl -u ${{ secrets.ARTIFACTORY_USER }}:${{ secrets.ARTIFACTORY_PASSWORD }} \
          --upload-file target/myapp.war \
          https://artifactory.example.com/libs-release-local/com/example/myapp/${{ github.sha }}/myapp-${{ github.sha }}.war

    Implement Artifact Retention Policies

    Automatically delete old artifacts to prevent storage bloat. Set retention policies based on your needs:

    • Development builds: Keep last 10 builds
    • Staging builds: Keep last 20 builds
    • Production builds: Keep last 50 builds

    GitHub Actions:

    - name: Delete old artifacts
      uses: actions/github-script@v6
      with:
        script: |
          const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
            owner: context.repo.owner,
            repo: context.repo.repo,
            run_id: context.payload.workflow_run.id,
          });
          const keep = 10;
          if (artifacts.data.artifacts.length > keep) {
            for (let i = keep; i < artifacts.data.artifacts.length; i++) {
              await github.rest.actions.deleteArtifact({
                owner: context.repo.owner,
                repo: context.repo.repo,
                artifact_id: artifacts.data.artifacts[i].id,
              });
            }
          }

    GitLab CI:

    # In .gitlab-ci.yml
    artifacts:
      expire_in: 30 days
      when: always

    Jenkins:

    stage('Archive Artifacts') {
        steps {
            archiveArtifacts artifacts: '**/*.war', fingerprint: true
        }
    }

    CircleCI:

    - store_artifacts:
        path: target/myapp.war
        destination: myapp.war

    Artifact management reduces storage costs by 70% and improves artifact retrieval speed by 90%.

    Build Time Monitoring

    You can't improve what you don't measure. Track build times to identify bottlenecks and measure the impact of optimizations.

    Define Build Time Metrics

    Track these key metrics:

    • Total build time: End-to-end build duration
    • Stage duration: Time spent in each build stage
    • Task duration: Time spent in individual tasks
    • Cache hit rate: Percentage of cache hits
    • Parallelization efficiency: Actual parallelism vs. theoretical maximum

    Implement Build Time Tracking

    GitHub Actions:

    - name: Build
      run: npm run build
      env:
        CI: true
      continue-on-error: false
     
    - name: Report build time
      run: |
        echo "Build completed in $SECONDS seconds"

    GitLab CI:

    build:
      stage: build
      script:
        - echo "Starting build..."
        - time npm run build
        - echo "Build completed in $SECONDS seconds"

    Jenkins:

    stage('Build') {
        steps {
            script {
                startTime = System.currentTimeMillis()
                sh 'npm run build'
                duration = (System.currentTimeMillis() - startTime) / 1000
                echo "Build completed in ${duration} seconds"
            }
        }
    }

    CircleCI:

    - name: Build
      run: |
        echo "Starting build..."
        time npm run build
        echo "Build completed in $SECONDS seconds"

    Set Build Time Targets

    Define realistic build time targets based on your project size and team velocity:

    Project SizeTarget Build Time
    Small (1-5 modules)5 minutes
    Medium (5-20 modules)15 minutes
    Large (20-50 modules)30 minutes
    Very Large (50+ modules)60 minutes

    Build time monitoring helps you identify when builds are slowing down and measure the effectiveness of optimizations.

    Build Validation Gates

    Build validation gates ensure builds meet quality standards before proceeding to deployment. This prevents bad code from reaching production and reduces deployment failures.

    Implement Quality Gates

    Quality gates can include:

    • Code coverage thresholds: Minimum percentage of code covered by tests
    • Linting rules: Enforce coding standards
    • Static analysis: Detect potential bugs and vulnerabilities
    • Build success: Ensure all tests pass
    • Artifact size limits: Prevent oversized artifacts

    GitHub Actions:

    - name: Run tests
      run: npm test -- --coverage
     
    - name: Check coverage
      run: |
        COVERAGE=$(npm run test:coverage -- --silent | grep "All files" | awk '{print $4}' | sed 's/%//')
        if (( $(echo "$COVERAGE < 80" | bc -l) )); then
          echo "Coverage $COVERAGE% is below 80%"
          exit 1
        fi

    GitLab CI:

    test:
      stage: test
      script:
        - npm test
      coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
      artifacts:
        reports:
          coverage_report:
            coverage_format: cobertura
            path: coverage/cobertura-coverage.xml
      rules:
        - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

    Jenkins:

    stage('Test') {
        steps {
            sh 'npm test'
            script {
                def coverage = sh(
                    script: 'npm run test:coverage -- --silent | grep "All files" | awk \'{print $4}\' | sed \'s/%//\'',
                    returnStdout: true
                ).trim()
                if (coverage.toInteger() < 80) {
                    error "Coverage $coverage% is below 80%"
                }
            }
        }
    }

    CircleCI:

    - name: Run tests
      run: npm test -- --coverage
     
    - name: Check coverage
      run: |
        COVERAGE=$(npm run test:coverage -- --silent | grep "All files" | awk '{print $4}' | sed 's/%//')
        if (( $(echo "$COVERAGE < 80" | bc -l) )); then
          echo "Coverage $COVERAGE% is below 80%"
          exit 1
        fi

    Quality gates prevent 60% of bugs from reaching production and reduce deployment failures by 40%.

    Build Environment Consistency

    Inconsistent build environments cause flaky builds. Different dependency versions, compiler settings, or operating systems can produce different results. Consistent environments ensure builds are reproducible.

    Use Containerized Build Environments

    Containerized build environments provide consistent runtime environments across all builds.

    GitHub Actions:

    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
      with:
        node-version: '20'
        cache: 'npm'
    - run: npm ci
    - run: npm test

    GitLab CI:

    image: node:20
     
    build:
      stage: build
      script:
        - npm ci
        - npm test

    Jenkins:

    pipeline {
        agent {
            docker {
                image 'node:20'
                args '-v $HOME/.npm:/root/.npm'
            }
        }
        stages {
            stage('Build') {
                steps {
                    sh 'npm ci'
                    sh 'npm test'
                }
            }
        }
    }

    CircleCI:

    version: 2.1
    jobs:
      build:
        docker:
          - image: node:20
        steps:
          - checkout
          - run: npm ci
          - run: npm test

    Containerized environments reduce flaky builds by 80% and improve build reproducibility by 95%.

    Pin Dependency Versions

    Pin dependency versions to prevent unexpected updates that break builds.

    package.json:

    {
      "dependencies": {
        "express": "^4.18.2",
        "lodash": "^4.17.21",
        "moment": "^2.29.4"
      }
    }

    package-lock.json:

    {
      "name": "my-app",
      "version": "1.0.0",
      "lockfileVersion": 3,
      "requires": true,
      "packages": {
        "node_modules/express": {
          "version": "4.18.2",
          "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
          "integrity": "sha512-88/2jFJjyqnbLfqJpQZG1VcDv27at9hB7kU3V6esEbaS0a4T+rMxZ6vXO8+u5rKmNP3Hv+R7O/8mTm5D8zCSzWg=="
        }
      }
    }

    Pinning dependency versions ensures consistent builds across all environments.

    Build Notifications

    Timely build notifications keep your team informed about build status. This enables quick responses to failures and reduces the time between code changes and feedback.

    Configure Build Notifications

    GitHub Actions:

    - name: Notify on failure
      if: failure()
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        text: 'Build failed: ${{ github.repository }}'
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}

    GitLab CI:

    build:
      stage: build
      script:
        - npm ci
        - npm test
      artifacts:
        when: always
      notify:
        - slack:
            channel: '#builds'
            url: ${{ secrets.SLACK_WEBHOOK }}

    Jenkins:

    stage('Build') {
        steps {
            script {
                try {
                    sh 'npm ci'
                    sh 'npm test'
                } catch (Exception e) {
                    slackSend(
                        color: 'danger',
                        message: "Build failed: ${env.JOB_NAME} #${env.BUILD_NUMBER}\n${e.getMessage()}"
                    )
                    throw e
                }
            }
        }
    }

    CircleCI:

    - name: Notify on failure
      if: failure()
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        text: 'Build failed: ${{ github.repository }}'
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}

    Build notifications reduce mean time to recovery (MTTR) by 50% by alerting the team immediately.

    Build Optimization Checklist

    Use this checklist to optimize your build stage:

    • Parallel execution: Run independent tasks simultaneously
    • Dependency caching: Cache dependencies to avoid reinstallation
    • Incremental builds: Only rebuild changed code
    • Artifact management: Store artifacts in a central repository
    • Build time monitoring: Track build times and identify bottlenecks
    • Quality gates: Enforce quality standards before deployment
    • Environment consistency: Use containerized build environments
    • Build notifications: Alert the team on build failures

    Conclusion

    The build stage is the heart of your CI/CD pipeline. Optimizing it improves developer velocity, reduces deployment failures, and ensures code quality. Start by measuring your current build times and identifying bottlenecks. Then implement these best practices one at a time, measuring the impact of each change.

    Remember: the goal isn't to make builds faster at the expense of quality. The goal is to make builds faster while maintaining or improving quality. Every optimization should be measured and justified.

    Platforms like ServerlessBase simplify build stage management by providing pre-configured CI/CD pipelines with built-in best practices. You can focus on writing code while the platform handles the build stage optimization.

    The next step is to audit your current build stage and identify the top three optimizations to implement first. Start with parallel execution and dependency caching—these two optimizations typically provide the biggest immediate impact.

    Leave comment