Laptop to Production

Continuous integration

Lint, test, build, scan, and a plan on every pull request.

Time
75 minutes
Level
Intermediate

Part 1: The problem

QuickCart, a broken main

Sam changed how the delivery fee was calculated. The tests passed on his laptop. The pull request looked small, Noor approved it, and he merged it on a Wednesday afternoon.

On Thursday morning, three other engineers pulled main and nothing worked. Sam’s laptop had an old copy of a file the tests depended on; on a clean checkout, two tests failed. Every branch built on top of Wednesday’s main was now broken too. It took until lunch to find which change had done it.

Later that month, a scan by the security team found a critical vulnerability in the base image of the orders service. It had been in production for six weeks. Nobody had checked.

Part 2: The idea

Continuous integration, or CI, means every change is checked automatically, the same way, before it joins main. Not on someone’s laptop, but on a clean agent, from the exact commit that will be merged.

Key point: Every change is checked the same way before it can join main. Each stage is a gate: fail one, and the change stops there.

What runs, and in what order

QuickCart’s pipeline has five gates. They are ordered cheapest first, so the most common mistakes are reported in seconds.

StageChecksTakes aboutTool
LintThe Dockerfile follows good practice; the Terraform is formattedSecondshadolint, terraform fmt -check
Unit testsThe code does what the tests say it shouldA minutenode --test here; whatever your language uses
Build imageThe image builds from this commitA couple of minutesdocker build
Scan imageNo known critical vulnerabilities in the image’s packagesUnder a minutetrivy, or ECR’s own scanning
Terraform planWhat this change would do to AWS. Pull requests onlyUnder a minuteterraform plan, posted as a comment

Fail fast means three things. Put quick checks first. Stop at the first failure; declarative pipelines do this for you. And when someone pushes again to the same pull request, abort the build that’s still running for the old commit, because its answer no longer matters. disableConcurrentBuilds(abortPrevious: true) does that last one.

One job per branch: multibranch pipelines

A multibranch pipeline is a Jenkins job that looks at a repository and makes a job for every branch and every pull request that has a Jenkinsfile. Each one builds with the Jenkinsfile from its own branch. New branch, new job; branch deleted, job removed.

Jenkins seesIt buildsBRANCH_NAME
The main branchEvery commit on mainmain
A pull request, number 42The pull request merged with the current main, so you test what main would becomePR-42
A branch with an open pull requestNothing extra. By default, the pull request job covers it—

The GitHub Branch Source plugin, part of the suggested plugins, does this for GitHub. It also reports each build’s result back to GitHub as a status check on the commit. For a pull request, the check is called continuous-integration/jenkins/pr-merge.

How Jenkins hears about a push: webhooks

Jenkins can ask GitHub every few minutes whether anything changed. That’s called polling, and it’s slow and wasteful. A webhook is the other way round: the moment someone pushes, GitHub sends a message to Jenkins.

For QuickCart’s Jenkins on EC2, that means GitHub must be able to reach Jenkins. So the controller gets a load balancer with HTTPS in front of it, whose security group only accepts connections from GitHub’s webhook address ranges, which GitHub publishes. Then, in the repository on GitHub:

Settings → Webhooks → Add webhook

FieldEnter
Payload URLhttps://jenkins.example.com/github-webhook/
Content typeapplication/json
Secreta long random value, also entered in Jenkins’ GitHub settings in Manage Jenkins → System
Which eventsLet me select individual events: Pushes and Pull requests

The /github-webhook/ path is where the GitHub plugin in Jenkins listens. The secret lets Jenkins check that each message really came from GitHub.

In this lesson’s lab, Jenkins runs on your computer, which GitHub can’t reach. So you’ll use a periodic scan instead: Jenkins checks GitHub every minute. It works the same, just a minute slower.

The gate must be required

A red check does nothing by itself. In GitHub, a ruleset on main makes it binding:

RuleWhat it does
Require a pull request before mergingNobody pushes straight to main
Require status checks to passThe merge button stays grey until the Jenkins check is green

Terraform plan on every pull request

