Like most people, I spent my weekend building CI/CD pipelines.
The challenge I gave myself was simple to explain:
Can I recreate the basic developer experience of Vercel preview deployments, but using Jenkins and infrastructure I control?
I wanted this flow:
- A developer opens or updates a pull request.
- Jenkins automatically discovers the PR.
- Jenkins tests the code as it would exist after merging into the target branch.
- The application is built into a Docker image.
- The code and image go through security/quality checks.
- The image is pushed to Amazon ECR.
- Terraform provisions or reuses an EC2 preview host.
- Jenkins deploys a temporary container to that host.
- Jenkins health-checks the deployment.
- Jenkins publishes a WhisperGate Preview check back to the GitHub pull request.
- Clicking Details on the GitHub check opens the live preview.
The finished experience looks deceptively simple:
Open PR
↓
Jenkins runs
↓
Build + Scan + Push + Provision + Deploy
↓
GitHub PR
├── ✓ Jenkins
└── ✓ WhisperGate Preview
└── Details → http://preview-host:port
Getting there involved more moving parts than I expected: Jenkins agents, Docker socket permissions, GitHub App authentication, GitHub's old Status API versus the newer Checks API, AWS IAM, ECR, Terraform state, EC2 cloud-init timing, SSH, dynamic ports, cron cleanup, SonarQube, Trivy, and a few mistakes that produced very confusing behavior.
This article walks through the whole system from scratch.
What we are building
The architecture in this tutorial is intentionally understandable rather than maximally sophisticated.
The CI infrastructure runs Jenkins with a dedicated Docker-capable agent. SonarQube is available to Jenkins on the same Docker network. AWS is used for the preview infrastructure.
GitHub
│
│ Pull request / webhook
▼
┌───────────────────┐
│ Jenkins Controller│
└─────────┬─────────┘
│ schedules job
▼
┌───────────────────┐
│ Jenkins Agent │
│ label: agent1 │
│ │
│ Node / npm │
│ Docker CLI │
│ AWS CLI │
│ Terraform │
│ Trivy │
│ SonarScanner │
└───────┬───────────┘
│
┌───────────────┼────────────────┐
│ │ │
▼ ▼ ▼
SonarQube Amazon ECR Terraform/AWS
│
▼
┌───────────┐
│ EC2 host │
│ Docker │
└─────┬─────┘
│
temporary container
│
▼
Preview URL
│
▼
GitHub Check
A few deliberate choices:
- Jenkins is a Multibranch Pipeline, not a single static Pipeline job.
- PRs use the merge discovery strategy so CI tests the PR merged with the current target branch.
- The Jenkins agent owns the build tools; the controller stays mostly orchestration-only.
- Docker images are stored in Amazon ECR.
- Terraform creates the EC2 host, key pair registration, security group and an EC2 IAM role.
- The EC2 host receives ECR read access through an instance profile, not copied AWS access keys.
- Jenkins uses SSH only for the deployment step.
- Preview containers expire automatically.
- GitHub authentication uses a GitHub App, because publishing GitHub Checks requires it.
- The final PR shows a normal Jenkins check plus a separate WhisperGate Preview check whose
detailsURLis the live deployment.
1. Prerequisites
You should already be comfortable with the basics of:
- Git and GitHub
- Docker
- Linux shell commands
- Jenkins Pipelines
- basic AWS concepts
- Terraform fundamentals
You will need:
- a GitHub repository
- Docker and Docker Compose
- an AWS account
- an AWS IAM principal Jenkins can use for infrastructure/ECR operations
- an existing or bootstrapped ECR repository
- an EC2 SSH key pair private key stored in Jenkins, with the public half passed into Terraform
- a Jenkins controller
- a Jenkins build agent
- SonarQube
- a GitHub App
- the Jenkins plugins listed later in this guide
This tutorial uses:
AWS region: eu-north-1
Jenkins agent label: agent1
Application container port: 3001
Preview host port scheme: 3000 + Jenkins BUILD_NUMBER
ECR repository example: ci-cd/jenkins
SonarQube project key: whispergate
Replace all account-specific values with your own.
2. Repository structure
I keep infrastructure code in a normal committed terraform/ directory.
It is important not to confuse that with Terraform's generated .terraform/ directory.
A useful layout is:
.
├── Dockerfile
├── Jenkinsfile
├── package.json
├── package-lock.json
├── sonar-project.properties
├── deploy-preview.sh
├── delete-cron.sh
├── src/
└── terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf
Your .gitignore should include Terraform-generated state/cache files, but not the provider lock file:
# Terraform local/generated data
.terraform/
*.tfstate
*.tfstate.*
tfplan
crash.log
# Keep this committed
# .terraform.lock.hcl
# Local app secrets
.env
.env.*
# Generated CI files
preview-url.txt
trivy-report-*.txt
sonar-metrics.json
Commit:
terraform/.terraform.lock.hcl
Do not commit:
terraform/.terraform/
terraform/tfplan
terraform.tfstate
The tfplan file is created by CI for that particular run. It is not source code.
3. Run Jenkins, the build agent and SonarQube
My build agent is a custom image based on:
jenkins/ssh-agent:alpine-jdk21
The agent needs more than Java because it performs the actual CI work.
Dockerfile.agent
Create Dockerfile.agent:
FROM jenkins/ssh-agent:alpine-jdk21
USER root
ARG DOCKER_GID=969
RUN apk add --no-cache \
bash \
curl \
git \
openssh-client \
docker-cli \
nodejs \
npm \
aws-cli \
terraform
# Install Trivy CLI
RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
# The Docker socket is mounted from the host. The group inside the container
# needs the same GID as the group that owns /var/run/docker.sock on the host.
RUN addgroup -g "${DOCKER_GID}" docker \
&& adduser jenkins docker
USER jenkins
Why the Docker GID matters
The agent uses the host's Docker daemon through:
/var/run/docker.sock
Run this on the Docker host:
stat -c '%g' /var/run/docker.sock
or:
getent group docker
Use that GID as DOCKER_GID.
On one Fedora setup this was 969, which is why that number appears in the example. It is not universal.
Also understand the security consequence:
Access to the Docker socket is effectively highly privileged host access.
Only use this pattern for a trusted Jenkins agent. Do not run untrusted arbitrary PR code on an agent that can control your host Docker daemon.
docker-compose.yml
A compact development setup looks like this:
services:
jenkins:
image: jenkins/jenkins:lts
container_name: jenkins
ports:
- "8080:8080"
- "50000:50000"
volumes:
- jenkins_home:/var/jenkins_home
networks:
- jenkins_network
agent1:
build:
context: .
dockerfile: Dockerfile.agent
args:
DOCKER_GID: ${DOCKER_GID}
container_name: agent1
environment:
JENKINS_AGENT_SSH_PUBKEY: ${JENKINS_AGENT_SSH_PUBKEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
- jenkins_network
sonarqube:
image: sonarqube:community
container_name: sonarqube
ports:
- "9000:9000"
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_extensions:/opt/sonarqube/extensions
- sonarqube_logs:/opt/sonarqube/logs
- sonarqube_temp:/opt/sonarqube/temp
networks:
- jenkins_network
networks:
jenkins_network:
volumes:
jenkins_home:
sonarqube_data:
sonarqube_extensions:
sonarqube_logs:
sonarqube_temp:
Create a .env for Compose:
DOCKER_GID=969
JENKINS_AGENT_SSH_PUBKEY=ssh-ed25519 AAAA...your-public-key...
Do not commit this file if it contains environment-specific values you do not want public.
Start everything:
docker compose up -d --build
Verify:
docker ps
You should have:
jenkins
agent1
sonarqube
4. Generate the Jenkins-to-agent SSH key
The jenkins/ssh-agent image can authorize a supplied SSH public key.
Generate a dedicated key:
ssh-keygen -t ed25519 -f ./jenkins-agent-key -C "jenkins-agent"
You get:
jenkins-agent-key
jenkins-agent-key.pub
Put the public key in:
JENKINS_AGENT_SSH_PUBKEY
Put the private key in Jenkins Credentials.
In Jenkins:
Manage Jenkins
→ Credentials
→ System
→ Global credentials
→ Add Credentials
Use:
Kind: SSH Username with private key
Username: jenkins
Private Key: Enter directly
ID: agent1-ssh
Paste the contents of jenkins-agent-key.
5. Register agent1 in Jenkins
Go to:
Manage Jenkins
→ Nodes
→ New Node
Use something like:
Node name: agent1
Type: Permanent Agent
Remote root directory: /home/jenkins/agent
Labels: agent1
Usage: Use this node as much as possible
Launch method: Launch agents via SSH
Host: agent1
Credentials: agent1-ssh
Host key verification strategy: choose an appropriate strategy
Because the controller and agent are on the same Compose network, agent1 resolves by container name.
After saving, Jenkins should connect to it.
Verify from a Pipeline or agent shell:
node --version
npm --version
docker version
aws --version
terraform version
trivy --version
git --version
A useful Docker test:
docker ps
If this fails with permission errors, re-check the Docker socket GID.
6. Install the Jenkins plugins
Install these from:
Manage Jenkins
→ Plugins
Core plugins for this setup:
GitHub Branch Source
Checks API
GitHub Checks
Credentials Binding
AWS Credentials
SonarQube Scanner
Workspace Cleanup
Pipeline
Git
Why the Checks plugins matter:
-
Checks API exposes the generic Jenkins
publishChecksPipeline step. - GitHub Checks implements that API for GitHub.
- GitHub Branch Source understands GitHub branches and pull requests and can use GitHub App credentials.
Without the GitHub Checks implementation, a publishChecks step does not magically create a GitHub Check Run.
7. Configure SonarQube
Open:
http://localhost:9000
Set up the SonarQube instance and create a project/token.
In Jenkins, store the token:
Manage Jenkins
→ Credentials
→ Add Credentials
Example:
Kind: Secret text
Secret: <SONAR_TOKEN>
ID: sonarqube-token
Then configure the server:
Manage Jenkins
→ System
→ SonarQube servers
Example:
Name: SonarQube
Server URL: http://sonarqube:9000
Server authentication token: sonarqube-token
The hostname is sonarqube, not localhost, because the Jenkins agent/controller communicates over the Docker network.
Then configure the scanner tool:
Manage Jenkins
→ Tools
→ SonarQube Scanner installations
Set:
Name: SonarScanner
This name must match the Pipeline:
def scannerHome = tool 'SonarScanner'
sonar-project.properties
For a TypeScript/Node project, a minimal starting point is:
sonar.projectKey=whispergate
sonar.projectName=WhisperGate
sonar.sources=src
sonar.sourceEncoding=UTF-8
Add test/coverage configuration according to your project.
A Jenkins stage can then be:
stage('SonarQube Analysis') {
steps {
script {
def scannerHome = tool 'SonarScanner'
withSonarQubeEnv('SonarQube') {
sh "${scannerHome}/bin/sonar-scanner"
}
}
archiveArtifacts(
artifacts: '.scannerwork/report-task.txt',
allowEmptyArchive: true
)
}
}
During development I had multiple SonarQube stages while testing report paths and API output. For a clean final pipeline, consolidate that into one analysis stage unless you intentionally need separate scans.
8. Configure Trivy
The custom agent image already installs Trivy.
You can scan the source tree:
trivy fs .
and/or the final container image:
trivy image your-image:tag
A report-producing Jenkins stage:
stage('Image Scan') {
steps {
sh '''
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
trivy image \
--severity CRITICAL,HIGH,MEDIUM \
--output "trivy-report-${TIMESTAMP}.txt" \
"$ECR_IMAGE:$TAG"
'''
archiveArtifacts(
artifacts: 'trivy-report-*.txt',
allowEmptyArchive: true
)
}
}
When you first add scanning, I recommend getting the report generation working before making every finding fatal.
When you are ready to enforce a gate, use an exit code:
trivy image \
--exit-code 1 \
--severity CRITICAL,HIGH \
"$IMAGE"
That turns security findings into a CI policy rather than just an artifact.
9. Create the Amazon ECR repository
The application image needs somewhere private to live.
For example:
ci-cd/jenkins
You can create the repository from the AWS console or CLI:
aws ecr create-repository \
--repository-name ci-cd/jenkins \
--region eu-north-1
A bootstrap detail that matters
In the pipeline we are going to push the image before the EC2 deployment.
That means the ECR repository must already exist.
You can absolutely create ECR with Terraform, but do it in a bootstrap stack or move ECR creation earlier than the image push. Do not write a pipeline that attempts:
docker push
to a repository Terraform has not created yet.
10. Create Jenkins AWS credentials
Create a dedicated IAM user/role for Jenkins.
For ECR push, AWS documents permissions similar to:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:CompleteLayerUpload",
"ecr:UploadLayerPart",
"ecr:InitiateLayerUpload",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:BatchGetImage"
],
"Resource": "arn:aws:ecr:eu-north-1:<AWS_ACCOUNT_ID>:repository/ci-cd/jenkins"
},
{
"Effect": "Allow",
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
}
]
}
That last statement is worth highlighting.
One very easy failure is:
AccessDeniedException: ecr:GetAuthorizationToken
ecr:GetAuthorizationToken needs:
"Resource": "*"
Repository-scoped image actions and registry authentication are not exactly the same thing.
Terraform also needs permissions to manage the EC2 resources in this tutorial. In a learning AWS account, you may start broader and then tighten the policy once you know which API calls your configuration makes. Do not use a broad production credential simply because it is convenient.
Add the AWS credential in Jenkins:
Manage Jenkins
→ Credentials
→ Global
→ Add Credentials
Use:
Kind: AWS Credentials
ID: jenkins-ecr
Access Key: ...
Secret Key: ...
The AWS Credentials Jenkins plugin exposes these inside:
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
// ...
}
11. Authenticate Docker to ECR
The standard ECR flow is:
aws ecr get-login-password --region eu-north-1 \
| docker login \
--username AWS \
--password-stdin \
<AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com
Then tag and push:
docker tag \
whispergate:$BUILD_NUMBER \
<AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com/ci-cd/jenkins:$BUILD_NUMBER
docker push \
<AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com/ci-cd/jenkins:$BUILD_NUMBER
A Jenkins version:
stage('Push to ECR') {
steps {
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
sh '''
set -e
aws sts get-caller-identity
aws ecr get-login-password --region "$AWS_REGION" \
| docker login \
--username AWS \
--password-stdin "$ECR_REGISTRY"
docker push "$ECR_IMAGE:$TAG"
'''
}
}
}
I used aws sts get-caller-identity while building this because it quickly answers the question:
"Are the credentials broken, or is only ECR broken?"
That is a useful CI debugging habit.
12. Create the EC2 SSH credential Jenkins will use
Generate a dedicated EC2 key locally:
ssh-keygen -t ed25519 -f whispergate-preview-key -C "whispergate-preview"
Store the private key in Jenkins:
Kind: SSH Username with private key
ID: aws-ec2-ssh-key
Username: ubuntu
Private Key: whispergate-preview-key
Do not commit it.
Terraform will receive the public half dynamically from Jenkins:
ssh-keygen -y -f "$SSH_KEY_FILE"
That lets the private key stay entirely inside Jenkins Credentials.
13. Store the preview environment file in Jenkins
If the application needs runtime environment variables, do not bake them into the image.
Create a file such as:
NODE_ENV=preview
DATABASE_URL=...
APP_KEY=...
Store it in Jenkins as a Secret file:
ID: whispergate-preview-env
The Pipeline receives it temporarily as:
file(
credentialsId: 'whispergate-preview-env',
variable: 'PREVIEW_ENV_FILE'
)
The deployment later copies it to the EC2 host and sets restrictive permissions:
chmod 600 ~/.whispergate-preview.env
For a more mature production design, consider AWS Secrets Manager or SSM Parameter Store. A Jenkins Secret File is fine for understanding the mechanics and keeping secrets out of Git.
14. Terraform: provision the preview host
The Terraform in this section is a cleaned reference implementation of the architecture. It generalizes account-specific values and removes experimental details.
It assumes:
- a default VPC exists
- you want one EC2 preview host
- Jenkins can reach the EC2 host over SSH
- the host will expose temporary preview ports
- the EC2 instance can pull ECR images using an IAM instance profile
terraform/versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
# Optional but recommended:
#
# backend "s3" {
# bucket = "<YOUR_TERRAFORM_STATE_BUCKET>"
# key = "whispergate/preview/terraform.tfstate"
# region = "eu-north-1"
# encrypt = true
# }
}
If you use an S3 backend, bootstrap that bucket first. Terraform cannot depend on a backend bucket that does not exist yet.
Remote state matters here because you generally want repeated Jenkins runs to understand that the preview EC2 instance already exists instead of blindly creating a new host every time.
terraform/variables.tf
variable "aws_region" {
type = string
default = "eu-north-1"
}
variable "instance_type" {
type = string
description = "Choose an EC2 size appropriate for your account and workload."
default = "t3.micro"
}
variable "public_ssh_key" {
type = string
description = "Public key corresponding to the private key stored in Jenkins."
}
variable "ssh_cidr" {
type = string
description = "CIDR allowed to SSH into the preview host."
}
Do not blindly assume an instance type is free-tier eligible. AWS pricing and free-tier rules change. Check your account and region.
terraform/main.tf
provider "aws" {
region = var.aws_region
}
data "aws_vpc" "default" {
default = true
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_key_pair" "preview" {
key_name = "whispergate-preview"
public_key = var.public_ssh_key
}
resource "aws_security_group" "preview" {
name = "whispergate-preview"
description = "WhisperGate PR preview host"
vpc_id = data.aws_vpc.default.id
ingress {
description = "SSH from Jenkins/admin network"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_cidr]
}
# Demo architecture: preview containers are exposed directly by port.
# Replace this with a reverse proxy/load balancer for a stronger design.
ingress {
description = "Temporary preview ports"
from_port = 3000
to_port = 3999
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "preview" {
name = "whispergate-preview"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}
resource "aws_iam_role_policy_attachment" "ecr_read" {
role = aws_iam_role.preview.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
resource "aws_iam_instance_profile" "preview" {
name = "whispergate-preview"
role = aws_iam_role.preview.name
}
resource "aws_instance" "app_server" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = aws_key_pair.preview.key_name
vpc_security_group_ids = [aws_security_group.preview.id]
iam_instance_profile = aws_iam_instance_profile.preview.name
associate_public_ip_address = true
user_data = <<-EOF
#!/bin/bash
set -eux
apt-get update -y
apt-get install -y docker.io awscli
systemctl enable docker
systemctl start docker
usermod -aG docker ubuntu
EOF
tags = {
Name = "whispergate-preview"
}
}
The IAM instance profile is an important design decision.
The EC2 server needs to run:
aws ecr get-login-password
so it can pull the private image.
Do not solve this by copying the Jenkins AWS access key onto EC2.
Give the instance an IAM role instead.
terraform/outputs.tf
output "EC2_url" {
description = "Public DNS name of the preview host."
value = aws_instance.app_server.public_dns
}
Jenkins can consume that output with:
terraform output -raw EC2_url
This is one of my favorite parts of the design: Terraform creates the infrastructure, then exposes exactly the machine-readable value the deployment stage needs.
15. Terraform stages in Jenkins
Initialization and validation:
stage('Terraform Init & Validate') {
steps {
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
dir('terraform') {
sh 'terraform init -input=false'
sh 'terraform validate'
}
}
}
}
Plan:
stage('Terraform Plan') {
steps {
withCredentials([
[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
],
sshUserPrivateKey(
credentialsId: 'aws-ec2-ssh-key',
keyFileVariable: 'SSH_KEY_FILE'
)
]) {
dir('terraform') {
sh '''
terraform plan \
-var "public_ssh_key=$(ssh-keygen -y -f "$SSH_KEY_FILE")" \
-var "ssh_cidr=<YOUR_JENKINS_PUBLIC_IP>/32" \
-out=tfplan
'''
}
}
}
}
Apply the same saved plan:
stage('Terraform Apply') {
steps {
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
dir('terraform') {
sh 'terraform apply -auto-approve tfplan'
}
}
}
}
For production infrastructure, an approval gate can be appropriate:
stage('Approval Gate') {
steps {
input(
message: 'Apply this infrastructure change?',
ok: 'Deploy'
)
}
}
For an automated disposable PR-preview workflow, a manual gate on every preview would defeat much of the point, so decide based on the environment you are provisioning.
16. Why terraform apply finishing does not mean EC2 is ready
This was one of the subtler problems.
Terraform can report that the EC2 instance exists while cloud-init is still:
- updating packages
- installing Docker
- installing/configuring AWS CLI
- starting services
If Jenkins immediately SSHes in and runs Docker commands, the deployment can fail even though Terraform itself succeeded.
So the deployment stage waits for:
/var/lib/cloud/instance/boot-finished
and also verifies:
aws
docker
Docker service
A readiness loop:
attempt=0
while [ "$attempt" -le 3 ]; do
if ssh -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
'test -f /var/lib/cloud/instance/boot-finished &&
command -v aws >/dev/null &&
command -v docker >/dev/null &&
sudo systemctl is-active --quiet docker'
then
echo "EC2 host is ready for deployment."
break
fi
if [ "$attempt" -eq 3 ]; then
echo "EC2 did not finish cloud-init in time."
ssh -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
'sudo tail -n 100 /var/log/cloud-init-output.log || true'
exit 1
fi
sleep 100
attempt=$((attempt + 1))
done
The cloud-init-output.log fallback is extremely useful. It turns "SSH deployment failed" into actual evidence.
For a hardened setup, replace StrictHostKeyChecking=no with proper known-host management.
17. Create deploy-preview.sh
The exact deployment script will depend on your app. This is a cleaned version of the pattern used in this system.
The app listens on container port:
3001
Each Jenkins build receives a host port:
3000 + BUILD_NUMBER
So:
Build 1 → host port 3001
Build 2 → host port 3002
Build 23 → host port 3023
Create deploy-preview.sh:
#!/usr/bin/env bash
set -euo pipefail
BUILD_ID="${1:?build id required}"
ENV_FILE="${2:?environment file required}"
AWS_REGION="eu-north-1"
AWS_ACCOUNT_ID="<AWS_ACCOUNT_ID>"
ECR_REPOSITORY="ci-cd/jenkins"
REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
IMAGE="${REGISTRY}/${ECR_REPOSITORY}:${BUILD_ID}"
CONTAINER_NAME="whispergate-preview-${BUILD_ID}"
HOST_PORT=$((3000 + BUILD_ID))
CONTAINER_PORT=3001
# Six-hour temporary preview
EXPIRES_AT="$(date -u -d '+6 hours' '+%Y-%m-%dT%H:%M:%SZ')"
echo "Deploying ${IMAGE}"
echo "Container: ${CONTAINER_NAME}"
echo "Host port: ${HOST_PORT}"
echo "Expires at: ${EXPIRES_AT}"
# Replace the same build's container if this script is re-run.
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
docker pull "${IMAGE}"
docker run -d \
--name "${CONTAINER_NAME}" \
--env-file "${ENV_FILE}" \
-p "${HOST_PORT}:${CONTAINER_PORT}" \
--label "preview=true" \
--label "expires-at=${EXPIRES_AT}" \
"${IMAGE}"
echo "${CONTAINER_NAME}"
The labels are important:
preview=true
expires-at=<UTC timestamp>
They give the cleanup job a machine-readable way to identify preview containers without maintaining another database.
18. Create delete-cron.sh
A lightweight preview system needs lifecycle management.
Otherwise every PR/build leaves a container running forever.
Create:
#!/usr/bin/env bash
set -euo pipefail
NOW_EPOCH="$(date -u +%s)"
docker ps -aq --filter "label=preview=true" | while read -r CONTAINER_ID; do
[ -z "$CONTAINER_ID" ] && continue
EXPIRES_AT="$(
docker inspect \
--format '{{ index .Config.Labels "expires-at" }}' \
"$CONTAINER_ID"
)"
[ -z "$EXPIRES_AT" ] && continue
EXPIRES_EPOCH="$(date -u -d "$EXPIRES_AT" +%s 2>/dev/null || echo 0)"
if [ "$EXPIRES_EPOCH" -gt 0 ] && [ "$EXPIRES_EPOCH" -le "$NOW_EPOCH" ]; then
NAME="$(docker inspect --format '{{.Name}}' "$CONTAINER_ID" | sed 's#^/##')"
echo "Removing expired preview: $NAME ($EXPIRES_AT)"
docker rm -f "$CONTAINER_ID"
fi
done
Install it in cron from the Jenkins deployment:
(crontab -l 2>/dev/null | grep -v delete-cron; \
echo "*/15 * * * * bash /home/ubuntu/delete-cron.sh") | crontab -
This checks every 15 minutes.
The design is intentionally simple:
Container labels = state
Cron = garbage collector
For a larger platform, you would likely move this responsibility into a proper preview-environment controller or lifecycle service.
19. Deploy the preview from Jenkins
The deployment stage needs:
- AWS credentials for Terraform/API operations
- the EC2 SSH private key
- the application secret environment file
Example:
stage('Deploy Preview to AWS') {
when {
changeRequest()
}
steps {
withCredentials([
[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
],
sshUserPrivateKey(
credentialsId: 'aws-ec2-ssh-key',
keyFileVariable: 'SSH_KEY_FILE',
usernameVariable: 'SSH_USER'
),
file(
credentialsId: 'whispergate-preview-env',
variable: 'PREVIEW_ENV_FILE'
)
]) {
dir('terraform') {
sh '''
set -e
EC2_HOST=$(terraform output -raw EC2_url)
if [ -z "$EC2_HOST" ]; then
echo "Terraform did not return an EC2 hostname."
exit 1
fi
PORT=$((3000 + BUILD_NUMBER))
attempt=0
while [ "$attempt" -le 3 ]; do
if ssh -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
'test -f /var/lib/cloud/instance/boot-finished &&
command -v aws >/dev/null &&
command -v docker >/dev/null &&
sudo systemctl is-active --quiet docker'
then
echo "EC2 host is ready for deployment."
break
fi
if [ "$attempt" -eq 3 ]; then
echo "EC2 did not finish cloud-init in time."
ssh -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
'sudo tail -n 100 /var/log/cloud-init-output.log || true'
exit 1
fi
echo "Waiting for EC2 cloud-init..."
sleep 100
attempt=$((attempt + 1))
done
scp -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
../deploy-preview.sh \
../delete-cron.sh \
"$PREVIEW_ENV_FILE" \
"$SSH_USER@$EC2_HOST:~/"
ENV_BASENAME=$(basename "$PREVIEW_ENV_FILE")
ssh -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" bash -s <<REMOTE
set -e
mv ~/${ENV_BASENAME} ~/.whispergate-preview.env
chmod 600 ~/.whispergate-preview.env
aws ecr get-login-password --region eu-north-1 \
| docker login \
--username AWS \
--password-stdin <AWS_ACCOUNT_ID>.dkr.ecr.eu-north-1.amazonaws.com
bash ~/deploy-preview.sh \
$BUILD_NUMBER \
~/.whispergate-preview.env
(crontab -l 2>/dev/null | grep -v delete-cron; \
echo "*/15 * * * * bash /home/$SSH_USER/delete-cron.sh") \
| crontab -
REMOTE
attempt=1
while [ "$attempt" -le 10 ]; do
if curl -sf "http://$EC2_HOST:$PORT/health" > /dev/null; then
printf "http://%s:%s\\n" \
"$EC2_HOST" \
"$PORT" \
> ../preview-url.txt
echo "Preview live at http://$EC2_HOST:$PORT"
exit 0
fi
sleep 3
attempt=$((attempt + 1))
done
echo "Preview failed health check."
ssh -o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
"docker ps -a --filter name=whispergate-preview-$BUILD_NUMBER;
docker logs --tail 100 whispergate-preview-$BUILD_NUMBER || true"
exit 1
'''
}
}
}
}
20. Why the health check matters
Do not publish a preview URL merely because:
docker run
returned successfully.
A container can start and then immediately crash.
Instead, Jenkins polls:
/health
In this setup:
curl -sf "http://$EC2_HOST:$PORT/health"
runs up to 10 times, with three seconds between attempts.
Only after that succeeds do we create:
preview-url.txt
The file becomes the contract between:
deployment stage
and:
GitHub publishing stage
That keeps the check-publishing code independent from Terraform and SSH details.
21. The most important GitHub/Jenkins part: use a GitHub App
Initially it is tempting to connect Jenkins using a GitHub Personal Access Token.
A PAT can work for repository discovery and classic commit statuses.
But the goal here is a real GitHub Check Run with a custom Details URL.
For Jenkins' GitHub Checks integration, use a GitHub App with Checks: Read & write.
22. Create the GitHub App
On GitHub:
Profile
→ Settings
→ Developer settings
→ GitHub Apps
→ New GitHub App
Use a name such as:
WhisperGate Jenkins
Set a homepage URL, for example your repository/project page.
Set the webhook URL to:
https://<YOUR-JENKINS-HOST>/github-webhook/
For event-driven builds, GitHub must be able to reach this endpoint.
If Jenkins is running only on your laptop, a public GitHub webhook cannot directly reach localhost. Use a securely exposed HTTPS endpoint/tunnel or host Jenkins somewhere reachable. Polling/scanning can be useful while experimenting, but webhooks are the better final event path.
GitHub App repository permissions
For GitHub Branch Source, Jenkins documents permissions including:
Commit statuses: Read & write
Contents: Read-only
Metadata: Read-only
Pull requests: Read-only
The GitHub Checks plugin adds:
Checks: Read & write
So the useful set for this tutorial is:
Checks Read & write
Commit statuses Read & write
Contents Read-only
Metadata Read-only
Pull requests Read-only
Use the minimum access your workflow needs.
For the simplest compatibility with GitHub Branch Source, Jenkins' GitHub App guide currently recommends enabling the needed webhook events; its guide says to enable all events. You can tighten event subscriptions later after validating your exact workflow.
Create the App.
23. Do not confuse the Client Secret with the private key
This caused some confusion during setup because the GitHub App page exposes OAuth-style values such as:
Client ID
Client secrets
Those are not the credential Jenkins GitHub Branch Source is asking for.
Scroll to:
Private keys
and click:
Generate a private key
GitHub downloads a .pem file.
You need:
App ID
Private key (.pem)
not the OAuth client secret.
24. Convert the GitHub App private key for Jenkins
The Jenkins GitHub Branch Source guide shows converting the key to PKCS#8:
openssl pkcs8 \
-topk8 \
-inform PEM \
-outform PEM \
-in github-app.private-key.pem \
-out converted-github-app.pem \
-nocrypt
Inspect it:
cat converted-github-app.pem
It should look like:
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
Keep this secret.
25. Install the GitHub App on the repository
On the GitHub App settings page:
Install App
For a private project, prefer:
Only select repositories
and choose the repository Jenkins needs.
This keeps the App's installation scope small.
26. Add the GitHub App credential to Jenkins
Go to:
Manage Jenkins
→ Credentials
→ Global credentials
→ Add Credentials
Use:
Kind: GitHub App
ID: github-app
Description: WhisperGate GitHub App
App ID: <NUMERIC_GITHUB_APP_ID>
Key: contents of converted-github-app.pem
Use the numeric App ID, not the Client ID.
After selecting a valid GitHub App credential in the GitHub Branch Source configuration, Jenkins should be able to verify the App.
27. Create/configure the Multibranch Pipeline
Create:
New Item
→ Multibranch Pipeline
Under:
Branch Sources
→ Add source
→ GitHub
Select:
Credentials: github-app
Repository: your repository
This is important:
The GitHub App belongs in the Multibranch GitHub source configuration.
You do not need to wrap publishChecks in a random PAT credential block.
28. Configure PR discovery
For:
Discover pull requests from origin
choose:
Merging the pull request with the current target branch revision
This tells Jenkins to test:
PR head + current target branch
instead of only testing the PR tip in isolation.
Conceptually:
feature commit
│
├──────────┐
│ │
│ current dev/main
│ │
└──── merge test revision
│
▼
Jenkins
That is valuable because a PR can be individually valid but fail when combined with changes already present in the target branch.
29. Avoid duplicate PR builds
There are two separate ways duplicate-looking Jenkins activity can happen:
Cause A: PR discovery set to "Both"
If PR discovery is configured to build both:
Head
Merge
then Jenkins is intentionally building two different revisions.
For this tutorial, use:
Merge
only.
Cause B: branch discovery also builds the same source branch
If branch discovery is configured in a way that builds feature branches independently while the PR job also builds them, you can get:
branch job
PR merge job
For a PR-oriented flow, choose branch discovery rules that do not duplicate feature-branch PR builds. A common choice is:
Exclude branches that are also filed as PRs
Then keep normal builds for long-lived branches such as dev, staging or main as your workflow requires.
30. Do not manually checkout dev in the Jenkinsfile
This was one of the most important bugs in the pipeline.
A Multibranch Pipeline already performs a special checkout for the PR.
The log can look like:
PR head
+
target branch
↓
synthetic merge commit
↓
Declarative: Checkout SCM
If you then add:
stage('Checkout') {
steps {
git branch: 'dev',
credentialsId: 'github-pat',
url: 'https://github.com/example/repo.git'
}
}
you destroy the entire point of the PR merge checkout.
Jenkins successfully checks out the PR merge revision, then your custom stage replaces the workspace with dev.
The pipeline "works", but it is testing/deploying the wrong code.
Correct solution
Delete that manual stage.
With:
pipeline {
agent { label 'agent1' }
stages {
stage('Build') {
// ...
}
}
}
Declarative Pipeline performs its normal SCM checkout automatically.
If you intentionally disable the default checkout, use:
checkout scm
because scm represents the exact branch/PR revision chosen by the Multibranch job.
31. Configure GitHub Checks in the Branch Source
After installing Checks API and GitHub Checks, open:
Multibranch Pipeline
→ Configure
→ Branch Sources
→ GitHub
→ Behaviors
→ Add
→ Status Checks Properties
In the final setup I want:
Skip publishing status checks:
unchecked
Status checks name:
Jenkins
Skip GitHub Branch Source notifications:
checked
Why?
GitHub Branch Source can publish old-style Status API entries such as:
continuous-integration/jenkins/branch
continuous-integration/jenkins/pr-merge
At the same time, the GitHub Checks plugin publishes the newer:
Jenkins
Check Run.
Without changing anything, your PR can suddenly show three Jenkins-looking checks.
The GitHub Checks plugin explicitly supports disabling the Branch Source status notifications.
Checking:
Skip GitHub Branch Source notifications
removes those legacy status notifications while keeping the new Checks API result.
Do not check:
Skip publishing status checks
because that would disable the new status-check behavior you actually want.
After making this change, trigger a new commit/build. Existing statuses on older commits do not disappear retroactively.
32. Publish the preview URL to GitHub
This is the payoff.
After deployment succeeds, Jenkins has:
preview-url.txt
containing something like:
http://ec2-xx-xx-xx-xx.eu-north-1.compute.amazonaws.com:3023
Now use:
publishChecks
with:
detailsURL: previewUrl
Example:
stage('Publish Preview URL') {
when {
allOf {
changeRequest()
expression { fileExists('preview-url.txt') }
}
}
steps {
script {
def previewUrl = readFile('preview-url.txt').trim()
currentBuild.description = "Preview: ${previewUrl}"
echo "Preview URL: ${previewUrl}"
publishChecks(
name: 'WhisperGate Preview',
title: 'Preview deployment ready',
summary: 'Your PR preview is live.',
text: "Preview: ${previewUrl}",
detailsURL: previewUrl,
conclusion: 'SUCCESS'
)
}
}
}
The critical field is:
detailsURL: previewUrl
GitHub renders the check with a clickable Details destination.
The PR now has two useful checks with different responsibilities:
✓ Jenkins
Overall CI/build status
✓ WhisperGate Preview
Preview deployment ready
Details → live application
This separation is cleaner than hijacking the normal Jenkins build link.
33. Full cleaned Jenkinsfile
The Jenkinsfile below is a consolidated reference version of the working design.
It intentionally removes experimental duplicate SonarQube stages and the earlier Docker Hub push because ECR is the registry used for deployment.
It also avoids the manual dev checkout that would overwrite the Multibranch PR merge revision.
pipeline {
agent { label 'agent1' }
environment {
AWS_REGION = 'eu-north-1'
AWS_ACCOUNT_ID = '<AWS_ACCOUNT_ID>'
ECR_REGISTRY = "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
ECR_REPOSITORY = 'ci-cd/jenkins'
ECR_IMAGE = "${ECR_REGISTRY}/${ECR_REPOSITORY}"
TAG = "${env.BUILD_NUMBER}"
}
stages {
/*
* No manual Git checkout here.
*
* In a Multibranch Pipeline, Jenkins' automatic SCM checkout
* preserves the PR/merge revision selected by GitHub Branch Source.
*/
stage('Install & Build') {
steps {
sh 'npm ci'
sh 'npm run build'
sh '''
docker build \
-t "$ECR_IMAGE:$TAG" \
.
'''
}
}
stage('Test') {
steps {
sh 'npm test --if-present'
}
}
stage('SonarQube Analysis') {
steps {
script {
def scannerHome = tool 'SonarScanner'
withSonarQubeEnv('SonarQube') {
sh "${scannerHome}/bin/sonar-scanner"
}
}
archiveArtifacts(
artifacts: '.scannerwork/report-task.txt',
allowEmptyArchive: true
)
}
}
stage('Trivy Image Scan') {
steps {
sh '''
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
trivy image \
--severity CRITICAL,HIGH,MEDIUM \
--output "trivy-report-${TIMESTAMP}.txt" \
"$ECR_IMAGE:$TAG"
'''
archiveArtifacts(
artifacts: 'trivy-report-*.txt',
allowEmptyArchive: true
)
}
}
stage('Push to ECR') {
steps {
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
sh '''
set -e
export AWS_DEFAULT_REGION="$AWS_REGION"
echo "Testing AWS identity..."
aws sts get-caller-identity
echo "Logging Docker into ECR..."
aws ecr get-login-password \
--region "$AWS_REGION" \
| docker login \
--username AWS \
--password-stdin "$ECR_REGISTRY"
docker push "$ECR_IMAGE:$TAG"
'''
}
}
}
stage('Terraform Init & Validate') {
when {
changeRequest()
}
steps {
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
dir('terraform') {
sh 'terraform init -input=false'
sh 'terraform validate'
}
}
}
}
stage('Terraform Plan') {
when {
changeRequest()
}
steps {
withCredentials([
[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
],
sshUserPrivateKey(
credentialsId: 'aws-ec2-ssh-key',
keyFileVariable: 'SSH_KEY_FILE'
)
]) {
dir('terraform') {
sh '''
terraform plan \
-var "public_ssh_key=$(ssh-keygen -y -f "$SSH_KEY_FILE")" \
-var "ssh_cidr=<YOUR_JENKINS_PUBLIC_IP>/32" \
-out=tfplan
'''
}
}
}
}
stage('Terraform Apply') {
when {
changeRequest()
}
steps {
withCredentials([[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
]]) {
dir('terraform') {
sh 'terraform apply -auto-approve tfplan'
}
}
}
}
stage('Deploy Preview to AWS') {
when {
changeRequest()
}
steps {
withCredentials([
[
$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: 'jenkins-ecr'
],
sshUserPrivateKey(
credentialsId: 'aws-ec2-ssh-key',
keyFileVariable: 'SSH_KEY_FILE',
usernameVariable: 'SSH_USER'
),
file(
credentialsId: 'whispergate-preview-env',
variable: 'PREVIEW_ENV_FILE'
)
]) {
dir('terraform') {
sh '''
set -e
EC2_HOST=$(terraform output -raw EC2_url)
if [ -z "$EC2_HOST" ]; then
echo "Terraform did not return an EC2 hostname."
exit 1
fi
PORT=$((3000 + BUILD_NUMBER))
echo "Waiting for EC2 host: $EC2_HOST"
attempt=0
while [ "$attempt" -le 3 ]; do
if ssh \
-o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
'test -f /var/lib/cloud/instance/boot-finished &&
command -v aws >/dev/null &&
command -v docker >/dev/null &&
sudo systemctl is-active --quiet docker'
then
echo "EC2 host is ready for deployment."
break
fi
if [ "$attempt" -eq 3 ]; then
echo "EC2 did not finish cloud-init in time."
ssh \
-o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
'sudo tail -n 100 /var/log/cloud-init-output.log || true'
exit 1
fi
sleep 100
attempt=$((attempt + 1))
done
scp \
-o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
../deploy-preview.sh \
../delete-cron.sh \
"$PREVIEW_ENV_FILE" \
"$SSH_USER@$EC2_HOST:~/"
ENV_BASENAME=$(basename "$PREVIEW_ENV_FILE")
ssh \
-o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" bash -s <<REMOTE
set -e
mv ~/${ENV_BASENAME} ~/.whispergate-preview.env
chmod 600 ~/.whispergate-preview.env
aws ecr get-login-password \
--region $AWS_REGION \
| docker login \
--username AWS \
--password-stdin $ECR_REGISTRY
bash ~/deploy-preview.sh \
$BUILD_NUMBER \
~/.whispergate-preview.env
(crontab -l 2>/dev/null | grep -v delete-cron; \
echo "*/15 * * * * bash /home/$SSH_USER/delete-cron.sh") \
| crontab -
REMOTE
attempt=1
while [ "$attempt" -le 10 ]; do
if curl -sf \
"http://$EC2_HOST:$PORT/health" \
> /dev/null
then
printf "http://%s:%s\\n" \
"$EC2_HOST" \
"$PORT" \
> ../preview-url.txt
echo "Preview live at http://$EC2_HOST:$PORT"
exit 0
fi
sleep 3
attempt=$((attempt + 1))
done
echo "Preview did not pass health check."
ssh \
-o StrictHostKeyChecking=no \
-i "$SSH_KEY_FILE" \
"$SSH_USER@$EC2_HOST" \
"docker ps -a --filter name=whispergate-preview-$BUILD_NUMBER;
docker logs --tail 100 whispergate-preview-$BUILD_NUMBER || true"
exit 1
'''
}
}
}
}
stage('Publish Preview URL') {
when {
allOf {
changeRequest()
expression {
fileExists('preview-url.txt')
}
}
}
steps {
script {
def previewUrl = readFile('preview-url.txt').trim()
currentBuild.description = "Preview: ${previewUrl}"
echo "Preview URL: ${previewUrl}"
publishChecks(
name: 'WhisperGate Preview',
title: 'Preview deployment ready',
summary: 'Your PR preview is live.',
text: "Preview: ${previewUrl}",
detailsURL: previewUrl,
conclusion: 'SUCCESS'
)
}
}
}
}
post {
success {
echo "Build #${env.BUILD_NUMBER} succeeded."
}
failure {
echo "Build #${env.BUILD_NUMBER} failed."
}
always {
echo "Build #${env.BUILD_NUMBER} finished."
cleanWs()
}
}
}
34. A note about BUILD_NUMBER versus CHANGE_ID
The working preview design above uses:
BUILD_NUMBER
for:
- image tags
- preview container names
- preview ports
That is easy to implement because every Jenkins run already has a unique number.
But it means a single PR can create:
PR #23
build 101 → preview 101
build 102 → preview 102
build 103 → preview 103
A stronger Vercel-like design uses:
env.CHANGE_ID
for the preview identity:
PR #23 → whispergate-preview-23
Then each push replaces the same PR environment.
For example:
PR #23 opened
↓
preview-23
new commit pushed
↓
replace preview-23
PR #23 closed
↓
destroy preview-23
I would treat that as the next lifecycle improvement rather than mixing it into the first working version.
35. What the final GitHub PR should show
When everything is connected correctly, a PR build should eventually show something conceptually like:
All checks have passed
✓ Jenkins
Build completed successfully
✓ WhisperGate Preview
Preview deployment ready
Details →
Clicking Details on WhisperGate Preview opens:
http://<EC2_PUBLIC_DNS>:<PREVIEW_PORT>
The GitHub UI is now the entry point for the developer.
They do not need to:
- open Jenkins
- inspect Terraform output
- SSH into EC2
- search a console log for a port
That is the developer-experience win.
36. Troubleshooting: publishChecks runs but GitHub shows no Check
Symptom:
[Pipeline] publishChecks
appears in Jenkins, but GitHub shows:
Checks 0
and only old statuses such as:
continuous-integration/jenkins/branch
continuous-integration/jenkins/pr-merge
Check:
- Checks API plugin is installed.
- GitHub Checks plugin is installed.
- GitHub Branch Source is using a GitHub App, not only a PAT.
- The GitHub App has:
Checks: Read & write
- The App is installed on the repository.
- Jenkins is using the correct App ID/private key.
- You generated the private key, not an OAuth client secret.
- The GitHub App credential is selected in the Multibranch Branch Source.
37. Troubleshooting: three Jenkins checks appear
Symptom:
continuous-integration/jenkins/branch
continuous-integration/jenkins/pr-merge
Jenkins
This is not necessarily three different CI systems.
It is usually:
GitHub Branch Source old Status API
+
GitHub Checks new Checks API
Fix:
Multibranch
→ Configure
→ Branch Sources
→ GitHub
→ Behaviors
→ Add
→ Status Checks Properties
Check:
Skip GitHub Branch Source notifications
Leave:
Skip publishing status checks
unchecked.
Trigger a new commit afterward.
38. Troubleshooting: the PR pipeline deploys dev instead of the PR
Look at the checkout logs.
If you see Jenkins first do:
Merging target branch into PR head
Checking out Revision <synthetic-merge>
and then later see:
Checking out Revision <dev SHA>
your Jenkinsfile probably contains a manual checkout like:
git branch: 'dev', ...
Delete it.
In a Multibranch Pipeline, trust the scm selected by Jenkins.
This bug is especially dangerous because the pipeline may remain completely green while testing the wrong revision.
39. Troubleshooting: EC2 exists but deployment says Docker/AWS is missing
Terraform is finished; cloud-init is not.
Check:
sudo tail -n 100 /var/log/cloud-init-output.log
and:
test -f /var/lib/cloud/instance/boot-finished
Wait until the bootstrap script is actually complete.
Do not use an arbitrary sleep 10 and hope.
Use readiness conditions.
40. Troubleshooting: ecr:GetAuthorizationToken AccessDenied
Validate credentials:
aws sts get-caller-identity
If that succeeds but:
aws ecr get-login-password
fails, inspect IAM.
Remember:
{
"Action": "ecr:GetAuthorizationToken",
"Resource": "*"
}
must be allowed.
Then make sure the repository-specific push actions are also allowed.
41. Troubleshooting: Docker works on the host but not on the Jenkins agent
Check the mounted socket:
docker exec -it agent1 ls -l /var/run/docker.sock
Check the Jenkins user's groups:
docker exec -it agent1 id jenkins
Check the host socket GID:
stat -c '%g' /var/run/docker.sock
The group permissions inside the container need to match.
42. Troubleshooting: SonarQube is reachable from your browser but not Jenkins
From your browser:
http://localhost:9000
works because your browser is outside Docker.
From the Jenkins Docker network, Jenkins should use:
http://sonarqube:9000
not:
http://localhost:9000
Inside the Jenkins container, localhost means Jenkins itself.
43. Security improvements I would make before calling this production-grade
This architecture is a learning/preview platform, not the finished version of a production deployment system.
Here are the first things I would harden.
1. Stop exposing a range of raw ports publicly
The demo uses:
EC2_HOST:3001
EC2_HOST:3002
EC2_HOST:3003
...
A cleaner architecture is:
pr-23.preview.example.com
↓
wildcard DNS
↓
TLS
↓
reverse proxy / ALB / ingress layer
↓
container
That removes the need to expose 3000-3999 to the public internet.
2. Restrict SSH properly
For learning, it is common to temporarily allow broad SSH while debugging.
Do not leave:
0.0.0.0/0 → port 22
in a real environment.
Restrict it to the Jenkins egress IP, VPN, bastion or another trusted network.
3. Replace StrictHostKeyChecking=no
Manage host keys or known hosts instead.
StrictHostKeyChecking=no is convenient while iterating, but it removes a layer of SSH authenticity verification.
4. Avoid long-lived AWS user credentials where possible
A stronger Jenkins deployment would run in AWS with an IAM role, use OIDC/federation, or otherwise obtain short-lived AWS credentials.
The EC2 side already follows the better model by using an instance profile for ECR pull.
5. Treat Docker socket access as privileged
The Jenkins agent can control the host Docker daemon.
That means you should not treat arbitrary external fork PRs as safe workloads.
If you want to build untrusted code, isolate builds more aggressively.
6. Make security checks enforce policy
Once you trust the scans:
Critical vulnerability → fail build
Quality gate failed → fail build
Tests failed → fail build
Reporting is step one.
Enforcement is step two.
44. Where I would take the preview platform next
The first working version gets the developer experience right:
PR → CI → temporary deployment → clickable preview
The next improvements are mostly lifecycle and routing.
Stable PR identities
Use:
CHANGE_ID
instead of:
BUILD_NUMBER
for container/environment identity.
Destroy previews when PRs close
Time-based expiry works, but PR lifecycle is a better signal.
Eventually:
PR closed
↓
Jenkins/GitHub event
↓
remove preview immediately
Keep TTL cleanup as a safety net.
Wildcard domains
Move toward:
pr-23.preview.whispergate.example
instead of:
ec2-public-host:3023
HTTPS
Use an ALB, Caddy, Traefik, Nginx, Cloudflare or another edge/reverse-proxy approach to terminate TLS.
Reuse infrastructure
Provisioning the EC2 host should not necessarily happen from scratch for every PR.
Terraform state can keep the base host stable while Jenkins creates/removes preview containers on it.
At a larger scale, you might graduate to:
ECS
Kubernetes
Nomad
serverless container platforms
but building the simpler EC2 version first makes the underlying mechanics much easier to understand.
Better observability
Add:
- centralized application logs
- deployment duration metrics
- preview inventory
- container CPU/memory monitoring
- alerting for failed preview cleanup
- GitHub comments/check annotations for scan summaries
45. What I learned building this
The most interesting part of this project was not any individual tool.
It was the boundaries between them.
Terraform can say:
EC2 created
while cloud-init is still installing Docker.
Docker can say:
container started
while the application is about to crash.
Jenkins can say:
PR checkout successful
and then your own checkout stage can silently replace the PR with dev.
publishChecks can execute in Jenkins while your GitHub authentication is still incapable of publishing the kind of Check Run you expect.
GitHub can show three checks that all appear to be "Jenkins" but are actually two different GitHub reporting APIs.
AWS credentials can successfully call STS and still fail ECR because one specific permission is missing.
That is what made the project useful.
A CI/CD pipeline is not one system.
It is a chain of contracts:
GitHub
→ Jenkins SCM
→ workspace revision
→ build
→ scanner
→ image
→ registry
→ Terraform
→ EC2
→ cloud-init
→ Docker
→ application health
→ preview URL
→ GitHub Check
The pipeline is only as reliable as the weakest contract between two stages.
46. Final result
At the end, the workflow I wanted was finally there:
Developer opens PR
↓
Jenkins discovers it
↓
Jenkins tests merge revision
↓
Build Docker image
↓
SonarQube + Trivy
↓
Push to ECR
↓
Terraform provisions/reuses EC2
↓
SSH deploy temporary container
↓
Health check
↓
Write preview URL
↓
publishChecks(detailsURL: previewUrl)
↓
GitHub PR shows:
✓ Jenkins
✓ WhisperGate Preview
Details → Live deployment
There is still a lot I would improve before calling it a production preview platform.
But that is exactly why I liked this project.
Something as simple as a Preview button turns out to be a very good excuse to learn CI/CD, cloud infrastructure, IAM, networking, containers, security scanning, Bash, SSH, Terraform state, GitHub integrations and deployment lifecycle management at the same time.
Reference documentation
The following official/project documentation is useful when reproducing this setup:
- Jenkins GitHub Checks plugin: https://plugins.jenkins.io/github-checks/
- Jenkins Checks API plugin: https://plugins.jenkins.io/checks-api/
- Jenkins GitHub Branch Source — GitHub App authentication guide: https://github.com/jenkinsci/github-branch-source-plugin/blob/master/docs/github-app.adoc
- GitHub Docs — registering a GitHub App: https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app
- GitHub Docs — managing private keys for GitHub Apps: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps
- Jenkins AWS Credentials plugin: https://plugins.jenkins.io/aws-credentials/
- AWS — pushing images to ECR: https://docs.aws.amazon.com/AmazonECR/latest/userguide/docker-push-ecr-image.html
- AWS — IAM permissions for ECR image push: https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-push-iam.html
- HashiCorp — Terraform output values: https://developer.hashicorp.com/terraform/language/values/outputs
- HashiCorp —
terraform output: https://developer.hashicorp.com/terraform/cli/commands/output - Trivy installation: https://www.trivy.dev/docs/latest/getting-started/installation/
- Jenkins SonarQube Scanner plugin: https://plugins.jenkins.io/sonar/
- Jenkins Workspace Cleanup plugin: https://plugins.jenkins.io/ws-cleanup/
A final note about the code in this article
The snippets here are a cleaned and generalized version of a real working learning pipeline.
I intentionally:
- replaced AWS account-specific identifiers with placeholders
- removed secrets
- consolidated experimental duplicate stages
- removed the manual branch checkout that interfered with Multibranch PR merge builds
- removed an unnecessary duplicate Docker Hub push from the final reference flow
- added explicit PR-only guards to the infrastructure/deployment stages
- kept the raw EC2 hostname/port model because that was the milestone being demonstrated
That makes the tutorial safer to publish and easier for someone else to reproduce without copying environment-specific mistakes.













