Build End-to-End CI/CD with Jenkins, SonarQube, and Docker
Build a Jenkins pipeline on AWS that tests, scans with SonarQube and Trivy, pushes to ECR and deploys to EC2 on every git push to main.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds a Jenkins CI/CD pipeline on AWS that runs unit tests, checks code quality with SonarQube, scans the Docker image with Trivy, pushes it to Amazon ECR and deploys it to an EC2 server. Every git push to main triggers the whole chain automatically, and a failing test, quality gate or security scan stops the release before anything reaches the server.
The problem this pipeline solves
Without a pipeline, a deployment means logging into a server, pulling code, building by hand and hoping nothing breaks. Every release is slightly different, nobody checks quality or security, and there is no record of what was deployed or when.
Picture a product team at a company like Shopify or Razorpay shipping several times a day. They cannot rely on one person remembering a checklist. The checklist has to be code that runs on every commit. That is continuous integration and delivery (CI/CD), and Jenkins is the long-standing, self-hosted engine for it. Many teams that run on EC2 rather than Kubernetes still use exactly this stack.
What you will build
The pipeline has these stages, in order:
- Checkout the code and compute an image tag from the build number and commit
- Run unit tests with coverage inside a Node.js container
- Analyse the code with SonarQube and wait for the quality gate
- Build the Docker image
- Scan the image with Trivy and fail on fixable HIGH or CRITICAL issues
- Push the image to ECR
- Deploy to the app server over SSH and run a smoke test
What you will have at the end
- A GitHub repository with an app, a
Dockerfile, aJenkinsfileand a Sonar configuration - Screenshots of a green pipeline run, a passing SonarQube quality gate and an image in ECR
- A second run that is deliberately blocked by the quality gate, which proves the controls really work
Prerequisites and cost
You need an AWS account, the AWS CLI configured, a GitHub account, jq and basic Docker and Linux skills. The lab uses two t3.medium instances and one t3.micro instance in us-east-1. They bill by the hour, so run the cleanup at the end of the Validation step.
Milestone 1: Provision the Servers, Roles and ECR Repository
This milestone creates the network rules, IAM roles, three EC2 instances and the ECR repository that the rest of the project depends on.
Jenkins, SonarQube and the app each run on their own server so a crash or memory spike in one cannot take down the others. Instead of storing AWS access keys inside Jenkins, you attach an IAM role to the instances. The role gives the instance temporary credentials automatically, so there is no long-lived secret to leak.
export AWS_REGION=us-east-1export AWS_DEFAULT_REGION=$AWS_REGION MYIP="$(curl -s https://checkip.amazonaws.com | tr -d '\n')/32"VPC_ID=$(aws ec2 describe-vpcs --filters Name=isDefault,Values=true \ --query 'Vpcs[0].VpcId' --output text) # Latest Ubuntu 24.04 image, looked up so you never hardcode an AMIAMI=$(aws ssm get-parameter \ --name /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \ --query Parameter.Value --output text) aws ec2 create-key-pair --key-name devops-ci-key \ --query KeyMaterial --output text > devops-ci-key.pemchmod 400 devops-ci-key.pemCreate the three security groups, then open only what each server needs:
JENKINS_SG=$(aws ec2 create-security-group --group-name jenkins-sg \ --description "Jenkins" --vpc-id "$VPC_ID" --query GroupId --output text)SONAR_SG=$(aws ec2 create-security-group --group-name sonarqube-sg \ --description "SonarQube" --vpc-id "$VPC_ID" --query GroupId --output text)APP_SG=$(aws ec2 create-security-group --group-name app-sg \ --description "App server" --vpc-id "$VPC_ID" --query GroupId --output text) # Jenkins: SSH and UI from you, and the UI also from SonarQube (quality gate callback)aws ec2 authorize-security-group-ingress --group-id "$JENKINS_SG" --ip-permissions \ "IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=$MYIP}]" \ "IpProtocol=tcp,FromPort=8080,ToPort=8080,IpRanges=[{CidrIp=$MYIP}]" \ "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=$SONAR_SG}]" # GitHub sends webhooks from published IP ranges, so allow only those on 8080for cidr in $(curl -s https://api.github.com/meta | jq -r '.hooks[]' | grep -v ':'); do aws ec2 authorize-security-group-ingress --group-id "$JENKINS_SG" \ --protocol tcp --port 8080 --cidr "$cidr" > /dev/nulldone # SonarQube: SSH and UI from you, analysis traffic from Jenkinsaws ec2 authorize-security-group-ingress --group-id "$SONAR_SG" --ip-permissions \ "IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=$MYIP}]" \ "IpProtocol=tcp,FromPort=9000,ToPort=9000,IpRanges=[{CidrIp=$MYIP}]" \ "IpProtocol=tcp,FromPort=9000,ToPort=9000,UserIdGroupPairs=[{GroupId=$JENKINS_SG}]" # App server: SSH from Jenkins and from you, smoke tests from Jenkins, web traffic from youaws ec2 authorize-security-group-ingress --group-id "$APP_SG" --ip-permissions \ "IpProtocol=tcp,FromPort=22,ToPort=22,UserIdGroupPairs=[{GroupId=$JENKINS_SG}]" \ "IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=$MYIP}]" \ "IpProtocol=tcp,FromPort=80,ToPort=80,UserIdGroupPairs=[{GroupId=$JENKINS_SG}]" \ "IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=$MYIP}]"Create the two IAM roles. Jenkins can push images; the app server can only pull them:
cat > ec2-trust.json <<'EOF'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}EOF for role in jenkins-ci-role app-server-role; do aws iam create-role --role-name "$role" \ --assume-role-policy-document file://ec2-trust.json > /dev/null aws iam create-instance-profile --instance-profile-name "$role" > /dev/null aws iam add-role-to-instance-profile --instance-profile-name "$role" --role-name "$role"done aws iam attach-role-policy --role-name jenkins-ci-role \ --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUseraws iam attach-role-policy --role-name app-server-role \ --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly sleep 15 # let the instance profiles propagateLaunch the instances and create the registry. Tags in ECR are set to immutable so a pushed version can never be overwritten, which is what makes rollbacks trustworthy:
launch() { # name, type, security group, disk size, optional instance profile local profile=() [ -n "$5" ] && profile=(--iam-instance-profile "Name=$5") aws ec2 run-instances --image-id "$AMI" --instance-type "$2" \ --key-name devops-ci-key --security-group-ids "$3" "${profile[@]}" \ --metadata-options HttpTokens=required \ --block-device-mappings "DeviceName=/dev/sda1,Ebs={VolumeSize=$4,VolumeType=gp3,Encrypted=true}" \ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=$1}]" \ --query 'Instances[0].InstanceId' --output text} launch jenkins-server t3.medium "$JENKINS_SG" 30 jenkins-ci-rolelaunch sonarqube-server t3.medium "$SONAR_SG" 30launch app-server t3.micro "$APP_SG" 20 app-server-role aws ecr create-repository --repository-name ci-demo-app \ --image-tag-mutability IMMUTABLE \ --image-scanning-configuration scanOnPush=true \ --query 'repository.repositoryUri' --output text# Expected: ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com/ci-demo-appTipThe SonarQube server gets no instance profile at all, because it never calls AWS APIs. Giving each server only the access it needs keeps one compromised machine from reaching the registry.
Note the public and private IP of each instance. You will use public IPs to reach the web interfaces and private IPs for server-to-server traffic:
aws ec2 describe-instances \ --filters Name=instance-state-name,Values=running \ --query 'Reservations[].Instances[].[Tags[?Key==`Name`]|[0].Value,PublicIpAddress,PrivateIpAddress]' \ --output tableSecurityNever open 8080 or 9000 to
0.0.0.0/0. Both dashboards can run code or expose source, and automated scanners find open ones within hours. This setup allows your own IP, the GitHub webhook ranges, and the other servers by security group.
Common MistakeRestricting Jenkins to your IP only, then wondering why the GitHub webhook never fires. GitHub calls Jenkins from its own ranges. The
api.github.com/metaloop above adds them. GitHub changes that list over time, so refresh it if webhooks stop working.
Milestone 2: Install Jenkins, Docker, AWS CLI and Trivy
This milestone turns the Jenkins server into a build machine with everything the pipeline calls: Java, Jenkins, Docker, the AWS CLI and Trivy.
Jenkins is a Java application, so it needs a Java runtime first. Jenkins also runs docker build and trivy as shell commands, so those tools must exist on the same machine and the jenkins user must be allowed to use Docker.
ssh -i devops-ci-key.pem ubuntu@JENKINS_PUBLIC_IPOn the server:
sudo apt-get updatesudo apt-get install -y fontconfig openjdk-21-jre unzip jq curl gnupg ca-certificatesjava -version# Expected: openjdk version "21..." # Jenkins LTS repository (the signing key is the 2026 key, older guides show a key that no longer works)sudo wget -O /etc/apt/keyrings/jenkins-keyring.asc \ https://pkg.jenkins.io/debian-stable/jenkins.io-2026.keyecho "deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc] https://pkg.jenkins.io/debian-stable binary/" \ | sudo tee /etc/apt/sources.list.d/jenkins.list > /dev/nullsudo apt-get updatesudo apt-get install -y jenkinssudo systemctl enable --now jenkins # Docker, with the jenkins user allowed to use itsudo apt-get install -y docker.iosudo usermod -aG docker jenkins # AWS CLI v2 (uses the instance role, no keys needed)curl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zipunzip -q awscliv2.zip && sudo ./aws/installaws sts get-caller-identity# Expected: an assumed-role ARN for jenkins-ci-role # Trivy from the official apt repositorywget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key \ | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/nullecho "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" \ | sudo tee /etc/apt/sources.list.d/trivy.list > /dev/nullsudo apt-get update && sudo apt-get install -y trivytrivy --version # Restart so Jenkins picks up the docker group, then confirmsudo systemctl restart jenkinssudo -u jenkins docker ps# Expected: an empty container list, not a permission errorOpen http://JENKINS_PUBLIC_IP:8080 and unlock Jenkins with the initial password:
sudo cat /var/lib/jenkins/secrets/initialAdminPasswordChoose Install suggested plugins, create an admin user, then add these plugins under Manage Jenkins, Plugins, Available plugins:
- SonarQube Scanner
- Docker Pipeline
- SSH Agent
SecurityMembership of the
dockergroup is effectively root on that machine, because anyone who can start containers can mount the host filesystem. That trade-off is normal for a single-purpose lab build server. In production, use ephemeral build agents so a compromised build cannot touch the controller.
Common MistakeFixing
permission deniedon the Docker socket withchmod 777 /var/run/docker.sock. It works until the next reboot and lets every user on the box control Docker. Add thejenkinsuser to thedockergroup and restart Jenkins instead.
Milestone 3: Run SonarQube with Docker Compose
This milestone starts SonarQube and its PostgreSQL database as containers on the second server, then creates the access token and callback webhook Jenkins needs.
SonarQube analyses source code for bugs, vulnerabilities, code smells and test coverage, then applies a quality gate, a pass or fail rule such as "new code must have at least 80 percent coverage". Running it in Docker Compose keeps the install to one file, avoids a manual Java and database setup, and makes upgrades a one-line change. The Community Build needs a recent JDK, and the official image already includes it.
ssh -i devops-ci-key.pem ubuntu@SONARQUBE_PUBLIC_IP sudo apt-get updatesudo apt-get install -y docker.io docker-compose-v2sudo usermod -aG docker ubuntu && newgrp docker # SonarQube embeds Elasticsearch, which needs these kernel limitsprintf "vm.max_map_count=524288\nfs.file-max=131072\n" | sudo tee /etc/sysctl.d/99-sonarqube.confsudo sysctl --system sudo mkdir -p /opt/sonarqube && sudo chown ubuntu:ubuntu /opt/sonarqubecd /opt/sonarqubeecho "POSTGRES_PASSWORD=$(openssl rand -hex 16)" > .envchmod 600 .envCreate /opt/sonarqube/docker-compose.yml:
services: db: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_USER: sonar POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: sonar volumes: - pg_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U sonar -d sonar"] interval: 10s timeout: 5s retries: 10 sonarqube: image: sonarqube:community restart: unless-stopped depends_on: db: condition: service_healthy # Lab setting: allow the quality gate callback to a private IP (blocked by default) command: ["-Dsonar.validateWebhooks=false"] environment: SONAR_JDBC_URL: jdbc:postgresql://db:5432/sonar SONAR_JDBC_USERNAME: sonar SONAR_JDBC_PASSWORD: ${POSTGRES_PASSWORD} ports: - "9000:9000" volumes: - sonar_data:/opt/sonarqube/data - sonar_extensions:/opt/sonarqube/extensions - sonar_logs:/opt/sonarqube/logs volumes: pg_data: sonar_data: sonar_extensions: sonar_logs:docker compose up -ddocker compose logs -f sonarqube# Wait for: SonarQube is operational (about 1 to 3 minutes), then press Ctrl+COpen http://SONARQUBE_PUBLIC_IP:9000 and sign in as admin with password admin. SonarQube forces you to change it immediately. Then:
- Go to My Account, Security, and generate a Global Analysis Token named
jenkins-token. Copy it now, because it is shown once - Go to Administration, Configuration, Webhooks, and create one named
jenkinswith the URLhttp://JENKINS_PRIVATE_IP:8080/sonarqube-webhook/(keep the trailing slash)
The webhook is how SonarQube tells Jenkins the quality gate result. Without it, the pipeline waits until its timeout and then fails, even when the code is fine.
Tip
sonarqube:communityfollows the current Community Build, which has no long-term-support line. For a stable lab, pin an exact version tag from Docker Hub and upgrade deliberately.
Common MistakeStarting SonarQube without raising
vm.max_map_count. The container exits with an Elasticsearch bootstrap error in the logs. Apply thesysctlsettings first, then restart the stack withdocker compose up -d.
Milestone 4: Create the Sample App and Dockerfile
This milestone creates a small Node.js service with tests, a Dockerfile and a SonarQube configuration, so the pipeline has something real to build.
Use your own application if you have one. This app is deliberately tiny so the focus stays on the pipeline. It exposes a home route and a /health route, and its tests produce a coverage report that SonarQube reads.
Create a new GitHub repository named ci-demo-app, clone it, and add these files.
package.json:
{ "name": "ci-demo-app", "version": "1.0.0", "private": true, "main": "src/server.js", "scripts": { "start": "node src/server.js", "test": "jest --coverage --coverageReporters=lcov --coverageReporters=text-summary" }, "dependencies": { "express": "^5.1.0" }, "devDependencies": { "jest": "^30.0.0", "supertest": "^7.1.0" }}src/app.js:
const express = require("express"); const app = express(); app.get("/", (req, res) => { res.json({ message: "Hello from the CI/CD pipeline", version: process.env.APP_VERSION || "dev" });}); app.get("/health", (req, res) => { res.status(200).send("ok");}); module.exports = app;src/server.js:
const app = require("./app"); const port = process.env.PORT || 8080;app.listen(port, () => console.log(`Listening on ${port}`));tests/app.test.js:
const request = require("supertest");const app = require("../src/app"); describe("app", () => { test("GET /health returns ok", async () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.text).toBe("ok"); }); test("GET / returns a message", async () => { const res = await request(app).get("/"); expect(res.status).toBe(200); expect(res.body.message).toContain("pipeline"); });});Dockerfile:
FROM node:24-alpineENV NODE_ENV=productionWORKDIR /appCOPY package*.json ./RUN npm ci --omit=devCOPY src ./srcARG APP_VERSION=devENV APP_VERSION=$APP_VERSIONUSER nodeEXPOSE 8080CMD ["node", "src/server.js"].dockerignore:
node_modulescoverage.gittestssonar-project.properties:
sonar.projectKey=ci-demo-appsonar.projectName=CI Demo Appsonar.sources=srcsonar.tests=testssonar.javascript.lcov.reportPaths=coverage/lcov.infoInstall, test locally and commit. The lock file is required, because the pipeline uses npm ci:
npm installnpm test# Expected: 2 passing tests and a coverage summary printf "node_modules\ncoverage\n" > .gitignoregit add . && git commit -m "feat: add sample app" && git push origin mainTipThe image runs as the non-root
nodeuser and installs only production dependencies. Smaller, less privileged images give Trivy less to complain about.
Common MistakeForgetting to commit
package-lock.json.npm cifails immediately without it. Runnpm installonce locally and commit the lock file.
Milestone 5: Connect Jenkins to SonarQube and the App Server
This milestone gives Jenkins the credentials, SonarQube connection and SSH access it needs, and installs Docker on the app server.
Jenkins stores secrets in its credentials store and injects them into a build only when a step asks for them, so they never appear in the Jenkinsfile or the logs.
Prepare the app server first. It needs Docker and the AWS CLI to pull from ECR, and a dedicated deploy key so Jenkins can log in:
# On your laptop: create a key used only for deploymentsssh-keygen -t ed25519 -f deploy_key -N "" -C "jenkins-deploy"ssh -i devops-ci-key.pem ubuntu@APP_PUBLIC_IP "echo '$(cat deploy_key.pub)' >> ~/.ssh/authorized_keys" # On the app serverssh -i devops-ci-key.pem ubuntu@APP_PUBLIC_IPsudo apt-get update && sudo apt-get install -y docker.io unzip curlsudo usermod -aG docker ubuntucurl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zipunzip -q awscliv2.zip && sudo ./aws/installexitLet the jenkins user trust the app server's host key, so SSH never needs host checking turned off:
# On the Jenkins serversudo -u jenkins mkdir -p -m 700 /var/lib/jenkins/.sshssh-keyscan -H APP_PRIVATE_IP | sudo -u jenkins tee -a /var/lib/jenkins/.ssh/known_hostsIn the Jenkins UI, go to Manage Jenkins, Credentials, System, Global credentials, and add:
| ID | Kind | Value |
|---|---|---|
sonarqube-token |
Secret text | The SonarQube token from Milestone 3 |
deploy-server-ssh |
SSH Username with private key | Username ubuntu, contents of the deploy_key file |
github-pat |
Username with password | Only if the repository is private |
Then configure the two tools:
- Manage Jenkins, System, SonarQube servers: tick environment variables, add a server named
sonarqubewith URLhttp://SONARQUBE_PRIVATE_IP:9000and thesonarqube-tokencredential - Manage Jenkins, Tools, SonarQube Scanner installations: add one named
sonar-scannerand let it install automatically
SecurityDelete
deploy_keyfrom your laptop after pasting it into Jenkins, and treat that Jenkins credential as production access. Anyone who can edit aJenkinsfilecan use it, so protect themainbranch with required reviews.
Common MistakeUsing the SonarQube public IP in the Jenkins server settings. It works for you, but the public path is closed to Jenkins by design. Use the private IP, which the security group allows.
Milestone 6: Write the Jenkinsfile
This milestone writes the pipeline definition. It lives in the repository next to the code, so the delivery process is versioned and reviewed like any other change.
A Jenkinsfile describes the pipeline as stages. Two choices here matter. Tests run inside a node:24-alpine container, so the build server needs no Node.js install and every run uses the same runtime. And the image tag combines the build number and short commit hash, so each deployed version can be traced and rolled back.
Create Jenkinsfile in the repository root. Replace ACCOUNT_ID and APP_PRIVATE_IP:
pipeline { agent any options { timestamps() timeout(time: 30, unit: 'MINUTES') disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: '20')) } environment { AWS_REGION = 'us-east-1' ECR_REGISTRY = 'ACCOUNT_ID.dkr.ecr.us-east-1.amazonaws.com' IMAGE_NAME = 'ci-demo-app' DEPLOY_HOST = 'APP_PRIVATE_IP' } stages { stage('Checkout') { steps { checkout scm script { def sha = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() env.IMAGE_TAG = "${env.BUILD_NUMBER}-${sha}" } echo "Building ${env.IMAGE_NAME}:${env.IMAGE_TAG}" } } stage('Unit Tests') { agent { docker { image 'node:24-alpine' reuseNode true args '-e npm_config_cache=/tmp/.npm' } } steps { sh 'npm ci' sh 'npm test' } } stage('SonarQube Analysis') { steps { script { env.SCANNER_HOME = tool 'sonar-scanner' } withSonarQubeEnv('sonarqube') { sh '"$SCANNER_HOME/bin/sonar-scanner"' } } } stage('Quality Gate') { steps { timeout(time: 5, unit: 'MINUTES') { waitForQualityGate abortPipeline: true } } } stage('Build Image') { steps { sh ''' docker build --pull \ --build-arg APP_VERSION="$IMAGE_TAG" \ -t "$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" . ''' } } stage('Trivy Scan') { steps { sh ''' trivy image --severity HIGH,CRITICAL --ignore-unfixed \ --exit-code 1 --no-progress \ "$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" ''' } post { always { sh ''' trivy image --format json --output trivy-report.json \ "$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" || true ''' archiveArtifacts artifacts: 'trivy-report.json', allowEmptyArchive: true } } } stage('Push to ECR') { steps { sh ''' aws ecr get-login-password --region "$AWS_REGION" \ | docker login --username AWS --password-stdin "$ECR_REGISTRY" docker push "$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" ''' } } stage('Deploy to EC2') { when { expression { env.GIT_BRANCH in ['origin/main', 'main'] } } steps { sshagent(credentials: ['deploy-server-ssh']) { sh ''' ssh ubuntu@"$DEPLOY_HOST" "\ aws ecr get-login-password --region $AWS_REGION \ | docker login --username AWS --password-stdin $ECR_REGISTRY && \ docker pull $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG && \ (docker rm -f ci-demo-app || true) && \ docker run -d --name ci-demo-app --restart unless-stopped \ -p 80:8080 $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" ''' } } } stage('Smoke Test') { when { expression { env.GIT_BRANCH in ['origin/main', 'main'] } } steps { sh ''' for i in 1 2 3 4 5 6; do curl -fsS "http://$DEPLOY_HOST/health" && exit 0 sleep 5 done echo "Smoke test failed" && exit 1 ''' } } } post { success { echo "Deployed ${env.IMAGE_NAME}:${env.IMAGE_TAG}" } failure { echo "Pipeline failed. Check the failing stage above." } always { sh 'docker image prune -f || true' } }}| Stage | What it protects against |
|---|---|
| Unit Tests | Broken logic reaching later stages |
| SonarQube and Quality Gate | Bugs, vulnerabilities and untested new code |
| Trivy Scan | Known vulnerabilities in the base image and packages |
| Push to ECR | Only scanned images enter the registry |
| Smoke Test | A container that starts but does not serve traffic |
Commit and push the file:
git add Jenkinsfile && git commit -m "ci: add Jenkins pipeline" && git push origin mainTip
--ignore-unfixedmakes Trivy report only vulnerabilities that have a fix available. That keeps the gate actionable, because you cannot patch a flaw nobody has patched yet.
Common MistakeUsing
when { branch 'main' }in a plain Pipeline job. That condition only works in a Multibranch Pipeline, so the deploy stage is silently skipped on every run. This Jenkinsfile checksGIT_BRANCHinstead, which works in both setups.
Milestone 7: Create the Job and the GitHub Webhook
This milestone creates the Jenkins job and the webhook, so a push to GitHub starts the pipeline without anyone clicking Build.
A webhook is GitHub calling a URL when an event happens. Polling would also work, but it wastes requests and adds delay.
In Jenkins, choose New Item, name it ci-demo-app, select Pipeline, then set:
- Build Triggers: tick GitHub hook trigger for GITScm polling
- Pipeline: Pipeline script from SCM
- SCM: Git, with your repository URL and the
github-patcredential if the repo is private - Branch Specifier:
*/main - Script Path:
Jenkinsfile
In GitHub, open the repository's Settings, Webhooks, Add webhook:
- Payload URL:
http://JENKINS_PUBLIC_IP:8080/github-webhook/ - Content type:
application/json - Events: Just the push event
Run the job once manually with Build Now, because the first run lets Jenkins download the scanner tool and the Node image. GitHub shows a green tick next to the webhook once a delivery succeeds.
SecurityThis lab uses plain HTTP, so webhook payloads and the Jenkins login travel unencrypted. For a real setup, put Jenkins behind an Application Load Balancer or reverse proxy with an ACM or Let's Encrypt certificate, and enable webhook secrets.
Common MistakeLeaving the trailing slash off
/github-webhook/. Jenkins answers with a redirect or a 404 and GitHub marks the delivery as failed.
Validation & Testing
This final step proves that the pipeline deploys good code, and that it blocks bad code. Work through the checks in order.
# 1. Trigger a run with a normal commitgit commit --allow-empty -m "ci: trigger pipeline"git push origin main# Watch the stages turn green in Jenkins. Expected: all stages pass. # 2. The image is in ECR with the build tagaws ecr describe-images --repository-name ci-demo-app --region us-east-1 \ --query 'imageDetails[*].[imagePushedAt,imageTags[0]]' --output table# Expected: a tag in the form BUILDNUMBER-COMMITSHA # 3. The app is livecurl "http://APP_PUBLIC_IP/"# Expected: {"message":"Hello from the CI/CD pipeline","version":"<your tag>"} ssh -i devops-ci-key.pem ubuntu@APP_PUBLIC_IP "docker ps --filter name=ci-demo-app"# Expected: one running containerIn SonarQube, open the ci-demo-app project and confirm the quality gate shows Passed.
Prove the gate blocks bad code
Add a file with a hardcoded credential and no tests:
cat > src/config.js <<'EOF'const password = "admin123";module.exports = { password };EOFgit add . && git commit -m "test: introduce a hardcoded secret" && git push origin main# Expected: the Quality Gate stage fails (for example on Security Hotspots Reviewed# or on coverage), later stages do not run, and nothing new is deployedConfirm the running version did not change with curl http://APP_PUBLIC_IP/, then remove the file and push again to get back to green.
For an optional Trivy test, temporarily change the first line of the Dockerfile to an old image such as FROM node:18-alpine. The Trivy stage is likely to fail on fixable HIGH findings, which shows the scan is a real gate. Revert the change afterwards.
Clean up
# Terminate only the three lab instances (matched by name, never everything in the account)IDS=$(aws ec2 describe-instances \ --filters "Name=tag:Name,Values=jenkins-server,sonarqube-server,app-server" \ "Name=instance-state-name,Values=pending,running,stopped" \ --query 'Reservations[].Instances[].InstanceId' --output text)aws ec2 terminate-instances --instance-ids $IDSaws ec2 wait instance-terminated --instance-ids $IDS aws ecr delete-repository --repository-name ci-demo-app --forceaws ec2 delete-security-group --group-id "$APP_SG"aws ec2 delete-security-group --group-id "$SONAR_SG"aws ec2 delete-security-group --group-id "$JENKINS_SG"aws ec2 delete-key-pair --key-name devops-ci-key for role in jenkins-ci-role app-server-role; do aws iam remove-role-from-instance-profile --instance-profile-name "$role" --role-name "$role" aws iam delete-instance-profile --instance-profile-name "$role"doneaws iam detach-role-policy --role-name jenkins-ci-role \ --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUseraws iam detach-role-policy --role-name app-server-role \ --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnlyaws iam delete-role --role-name jenkins-ci-roleaws iam delete-role --role-name app-server-roleCommon MistakeTerminating only the instances and leaving the ECR repository, security groups and IAM roles behind. Instances stop billing quickly, but forgotten resources clutter the account and security groups cannot be deleted until the instances using them are gone.
Keep screenshots of the green run, the passed and failed quality gates, and the ECR image list. Together with the repository, they are your portfolio evidence for this project.