For infrastructure changes, the most useful thing CI can do is run terraform plan and post the result on the pull request. Reviewers then read what will actually change in AWS, not what the author thinks will change. In a real setup, the plan stage uses a read-only AWS role and the shared S3 state from Terraform state. Nothing is applied until after the merge.

Caching: faster without being wrong

CacheHowSaves
Docker layersA long-lived agent keeps layers between builds. On short-lived agents, pull the last image and pass --cache-fromRebuilding unchanged layers. Writing a Dockerfile shows why order matters
DependenciesKeep the package manager’s download folder, like ~/.npm, on the agent between buildsDownloading every library on every build
Terraform providersSet TF_PLUGIN_CACHE_DIR to a folder on the agentDownloading the AWS provider, which is large, on every init
Trivy’s databaseIt’s kept in ~/.cache/trivy and refreshed when it’s oldDownloading the vulnerability list every time

Cache things that are downloaded, never things that are checked. A cached test result is a test that didn’t run.

Shared libraries: write it once

With ten services, ten Jenkinsfiles grow the same lint stage, each slightly different. A shared library is a git repository of pipeline code that every Jenkinsfile can load. Every Groovy file in its vars/ folder becomes a step with the file’s name.

vars/quickcartLint.groovy
def call(Map config = [:]) {
    String dockerfile = config.get('dockerfile', 'Dockerfile')
    String terraformDir = config.get('terraformDir', 'infra')
    sh "hadolint '${dockerfile}'"
    sh "terraform fmt -check -recursive '${terraformDir}'"
}
LineWhat it means
def call(Map config = [:])What runs when a Jenkinsfile writes quickcartLint(). It takes named settings, and none are required
config.get('dockerfile', 'Dockerfile')Use the dockerfile setting if one was given, otherwise Dockerfile
sh "hadolint '${dockerfile}'"Double quotes, because Groovy should fill in the file name. It’s not a secret, so that’s safe

Register the library once, in Manage Jenkins → System → Global Trusted Pipeline Libraries: a name, quickcart-pipeline-lib, a default version, and the repository. Then any Jenkinsfile can use it:

Jenkinsfile
@Library('quickcart-pipeline-lib@v1') _
pipeline {
    agent any
    stages {
        stage('Lint') {
            steps {
                quickcartLint(terraformDir: 'terraform')
            }
        }
    }
}
LineWhat it means
@Library('quickcart-pipeline-lib@v1') _Load the library, at the git tag v1. The _ is required Groovy punctuation when nothing else follows on that line
quickcartLint(terraformDir: 'terraform')Call the step, with this repository’s Terraform folder

Pin a version, like the tag v1, so a change to the library can’t break every service’s build at once. Libraries registered here are trusted: their code runs with full access to Jenkins, so changes to the library repository need the same review as changes to Jenkins itself.

Flaky tests

A flaky test sometimes passes and sometimes fails, with no change to the code. Usually it depends on timing, on the order tests run in, or on something outside the test such as the network. Google has written publicly about how much time flaky tests cost it, even with its tooling. Flaky tests are dangerous because they teach people that red doesn’t mean anything.

ResponseEffect
retry(3) around the testsHides it, and hides real failures with it. Don’t
Press Rebuild until greenThe same, by hand
Quarantine: move it to a separate, non-blocking stage, with an owner and a deadlineThe main suite stays trustworthy, and the flaky test is still tracked
Fix the cause: wait for a condition instead of sleeping, give each test its own dataThe real answer

Keep retry for things that genuinely fail at random and are safe to repeat, like downloading a file. Never for a test.

Part 3: See it

Below is the pipeline a QuickCart pull request goes through. Push a commit that has no problems. Then choose A failing test and push again, and try the others.

The commit has
A pull request's pipeline: a commit passes through checkout, lint, unit tests, build image, scan image and Terraform plan. At the end, the pull request can be merged only if every stage passed.Pull request #42: every push runs every stage, in orderDevelopergit pushCheckout5 sLint15 sUnit tests1 minBuild image2 minScan image40 sTerraform plan30 sMergewaiting for checksTime since push: 0 s
ReadyPick what's in the commit, then choose Push a commit.

What to notice: a commit stops at the first gate it fails, and nothing after that gate runs. The cheaper the gate, the sooner you know. A Dockerfile mistake is reported 20 seconds after the push; a vulnerability, only after the image is built.

Part 4: Do it

Free — Jenkins and every tool run on your computer, and a public GitHub repository is free. No AWS.

You’ll put QuickCart’s orders service in a GitHub repository, point a multibranch pipeline at it, open a pull request that gets a Terraform plan posted on it, then break a test and watch the merge get blocked. About 60 minutes.

You need the Jenkins on your computer from The Jenkinsfile, a GitHub account, and Docker running.

  1. Install the tools the pipeline uses

    The pipeline runs on your computer, so the tools it calls must be installed there:

    Terminal
    brew install node hadolint trivy gh
    PartWhat it means
    brew installinstall with Homebrew
    noderuns the orders service and its tests
    hadolintchecks Dockerfiles for common mistakes
    trivyscans images for known vulnerabilities
    Terraformfor the plan. You installed it from HashiCorp’s tap in Set up your computer; Homebrew’s plain terraform is an old version
    ghGitHub’s command-line tool, used to post the plan on the pull request

    Jenkins only finds tools that are on the PATH of the terminal it was started from. So stop Jenkins with Ctrl+C in its terminal, and start it again in a new terminal window:

    Terminal
    jenkins-lts --httpListenAddress=127.0.0.1 --httpPort=8080
    PartWhat it means
    jenkins-ltsstart Jenkins, which now inherits a PATH that includes the new tools
    --httpListenAddress=127.0.0.1only this computer can reach it
    --httpPort=8080the port for the web page
  2. Create the repository and a token

    On GitHub, choose New repository:

    FieldEnter
    Repository namequickcart-orders
    VisibilityPublic. Required-check rules are free on public repositories
    Add a README fileticked

    Choose Create repository. Copy its HTTPS address from the Code button; the next steps call it REPO_URL.

    Jenkins needs a token to read the repository, post comments and report checks. On GitHub, go to your profile picture → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token:

    FieldEnter
    Token namejenkins-quickcart
    Expiration30 days
    Repository accessOnly select repositories: quickcart-orders
    Permissions → ContentsRead-only
    Permissions → Pull requestsRead and write
    Permissions → Commit statusesRead and write

    Choose Generate token and copy it. GitHub shows it only once.

  3. Add the code

    Clone the repository into your home folder and move into it:

    Terminal
    git clone REPO_URL
    PartWhat it means
    git clonecopy a repository from GitHub to your computer
    REPO_URLthe HTTPS address you copied
    Terminal
    cd quickcart-orders
    PartWhat it means
    cd quickcart-ordersmove into the copy

    Create these files, in these folders. First the code, a tiny function that adds up an order:

    src/total.mjs
    export function orderTotal(items, deliveryFeeCents) {
      const subtotal = items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);
      return subtotal + deliveryFeeCents;
    }

    Its tests, using the test runner built into Node:

    test/total.test.mjs
    import { test } from "node:test";
    import assert from "node:assert/strict";
    import { orderTotal } from "../src/total.mjs";
    
    const items = [
      { priceCents: 450, quantity: 2 },
      { priceCents: 100, quantity: 1 },
    ];
    
    test("orderTotal adds up the items", () => {
      assert.equal(orderTotal(items, 0), 1000);
    });
    
    test("orderTotal adds the delivery fee", () => {
      assert.equal(orderTotal(items, 250), 1250);
    });

    A small web server around it, which the next lesson deploys:

    server.mjs
    import { createServer } from "node:http";
    import { orderTotal } from "./src/total.mjs";
    
    const version = process.env.APP_VERSION || "dev";
    
    const server = createServer((req, res) => {
      res.setHeader("Content-Type", "application/json");
      if (req.url === "/health") {
        res.end(JSON.stringify({ status: "ok", version }));
        return;
      }
      const example = orderTotal([{ priceCents: 450, quantity: 2 }], 250);
      res.end(JSON.stringify({ service: "orders", version, exampleTotalCents: example }));
    });
    
    server.listen(8080, () => {
      console.log(`orders ${version} listening on port 8080`);
    });

    Its Dockerfile:

    Dockerfile
    FROM node:24-alpine
    WORKDIR /app
    COPY src ./src
    COPY server.mjs ./
    ENV NODE_ENV=production
    USER 1000
    EXPOSE 8080
    CMD ["node", "server.mjs"]

    USER 1000 runs the server as the image’s built-in node user, by number rather than name, so any system can resolve it. Writing a Dockerfile explains every other line.

    And a small piece of Terraform, in a folder called infra. It uses terraform_data, a resource built into Terraform that stores values and creates nothing in AWS, so the plan runs without an AWS login:

    main.tf
    terraform {
      required_version = ">= 1.10.0"
    }
    
    variable "environment" {
      description = "Which environment this plan is for"
      type        = string
      default     = "dev"
    }
    
    resource "terraform_data" "orders_service" {
      input = {
        environment   = var.environment
        desired_count = 2
        cpu           = 256
      }
    }
    SettingWhat it doesIf you leave it outWhen you’d change it
    required_versionRefuse Terraform older than 1.10Any version is acceptedWhen you rely on newer features
    variable "environment" → descriptionA note for whoever sets itNo note—
    type = stringOnly text is acceptedAny type is accepted—
    default = "dev"The value if nobody sets oneTerraform asks for it, and the pipeline has nobody to answer—
    terraform_data → inputValues stored in state, standing in for a real ECS service’s settingsNothing is storedThis is what the pull request will change

    Before you push, run the same checks the pipeline will run:

    Terminal
    node --test
    PartWhat it means
    node --testfind files named like *.test.mjs and run the tests in them
    You should see
    ✔ orderTotal adds up the items (0.79ms)
    ✔ orderTotal adds the delivery fee (0.14ms)
    ℹ tests 2
    ℹ suites 0
    ℹ pass 2
    ℹ fail 0

    Your times will differ.

  4. Add the Jenkinsfile and push

    In the root of the repository:

    Jenkinsfile
    pipeline {
        agent any
    
        options {
            timeout(time: 20, unit: 'MINUTES')
            timestamps()
            disableConcurrentBuilds(abortPrevious: true)
            buildDiscarder(logRotator(numToKeepStr: '30'))
        }
    
        environment {
            TF_PLUGIN_CACHE_DIR = "${env.HOME}/.terraform.d/plugin-cache"
        }
    
        stages {
            stage('Lint') {
                steps {
                    sh 'hadolint Dockerfile'
                    sh 'terraform fmt -check -recursive infra'
                }
            }
    
            stage('Unit tests') {
                steps {
                    sh 'node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=test-results.xml'
                }
                post {
                    always {
                        junit 'test-results.xml'
                    }
                }
            }
    
            stage('Build image') {
                steps {
                    sh 'docker build -t "quickcart-orders:$GIT_COMMIT" .'
                }
            }
    
            stage('Scan image') {
                steps {
                    sh 'trivy image --exit-code 1 --severity CRITICAL --ignore-unfixed --no-progress "quickcart-orders:$GIT_COMMIT"'
                }
            }
    
            stage('Terraform plan') {
                when {
                    changeRequest()
                }
                steps {
                    dir('infra') {
                        sh 'mkdir -p "$TF_PLUGIN_CACHE_DIR"'
                        sh 'terraform init -input=false -no-color'
                        sh 'terraform plan -input=false -no-color -out=tfplan'
                        sh 'terraform show -no-color tfplan > plan.txt'
                    }
                    withCredentials([usernamePassword(credentialsId: 'github-token', usernameVariable: 'GH_USER', passwordVariable: 'GH_TOKEN')]) {
                        sh 'gh pr comment "$CHANGE_ID" --body-file infra/plan.txt'
                    }
                }
            }
        }
    
        post {
            failure {
                echo "Build failed: ${env.BUILD_URL}"
            }
            cleanup {
                deleteDir()
            }
        }
    }
    LineWhat it means
    disableConcurrentBuilds(abortPrevious: true)A new push to the same branch aborts the build still running for the old commit
    TF_PLUGIN_CACHE_DIR = "${env.HOME}/…"Keep downloaded Terraform providers in one folder that every build reuses. Groovy fills in your home folder
    hadolint DockerfileLint the Dockerfile. Any finding fails the stage
    terraform fmt -check -recursive infraFail if any .tf file in infra isn’t formatted. -check reports without changing files
    node --test --test-reporter=spec --test-reporter-destination=stdoutRun the tests, printing readable results in the log
    --test-reporter=junit --test-reporter-destination=test-results.xmlAlso write the results as JUnit XML, the format Jenkins understands
    junit 'test-results.xml' in post → alwaysRead the results even when tests failed, so the build page lists which ones
    docker build -t "quickcart-orders:$GIT_COMMIT" .Build the image, tagged with the commit being built. GIT_COMMIT is set by Jenkins’ checkout
    trivy imageScan the image’s operating system packages and libraries against a list of known vulnerabilities
    --exit-code 1 --severity CRITICALFail the stage only for critical findings. Start strict and narrow; widen it to HIGH once you’re clean
    --ignore-unfixedSkip vulnerabilities that have no fix yet, since there’s nothing you could upgrade to
    --no-progressNo progress bar, which only clutters a build log
    when { changeRequest() }The plan runs only for pull requests
    dir('infra') { }Run the steps inside from the infra folder
    terraform plan … -out=tfplanWork out the plan and save it to a file
    terraform show -no-color tfplan > plan.txtTurn the saved plan into readable text. > writes that text into plan.txt instead of the log
    usernamePassword(credentialsId: 'github-token', …)Fetch the GitHub token you’ll store in Jenkins, into GH_TOKEN, which gh reads
    gh pr comment "$CHANGE_ID" --body-file infra/plan.txtComment on the pull request whose number is in CHANGE_ID, with the plan as the text

    Commit and push it all to main:

    Terminal
    git add .
    PartWhat it means
    git add .include every new file in the next commit
    Terminal
    git commit -m "Orders service with a CI pipeline"
    PartWhat it means
    git commit -m "…"record the files, with this message
    Terminal
    git push
    PartWhat it means
    git pushsend the commit to GitHub
  5. Create the multibranch pipeline

    In Jenkins, choose New Item:

    FieldEnter
    Enter an item namequickcart-orders
    TypeMultibranch Pipeline

    Choose OK. Under Branch Sources, choose Add source → GitHub. Next to Credentials, choose Add → Jenkins:

    FieldEnter
    KindUsername with password
    Usernameyour GitHub username
    Passwordthe token you generated
    IDgithub-token
    DescriptionGitHub token for quickcart-orders

    Choose Add, then pick the new credential in the Credentials list and fill in the rest:

    FieldEnter
    Repository HTTPS URLyour REPO_URL
    BehavioursLeave Discover branches and Discover pull requests from origin. Delete Discover pull requests from forks
    Build Configuration → Modeby Jenkinsfile, Script Path Jenkinsfile
    Scan Multibranch Pipeline TriggersTick Periodically if not otherwise run, Interval 1 minute

    Choose Save.

    You should see

    The Scan Repository Log finds the main branch and its Jenkinsfile, and schedules a build. After a minute or two, the job shows a main job with build #1. Its stages all pass except Terraform plan, which is skipped, because main isn’t a pull request. The first run is slow while Trivy downloads its vulnerability database.

  6. Open a pull request and get a plan

    Make a branch:

    Terminal
    git switch -c more-orders-capacity
    PartWhat it means
    git switch -ccreate a branch and move to it
    more-orders-capacityits name

    In infra/main.tf, change desired_count = 2 to desired_count = 3. Commit and push the branch:

    Terminal
    git commit -am "Run three orders tasks"
    PartWhat it means
    git commitrecord a change
    -ainclude every file git already knows that you’ve changed
    -m "…"the message
    Terminal
    git push -u origin more-orders-capacity
    PartWhat it means
    git pushsend it to GitHub
    -u origin more-orders-capacitycreate the branch on GitHub too, and remember the link

    On GitHub, choose Compare & pull request, then Create pull request.

    You should see

    Within a minute or two, Jenkins shows a Pull Requests tab with PR-1. On GitHub, the pull request gets a comment from your account containing the plan, and a check called continuous-integration/jenkins/pr-merge that turns green.

    The plan comment includes
      # terraform_data.orders_service will be created
      + resource "terraform_data" "orders_service" {
          + id     = (known after apply)
          + input  = {
              + cpu           = 256
              + desired_count = 3
              + environment   = "dev"
            }
          + output = (known after apply)
        }
    
    Plan: 1 to add, 0 to change, 0 to destroy.

    It says create because this lab’s Terraform has no shared state, so every plan starts from nothing. With the S3 state from Terraform state, the same change would show ~ update in-place and only the desired_count line.

  7. Make the check required

    On GitHub, in the repository, go to Settings → Rules → Rulesets → New ruleset → New branch ruleset:

    FieldEnter
    Ruleset Nameprotect-main
    Enforcement statusActive
    Target branchesAdd target → Include default branch
    Require a pull request before mergingticked
    Require status checks to passticked. Add checks → continuous-integration/jenkins/pr-merge

    Choose Create.

  8. Break a test

    On the same branch, in src/total.mjs, change the last line of the function to forget the fee:

    src/total.mjs
    export function orderTotal(items, deliveryFeeCents) {
      const subtotal = items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);
      return subtotal;
    }
    Terminal
    git commit -am "Simplify the total"
    PartWhat it means
    git commit -am "…"record the change to a file git already knows, with this message
    Terminal
    git push
    PartWhat it means
    git pushsend it. The branch already knows where to go
    Build #2 of PR-1 includes
    ✖ orderTotal adds the delivery fee
      AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
    
      1000 !== 1250
    
    Stage "Build image" skipped due to earlier failure(s)
    Stage "Scan image" skipped due to earlier failure(s)
    Stage "Terraform plan" skipped due to earlier failure(s)
    Finished: FAILURE
    You should see

    On GitHub, the check is red and the merge button says merging is blocked. On the build page in Jenkins, Test Result lists the one failing test by name, because junit read the report.

    Put the + deliveryFeeCents back, commit and push. The check goes green and the pull request can be merged. Merge it.

  9. Clean-up step: Clean up

    Nothing here costs money. Keep the repository, the token and the quickcart-orders job: Continuous delivery builds on them.

    When you’ve finished with them, delete the job in Jenkins (quickcart-orders → Delete Multibranch Pipeline), delete the token on GitHub (Settings → Developer settings → Fine-grained tokens → Delete), and free the disk space the test images use:

    Terminal
    docker image prune -a
    PartWhat it means
    docker image prunedelete images no container is using
    -aall unused images, not only untagged ones. Docker asks you to confirm

Part 5: Check yourself

A pull request's build fails at Lint. What happens to Unit tests, Build image and Scan image?

CI is red on a pull request, but someone merges it anyway. What was missing?

Why does QuickCart's pipeline run hadolint and terraform fmt before the unit tests and the image build?

A test fails about one run in twenty, for no clear reason. What should the team do?

Where does the code in a shared library's vars/quickcartLint.groovy get used?

Why are Terraform plans posted on pull requests, rather than applied?

Part 6: Cheat sheet

On one card
ThingRemember
CIEvery change checked the same way, on a clean agent, before it joins main
OrderCheapest first: lint, tests, build, scan, plan
Fail fastFirst failure stops the rest. disableConcurrentBuilds(abortPrevious: true)
MultibranchA job per branch and pull request. PR builds test the merge with main
Webhookhttps://jenkins.example.com/github-webhook/, with a secret. Polling is the slow fallback
Required checkGitHub ruleset on main: continuous-integration/jenkins/pr-merge must pass
Linthadolint Dockerfile, terraform fmt -check -recursive
TestsWrite JUnit XML; junit in post → always
Scantrivy image --exit-code 1 --severity CRITICAL --ignore-unfixed
Plan on PRRead-only role, shared state, gh pr comment "$CHANGE_ID" --body-file plan.txt
CacheDownloads (layers, packages, providers). Never results
Shared libraryvars/name.groovy → step name(). Load with @Library('lib@v1') _
Flaky testQuarantine with an owner. Never retry a test

Finished the build and the quiz? Mark it done to fill in its stop on the route map.