<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Caleb Ajibade</title>
    <description>The latest articles on DEV Community by Caleb Ajibade (@caleb_ajibade).</description>
    <link>https://dev.to/caleb_ajibade</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2229402%2F26846a99-5e5a-4415-a582-a30bab6d5d4a.png</url>
      <title>DEV Community: Caleb Ajibade</title>
      <link>https://dev.to/caleb_ajibade</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/caleb_ajibade"/>
    <language>en</language>
    <item>
      <title>Building Vercel-Style Pull Request Preview Deployments with Jenkins, GitHub Checks, Terraform, AWS EC2, Trivy and SonarQube</title>
      <dc:creator>Caleb Ajibade</dc:creator>
      <pubDate>Mon, 31 Aug 2026 19:41:00 +0000</pubDate>
      <link>https://dev.to/caleb_ajibade/building-vercel-style-pull-request-preview-deployments-with-jenkins-github-checks-terraform-aws-o6o</link>
      <guid>https://dev.to/caleb_ajibade/building-vercel-style-pull-request-preview-deployments-with-jenkins-github-checks-terraform-aws-o6o</guid>
      <description>&lt;p&gt;Like most people, I spent my weekend building CI/CD pipelines.&lt;/p&gt;

&lt;p&gt;The challenge I gave myself was simple to explain:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can I recreate the basic developer experience of Vercel preview deployments, but using Jenkins and infrastructure I control?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted this flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A developer opens or updates a pull request.&lt;/li&gt;
&lt;li&gt;Jenkins automatically discovers the PR.&lt;/li&gt;
&lt;li&gt;Jenkins tests the code as it would exist after merging into the target branch.&lt;/li&gt;
&lt;li&gt;The application is built into a Docker image.&lt;/li&gt;
&lt;li&gt;The code and image go through security/quality checks.&lt;/li&gt;
&lt;li&gt;The image is pushed to Amazon ECR.&lt;/li&gt;
&lt;li&gt;Terraform provisions or reuses an EC2 preview host.&lt;/li&gt;
&lt;li&gt;Jenkins deploys a temporary container to that host.&lt;/li&gt;
&lt;li&gt;Jenkins health-checks the deployment.&lt;/li&gt;
&lt;li&gt;Jenkins publishes a &lt;strong&gt;WhisperGate Preview&lt;/strong&gt; check back to the GitHub pull request.&lt;/li&gt;
&lt;li&gt;Clicking &lt;strong&gt;Details&lt;/strong&gt; on the GitHub check opens the live preview.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The finished experience looks deceptively simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Open PR
   ↓
Jenkins runs
   ↓
Build + Scan + Push + Provision + Deploy
   ↓
GitHub PR
   ├── ✓ Jenkins
   └── ✓ WhisperGate Preview
           └── Details → http://preview-host:port
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This article walks through the whole system from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;The architecture in this tutorial is intentionally understandable rather than maximally sophisticated.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few deliberate choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jenkins is a &lt;strong&gt;Multibranch Pipeline&lt;/strong&gt;, not a single static Pipeline job.&lt;/li&gt;
&lt;li&gt;PRs use the &lt;strong&gt;merge&lt;/strong&gt; discovery strategy so CI tests the PR merged with the current target branch.&lt;/li&gt;
&lt;li&gt;The Jenkins agent owns the build tools; the controller stays mostly orchestration-only.&lt;/li&gt;
&lt;li&gt;Docker images are stored in &lt;strong&gt;Amazon ECR&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Terraform creates the EC2 host, key pair registration, security group and an EC2 IAM role.&lt;/li&gt;
&lt;li&gt;The EC2 host receives &lt;strong&gt;ECR read access through an instance profile&lt;/strong&gt;, not copied AWS access keys.&lt;/li&gt;
&lt;li&gt;Jenkins uses SSH only for the deployment step.&lt;/li&gt;
&lt;li&gt;Preview containers expire automatically.&lt;/li&gt;
&lt;li&gt;GitHub authentication uses a &lt;strong&gt;GitHub App&lt;/strong&gt;, because publishing GitHub Checks requires it.&lt;/li&gt;
&lt;li&gt;The final PR shows a normal Jenkins check plus a separate &lt;strong&gt;WhisperGate Preview&lt;/strong&gt; check whose &lt;code&gt;detailsURL&lt;/code&gt; is the live deployment.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Prerequisites
&lt;/h2&gt;

&lt;p&gt;You should already be comfortable with the basics of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Git and GitHub&lt;/li&gt;
&lt;li&gt;Docker&lt;/li&gt;
&lt;li&gt;Linux shell commands&lt;/li&gt;
&lt;li&gt;Jenkins Pipelines&lt;/li&gt;
&lt;li&gt;basic AWS concepts&lt;/li&gt;
&lt;li&gt;Terraform fundamentals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You will need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a GitHub repository&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose&lt;/li&gt;
&lt;li&gt;an AWS account&lt;/li&gt;
&lt;li&gt;an AWS IAM principal Jenkins can use for infrastructure/ECR operations&lt;/li&gt;
&lt;li&gt;an existing or bootstrapped ECR repository&lt;/li&gt;
&lt;li&gt;an EC2 SSH key pair &lt;strong&gt;private key stored in Jenkins&lt;/strong&gt;, with the public half passed into Terraform&lt;/li&gt;
&lt;li&gt;a Jenkins controller&lt;/li&gt;
&lt;li&gt;a Jenkins build agent&lt;/li&gt;
&lt;li&gt;SonarQube&lt;/li&gt;
&lt;li&gt;a GitHub App&lt;/li&gt;
&lt;li&gt;the Jenkins plugins listed later in this guide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This tutorial uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace all account-specific values with your own.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Repository structure
&lt;/h2&gt;

&lt;p&gt;I keep infrastructure code in a normal committed &lt;code&gt;terraform/&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;It is important not to confuse that with Terraform's generated &lt;code&gt;.terraform/&lt;/code&gt; directory.&lt;/p&gt;

&lt;p&gt;A useful layout is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.
├── 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;.gitignore&lt;/code&gt; should include Terraform-generated state/cache files, but not the provider lock file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform/.terraform.lock.hcl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform/.terraform/
terraform/tfplan
terraform.tfstate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tfplan&lt;/code&gt; file is created by CI for that particular run. It is not source code.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Run Jenkins, the build agent and SonarQube
&lt;/h2&gt;

&lt;p&gt;My build agent is a custom image based on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;jenkins/ssh-agent:alpine-jdk21
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent needs more than Java because it performs the actual CI work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dockerfile.agent
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;Dockerfile.agent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; jenkins/ssh-agent:alpine-jdk21&lt;/span&gt;

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; root&lt;/span&gt;

&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; DOCKER_GID=969&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    bash &lt;span class="se"&gt;\
&lt;/span&gt;    curl &lt;span class="se"&gt;\
&lt;/span&gt;    git &lt;span class="se"&gt;\
&lt;/span&gt;    openssh-client &lt;span class="se"&gt;\
&lt;/span&gt;    docker-cli &lt;span class="se"&gt;\
&lt;/span&gt;    nodejs &lt;span class="se"&gt;\
&lt;/span&gt;    npm &lt;span class="se"&gt;\
&lt;/span&gt;    aws-cli &lt;span class="se"&gt;\
&lt;/span&gt;    terraform

&lt;span class="c"&gt;# Install Trivy CLI&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh &lt;span class="se"&gt;\
&lt;/span&gt;    | sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; /usr/local/bin

&lt;span class="c"&gt;# The Docker socket is mounted from the host. The group inside the container&lt;/span&gt;
&lt;span class="c"&gt;# needs the same GID as the group that owns /var/run/docker.sock on the host.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOCKER_GID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; docker &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser jenkins docker

&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; jenkins&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why the Docker GID matters
&lt;/h3&gt;

&lt;p&gt;The agent uses the host's Docker daemon through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this on the Docker host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%g'&lt;/span&gt; /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getent group docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use that GID as &lt;code&gt;DOCKER_GID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On one Fedora setup this was &lt;code&gt;969&lt;/code&gt;, which is why that number appears in the example. It is &lt;strong&gt;not universal&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Also understand the security consequence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Access to the Docker socket is effectively highly privileged host access.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  docker-compose.yml
&lt;/h2&gt;

&lt;p&gt;A compact development setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jenkins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jenkins/jenkins:lts&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jenkins&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50000:50000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;jenkins_home:/var/jenkins_home&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;jenkins_network&lt;/span&gt;

  &lt;span class="na"&gt;agent1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile.agent&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;DOCKER_GID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DOCKER_GID}&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agent1&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;JENKINS_AGENT_SSH_PUBKEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${JENKINS_AGENT_SSH_PUBKEY}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;jenkins_network&lt;/span&gt;

  &lt;span class="na"&gt;sonarqube&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonarqube:community&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sonarqube&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9000:9000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sonarqube_data:/opt/sonarqube/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sonarqube_extensions:/opt/sonarqube/extensions&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sonarqube_logs:/opt/sonarqube/logs&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sonarqube_temp:/opt/sonarqube/temp&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;jenkins_network&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jenkins_network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jenkins_home&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sonarqube_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sonarqube_extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sonarqube_logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sonarqube_temp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a &lt;code&gt;.env&lt;/code&gt; for Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DOCKER_GID=969
JENKINS_AGENT_SSH_PUBKEY=ssh-ed25519 AAAA...your-public-key...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not commit this file if it contains environment-specific values you do not want public.&lt;/p&gt;

&lt;p&gt;Start everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jenkins
agent1
sonarqube
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. Generate the Jenkins-to-agent SSH key
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;jenkins/ssh-agent&lt;/code&gt; image can authorize a supplied SSH public key.&lt;/p&gt;

&lt;p&gt;Generate a dedicated key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ./jenkins-agent-key &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"jenkins-agent"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jenkins-agent-key
jenkins-agent-key.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the &lt;strong&gt;public key&lt;/strong&gt; in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JENKINS_AGENT_SSH_PUBKEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the &lt;strong&gt;private key&lt;/strong&gt; in Jenkins Credentials.&lt;/p&gt;

&lt;p&gt;In Jenkins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Credentials
→ System
→ Global credentials
→ Add Credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kind: SSH Username with private key
Username: jenkins
Private Key: Enter directly
ID: agent1-ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste the contents of &lt;code&gt;jenkins-agent-key&lt;/code&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  5. Register &lt;code&gt;agent1&lt;/code&gt; in Jenkins
&lt;/h1&gt;

&lt;p&gt;Go to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Nodes
→ New Node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the controller and agent are on the same Compose network, &lt;code&gt;agent1&lt;/code&gt; resolves by container name.&lt;/p&gt;

&lt;p&gt;After saving, Jenkins should connect to it.&lt;/p&gt;

&lt;p&gt;Verify from a Pipeline or agent shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--version&lt;/span&gt;
npm &lt;span class="nt"&gt;--version&lt;/span&gt;
docker version
aws &lt;span class="nt"&gt;--version&lt;/span&gt;
terraform version
trivy &lt;span class="nt"&gt;--version&lt;/span&gt;
git &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A useful Docker test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this fails with permission errors, re-check the Docker socket GID.&lt;/p&gt;




&lt;h1&gt;
  
  
  6. Install the Jenkins plugins
&lt;/h1&gt;

&lt;p&gt;Install these from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Plugins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Core plugins for this setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Branch Source
Checks API
GitHub Checks
Credentials Binding
AWS Credentials
SonarQube Scanner
Workspace Cleanup
Pipeline
Git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the Checks plugins matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checks API&lt;/strong&gt; exposes the generic Jenkins &lt;code&gt;publishChecks&lt;/code&gt; Pipeline step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Checks&lt;/strong&gt; implements that API for GitHub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Branch Source&lt;/strong&gt; understands GitHub branches and pull requests and can use GitHub App credentials.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without the GitHub Checks implementation, a &lt;code&gt;publishChecks&lt;/code&gt; step does not magically create a GitHub Check Run.&lt;/p&gt;




&lt;h1&gt;
  
  
  7. Configure SonarQube
&lt;/h1&gt;

&lt;p&gt;Open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up the SonarQube instance and create a project/token.&lt;/p&gt;

&lt;p&gt;In Jenkins, store the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Credentials
→ Add Credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kind: Secret text
Secret: &amp;lt;SONAR_TOKEN&amp;gt;
ID: sonarqube-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ System
→ SonarQube servers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name: SonarQube
Server URL: http://sonarqube:9000
Server authentication token: sonarqube-token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hostname is &lt;code&gt;sonarqube&lt;/code&gt;, not &lt;code&gt;localhost&lt;/code&gt;, because the Jenkins agent/controller communicates over the Docker network.&lt;/p&gt;

&lt;p&gt;Then configure the scanner tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Tools
→ SonarQube Scanner installations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name: SonarScanner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This name must match the Pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;scannerHome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="s1"&gt;'SonarScanner'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  sonar-project.properties
&lt;/h2&gt;

&lt;p&gt;For a TypeScript/Node project, a minimal starting point is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;sonar.projectKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;whispergate&lt;/span&gt;
&lt;span class="py"&gt;sonar.projectName&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;WhisperGate&lt;/span&gt;
&lt;span class="py"&gt;sonar.sources&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;src&lt;/span&gt;
&lt;span class="py"&gt;sonar.sourceEncoding&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;UTF-8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add test/coverage configuration according to your project.&lt;/p&gt;

&lt;p&gt;A Jenkins stage can then be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SonarQube Analysis'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;scannerHome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="s1"&gt;'SonarScanner'&lt;/span&gt;

            &lt;span class="n"&gt;withSonarQubeEnv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SonarQube'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s2"&gt;"${scannerHome}/bin/sonar-scanner"&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;archiveArtifacts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;artifacts:&lt;/span&gt; &lt;span class="s1"&gt;'.scannerwork/report-task.txt'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;allowEmptyArchive:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h1&gt;
  
  
  8. Configure Trivy
&lt;/h1&gt;

&lt;p&gt;The custom agent image already installs Trivy.&lt;/p&gt;

&lt;p&gt;You can scan the source tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy fs &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and/or the final container image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy image your-image:tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A report-producing Jenkins stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Image Scan'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
            TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")

            trivy image \
                --severity CRITICAL,HIGH,MEDIUM \
                --output "trivy-report-${TIMESTAMP}.txt" \
                "$ECR_IMAGE:$TAG"
        '''&lt;/span&gt;

        &lt;span class="n"&gt;archiveArtifacts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;artifacts:&lt;/span&gt; &lt;span class="s1"&gt;'trivy-report-*.txt'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;allowEmptyArchive:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you first add scanning, I recommend getting the report generation working before making every finding fatal.&lt;/p&gt;

&lt;p&gt;When you are ready to enforce a gate, use an exit code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;trivy image &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exit-code&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--severity&lt;/span&gt; CRITICAL,HIGH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns security findings into a CI policy rather than just an artifact.&lt;/p&gt;




&lt;h1&gt;
  
  
  9. Create the Amazon ECR repository
&lt;/h1&gt;

&lt;p&gt;The application image needs somewhere private to live.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ci-cd/jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can create the repository from the AWS console or CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecr create-repository &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository-name&lt;/span&gt; ci-cd/jenkins &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; eu-north-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A bootstrap detail that matters
&lt;/h2&gt;

&lt;p&gt;In the pipeline we are going to push the image &lt;strong&gt;before&lt;/strong&gt; the EC2 deployment.&lt;/p&gt;

&lt;p&gt;That means the ECR repository must already exist.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to a repository Terraform has not created yet.&lt;/p&gt;




&lt;h1&gt;
  
  
  10. Create Jenkins AWS credentials
&lt;/h1&gt;

&lt;p&gt;Create a dedicated IAM user/role for Jenkins.&lt;/p&gt;

&lt;p&gt;For ECR push, AWS documents permissions similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:CompleteLayerUpload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:UploadLayerPart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:InitiateLayerUpload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:BatchCheckLayerAvailability"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:PutImage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecr:BatchGetImage"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:ecr:eu-north-1:&amp;lt;AWS_ACCOUNT_ID&amp;gt;:repository/ci-cd/jenkins"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ecr:GetAuthorizationToken"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last statement is worth highlighting.&lt;/p&gt;

&lt;p&gt;One very easy failure is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AccessDeniedException: ecr:GetAuthorizationToken
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt; needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repository-scoped image actions and registry authentication are not exactly the same thing.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Add the AWS credential in Jenkins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Credentials
→ Global
→ Add Credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kind: AWS Credentials
ID: jenkins-ecr
Access Key: ...
Secret Key: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AWS Credentials Jenkins plugin exposes these inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
    &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
&lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  11. Authenticate Docker to ECR
&lt;/h1&gt;

&lt;p&gt;The standard ECR flow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecr get-login-password &lt;span class="nt"&gt;--region&lt;/span&gt; eu-north-1 &lt;span class="se"&gt;\&lt;/span&gt;
  | docker login &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--username&lt;/span&gt; AWS &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--password-stdin&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &amp;lt;AWS_ACCOUNT_ID&amp;gt;.dkr.ecr.eu-north-1.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then tag and push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker tag &lt;span class="se"&gt;\&lt;/span&gt;
  whispergate:&lt;span class="nv"&gt;$BUILD_NUMBER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;AWS_ACCOUNT_ID&amp;gt;.dkr.ecr.eu-north-1.amazonaws.com/ci-cd/jenkins:&lt;span class="nv"&gt;$BUILD_NUMBER&lt;/span&gt;

docker push &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;AWS_ACCOUNT_ID&amp;gt;.dkr.ecr.eu-north-1.amazonaws.com/ci-cd/jenkins:&lt;span class="nv"&gt;$BUILD_NUMBER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Jenkins version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Push to ECR'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
            &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
        &lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                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"
            '''&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; while building this because it quickly answers the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Are the credentials broken, or is only ECR broken?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a useful CI debugging habit.&lt;/p&gt;




&lt;h1&gt;
  
  
  12. Create the EC2 SSH credential Jenkins will use
&lt;/h1&gt;

&lt;p&gt;Generate a dedicated EC2 key locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; whispergate-preview-key &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"whispergate-preview"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the &lt;strong&gt;private key&lt;/strong&gt; in Jenkins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kind: SSH Username with private key
ID: aws-ec2-ssh-key
Username: ubuntu
Private Key: whispergate-preview-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not commit it.&lt;/p&gt;

&lt;p&gt;Terraform will receive the public half dynamically from Jenkins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_KEY_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lets the private key stay entirely inside Jenkins Credentials.&lt;/p&gt;




&lt;h1&gt;
  
  
  13. Store the preview environment file in Jenkins
&lt;/h1&gt;

&lt;p&gt;If the application needs runtime environment variables, do not bake them into the image.&lt;/p&gt;

&lt;p&gt;Create a file such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NODE_ENV=preview
DATABASE_URL=...
APP_KEY=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store it in Jenkins as a &lt;strong&gt;Secret file&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ID: whispergate-preview-env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Pipeline receives it temporarily as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'whispergate-preview-env'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'PREVIEW_ENV_FILE'&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deployment later copies it to the EC2 host and sets restrictive permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.whispergate-preview.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h1&gt;
  
  
  14. Terraform: provision the preview host
&lt;/h1&gt;

&lt;p&gt;The Terraform in this section is a &lt;strong&gt;cleaned reference implementation&lt;/strong&gt; of the architecture. It generalizes account-specific values and removes experimental details.&lt;/p&gt;

&lt;p&gt;It assumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a default VPC exists&lt;/li&gt;
&lt;li&gt;you want one EC2 preview host&lt;/li&gt;
&lt;li&gt;Jenkins can reach the EC2 host over SSH&lt;/li&gt;
&lt;li&gt;the host will expose temporary preview ports&lt;/li&gt;
&lt;li&gt;the EC2 instance can pull ECR images using an IAM instance profile&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  terraform/versions.tf
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.6"&lt;/span&gt;

  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 6.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Optional but recommended:&lt;/span&gt;
  &lt;span class="c1"&gt;#&lt;/span&gt;
  &lt;span class="c1"&gt;# backend "s3" {&lt;/span&gt;
  &lt;span class="c1"&gt;#   bucket  = "&amp;lt;YOUR_TERRAFORM_STATE_BUCKET&amp;gt;"&lt;/span&gt;
  &lt;span class="c1"&gt;#   key     = "whispergate/preview/terraform.tfstate"&lt;/span&gt;
  &lt;span class="c1"&gt;#   region  = "eu-north-1"&lt;/span&gt;
  &lt;span class="c1"&gt;#   encrypt = true&lt;/span&gt;
  &lt;span class="c1"&gt;# }&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use an S3 backend, bootstrap that bucket first. Terraform cannot depend on a backend bucket that does not exist yet.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  terraform/variables.tf
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"aws_region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-north-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"instance_type"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Choose an EC2 size appropriate for your account and workload."&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"t3.micro"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"public_ssh_key"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Public key corresponding to the private key stored in Jenkins."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"ssh_cidr"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CIDR allowed to SSH into the preview host."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not blindly assume an instance type is free-tier eligible. AWS pricing and free-tier rules change. Check your account and region.&lt;/p&gt;




&lt;h2&gt;
  
  
  terraform/main.tf
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc"&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_ami"&lt;/span&gt; &lt;span class="s2"&gt;"ubuntu"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;most_recent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;owners&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"099720109477"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# Canonical&lt;/span&gt;

  &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"name"&lt;/span&gt;
    &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"virtualization-type"&lt;/span&gt;
    &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hvm"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_key_pair"&lt;/span&gt; &lt;span class="s2"&gt;"preview"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;key_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whispergate-preview"&lt;/span&gt;
  &lt;span class="nx"&gt;public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_ssh_key&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"preview"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whispergate-preview"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"WhisperGate PR preview host"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SSH from Jenkins/admin network"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_cidr&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Demo architecture: preview containers are exposed directly by port.&lt;/span&gt;
  &lt;span class="c1"&gt;# Replace this with a reverse proxy/load balancer for a stronger design.&lt;/span&gt;
  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Temporary preview ports"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3999&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;egress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"ec2_assume_role"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;

    &lt;span class="nx"&gt;principals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Service"&lt;/span&gt;
      &lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ec2.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"preview"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whispergate-preview"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ec2_assume_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy_attachment"&lt;/span&gt; &lt;span class="s2"&gt;"ecr_read"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;policy_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_instance_profile"&lt;/span&gt; &lt;span class="s2"&gt;"preview"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whispergate-preview"&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_instance"&lt;/span&gt; &lt;span class="s2"&gt;"app_server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ami&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_ami&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ubuntu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;instance_type&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance_type&lt;/span&gt;
  &lt;span class="nx"&gt;key_name&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_key_pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key_name&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_security_group_ids&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;iam_instance_profile&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_instance_profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;preview&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;associate_public_ip_address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;user_data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
    #!/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
&lt;/span&gt;&lt;span class="no"&gt;  EOF

&lt;/span&gt;  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"whispergate-preview"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IAM instance profile is an important design decision.&lt;/p&gt;

&lt;p&gt;The EC2 server needs to run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecr get-login-password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so it can pull the private image.&lt;/p&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; solve this by copying the Jenkins AWS access key onto EC2.&lt;/p&gt;

&lt;p&gt;Give the instance an IAM role instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  terraform/outputs.tf
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"EC2_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Public DNS name of the preview host."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_dns&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jenkins can consume that output with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; EC2_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h1&gt;
  
  
  15. Terraform stages in Jenkins
&lt;/h1&gt;

&lt;p&gt;Initialization and validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Terraform Init &amp;amp; Validate'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
            &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
        &lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'terraform init -input=false'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'terraform validate'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Terraform Plan'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;
            &lt;span class="o"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
            &lt;span class="o"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;sshUserPrivateKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'aws-ec2-ssh-key'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;keyFileVariable:&lt;/span&gt; &lt;span class="s1"&gt;'SSH_KEY_FILE'&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                    terraform plan \
                      -var "public_ssh_key=$(ssh-keygen -y -f "$SSH_KEY_FILE")" \
                      -var "ssh_cidr=&amp;lt;YOUR_JENKINS_PUBLIC_IP&amp;gt;/32" \
                      -out=tfplan
                '''&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the &lt;strong&gt;same saved plan&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Terraform Apply'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
            &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
        &lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'terraform apply -auto-approve tfplan'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production infrastructure, an approval gate can be appropriate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Approval Gate'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nl"&gt;message:&lt;/span&gt; &lt;span class="s1"&gt;'Apply this infrastructure change?'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;ok:&lt;/span&gt; &lt;span class="s1"&gt;'Deploy'&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h1&gt;
  
  
  16. Why &lt;code&gt;terraform apply&lt;/code&gt; finishing does not mean EC2 is ready
&lt;/h1&gt;

&lt;p&gt;This was one of the subtler problems.&lt;/p&gt;

&lt;p&gt;Terraform can report that the EC2 instance exists while &lt;code&gt;cloud-init&lt;/code&gt; is still:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;updating packages&lt;/li&gt;
&lt;li&gt;installing Docker&lt;/li&gt;
&lt;li&gt;installing/configuring AWS CLI&lt;/li&gt;
&lt;li&gt;starting services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Jenkins immediately SSHes in and runs Docker commands, the deployment can fail even though Terraform itself succeeded.&lt;/p&gt;

&lt;p&gt;So the deployment stage waits for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/lib/cloud/instance/boot-finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and also verifies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws
docker
Docker service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A readiness loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; 3 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if &lt;/span&gt;ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_KEY_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_USER&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$EC2_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s1"&gt;'test -f /var/lib/cloud/instance/boot-finished &amp;amp;&amp;amp;
         command -v aws &amp;gt;/dev/null &amp;amp;&amp;amp;
         command -v docker &amp;gt;/dev/null &amp;amp;&amp;amp;
         sudo systemctl is-active --quiet docker'&lt;/span&gt;
    &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"EC2 host is ready for deployment."&lt;/span&gt;
        &lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi

    if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 3 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"EC2 did not finish cloud-init in time."&lt;/span&gt;

        ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_KEY_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_USER&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$EC2_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="s1"&gt;'sudo tail -n 100 /var/log/cloud-init-output.log || true'&lt;/span&gt;

        &lt;span class="nb"&gt;exit &lt;/span&gt;1
    &lt;span class="k"&gt;fi

    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;100
    &lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;attempt &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cloud-init-output.log&lt;/code&gt; fallback is extremely useful. It turns "SSH deployment failed" into actual evidence.&lt;/p&gt;

&lt;p&gt;For a hardened setup, replace &lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt; with proper known-host management.&lt;/p&gt;




&lt;h1&gt;
  
  
  17. Create &lt;code&gt;deploy-preview.sh&lt;/code&gt;
&lt;/h1&gt;

&lt;p&gt;The exact deployment script will depend on your app. This is a cleaned version of the pattern used in this system.&lt;/p&gt;

&lt;p&gt;The app listens on container port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Jenkins build receives a host port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3000 + BUILD_NUMBER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build 1   → host port 3001
Build 2   → host port 3002
Build 23  → host port 3023
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;deploy-preview.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;BUILD_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?build&lt;span class="p"&gt; id required&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;ENV_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;:?environment&lt;span class="p"&gt; file required&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eu-north-1"&lt;/span&gt;
&lt;span class="nv"&gt;AWS_ACCOUNT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;AWS_ACCOUNT_ID&amp;gt;"&lt;/span&gt;
&lt;span class="nv"&gt;ECR_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ci-cd/jenkins"&lt;/span&gt;

&lt;span class="nv"&gt;REGISTRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCOUNT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.dkr.ecr.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AWS_REGION&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.amazonaws.com"&lt;/span&gt;
&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REGISTRY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ECR_REPOSITORY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUILD_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;CONTAINER_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"whispergate-preview-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUILD_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;HOST_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;3000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; BUILD_ID&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="nv"&gt;CONTAINER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3001

&lt;span class="c"&gt;# Six-hour temporary preview&lt;/span&gt;
&lt;span class="nv"&gt;EXPIRES_AT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'+6 hours'&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%dT%H:%M:%SZ'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Deploying &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Container: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Host port: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOST_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Expires at: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EXPIRES_AT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Replace the same build's container if this script is re-run.&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;docker pull &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env-file&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ENV_FILE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOST_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--label&lt;/span&gt; &lt;span class="s2"&gt;"preview=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--label&lt;/span&gt; &lt;span class="s2"&gt;"expires-at=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EXPIRES_AT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The labels are important:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;preview=true
expires-at=&amp;lt;UTC timestamp&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They give the cleanup job a machine-readable way to identify preview containers without maintaining another database.&lt;/p&gt;




&lt;h1&gt;
  
  
  18. Create &lt;code&gt;delete-cron.sh&lt;/code&gt;
&lt;/h1&gt;

&lt;p&gt;A lightweight preview system needs lifecycle management.&lt;/p&gt;

&lt;p&gt;Otherwise every PR/build leaves a container running forever.&lt;/p&gt;

&lt;p&gt;Create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;NOW_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

docker ps &lt;span class="nt"&gt;-aq&lt;/span&gt; &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="s2"&gt;"label=preview=true"&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; CONTAINER_ID&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue

    &lt;/span&gt;&lt;span class="nv"&gt;EXPIRES_AT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;
        docker inspect &lt;span class="se"&gt;\&lt;/span&gt;
          &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ index .Config.Labels "expires-at" }}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRES_AT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue

    &lt;/span&gt;&lt;span class="nv"&gt;EXPIRES_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRES_AT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRES_EPOCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRES_EPOCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NOW_EPOCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Name}}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s#^/##'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Removing expired preview: &lt;/span&gt;&lt;span class="nv"&gt;$NAME&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRES_AT&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
        docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install it in cron from the Jenkins deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;crontab &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; delete-cron&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"*/15 * * * * bash /home/ubuntu/delete-cron.sh"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; | crontab -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This checks every 15 minutes.&lt;/p&gt;

&lt;p&gt;The design is intentionally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Container labels = state
Cron = garbage collector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a larger platform, you would likely move this responsibility into a proper preview-environment controller or lifecycle service.&lt;/p&gt;




&lt;h1&gt;
  
  
  19. Deploy the preview from Jenkins
&lt;/h1&gt;

&lt;p&gt;The deployment stage needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS credentials for Terraform/API operations&lt;/li&gt;
&lt;li&gt;the EC2 SSH private key&lt;/li&gt;
&lt;li&gt;the application secret environment file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy Preview to AWS'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;
            &lt;span class="o"&gt;[&lt;/span&gt;
                &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
            &lt;span class="o"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;sshUserPrivateKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'aws-ec2-ssh-key'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;keyFileVariable:&lt;/span&gt; &lt;span class="s1"&gt;'SSH_KEY_FILE'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;usernameVariable:&lt;/span&gt; &lt;span class="s1"&gt;'SSH_USER'&lt;/span&gt;
            &lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'whispergate-preview-env'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'PREVIEW_ENV_FILE'&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                    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 &amp;amp;&amp;amp;
                             command -v aws &amp;gt;/dev/null &amp;amp;&amp;amp;
                             command -v docker &amp;gt;/dev/null &amp;amp;&amp;amp;
                             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 &amp;lt;&amp;lt;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 &amp;lt;AWS_ACCOUNT_ID&amp;gt;.dkr.ecr.eu-north-1.amazonaws.com

                        bash ~/deploy-preview.sh \
                          $BUILD_NUMBER \
                          ~/.whispergate-preview.env

                        (crontab -l 2&amp;gt;/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" &amp;gt; /dev/null; then
                            printf "http://%s:%s\\n" \
                                "$EC2_HOST" \
                                "$PORT" \
                                &amp;gt; ../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
                '''&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  20. Why the health check matters
&lt;/h1&gt;

&lt;p&gt;Do not publish a preview URL merely because:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returned successfully.&lt;/p&gt;

&lt;p&gt;A container can start and then immediately crash.&lt;/p&gt;

&lt;p&gt;Instead, Jenkins polls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$EC2_HOST&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;/health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;runs up to 10 times, with three seconds between attempts.&lt;/p&gt;

&lt;p&gt;Only after that succeeds do we create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;preview-url.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file becomes the contract between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment stage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub publishing stage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the check-publishing code independent from Terraform and SSH details.&lt;/p&gt;




&lt;h1&gt;
  
  
  21. The most important GitHub/Jenkins part: use a GitHub App
&lt;/h1&gt;

&lt;p&gt;Initially it is tempting to connect Jenkins using a GitHub Personal Access Token.&lt;/p&gt;

&lt;p&gt;A PAT can work for repository discovery and classic commit statuses.&lt;/p&gt;

&lt;p&gt;But the goal here is a real GitHub Check Run with a custom Details URL.&lt;/p&gt;

&lt;p&gt;For Jenkins' GitHub Checks integration, use a &lt;strong&gt;GitHub App&lt;/strong&gt; with &lt;code&gt;Checks: Read &amp;amp; write&lt;/code&gt;.&lt;/p&gt;




&lt;h1&gt;
  
  
  22. Create the GitHub App
&lt;/h1&gt;

&lt;p&gt;On GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Profile
→ Settings
→ Developer settings
→ GitHub Apps
→ New GitHub App
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a name such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WhisperGate Jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a homepage URL, for example your repository/project page.&lt;/p&gt;

&lt;p&gt;Set the webhook URL to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://&amp;lt;YOUR-JENKINS-HOST&amp;gt;/github-webhook/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For event-driven builds, GitHub must be able to reach this endpoint.&lt;/p&gt;

&lt;p&gt;If Jenkins is running only on your laptop, a public GitHub webhook cannot directly reach &lt;code&gt;localhost&lt;/code&gt;. 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  GitHub App repository permissions
&lt;/h2&gt;

&lt;p&gt;For GitHub Branch Source, Jenkins documents permissions including:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Commit statuses: Read &amp;amp; write
Contents: Read-only
Metadata: Read-only
Pull requests: Read-only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GitHub Checks plugin adds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checks: Read &amp;amp; write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the useful set for this tutorial is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checks             Read &amp;amp; write
Commit statuses    Read &amp;amp; write
Contents           Read-only
Metadata           Read-only
Pull requests      Read-only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the minimum access your workflow needs.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Create the App.&lt;/p&gt;




&lt;h1&gt;
  
  
  23. Do not confuse the Client Secret with the private key
&lt;/h1&gt;

&lt;p&gt;This caused some confusion during setup because the GitHub App page exposes OAuth-style values such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client ID
Client secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are not the credential Jenkins GitHub Branch Source is asking for.&lt;/p&gt;

&lt;p&gt;Scroll to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Private keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and click:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generate a private key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub downloads a &lt;code&gt;.pem&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;You need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App ID
Private key (.pem)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;not the OAuth client secret.&lt;/p&gt;




&lt;h1&gt;
  
  
  24. Convert the GitHub App private key for Jenkins
&lt;/h1&gt;

&lt;p&gt;The Jenkins GitHub Branch Source guide shows converting the key to PKCS#8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl pkcs8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-topk8&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-inform&lt;/span&gt; PEM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-outform&lt;/span&gt; PEM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-in&lt;/span&gt; github-app.private-key.pem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-out&lt;/span&gt; converted-github-app.pem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-nocrypt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;converted-github-app.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this secret.&lt;/p&gt;




&lt;h1&gt;
  
  
  25. Install the GitHub App on the repository
&lt;/h1&gt;

&lt;p&gt;On the GitHub App settings page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Install App
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a private project, prefer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Only select repositories
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and choose the repository Jenkins needs.&lt;/p&gt;

&lt;p&gt;This keeps the App's installation scope small.&lt;/p&gt;




&lt;h1&gt;
  
  
  26. Add the GitHub App credential to Jenkins
&lt;/h1&gt;

&lt;p&gt;Go to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manage Jenkins
→ Credentials
→ Global credentials
→ Add Credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kind: GitHub App
ID: github-app
Description: WhisperGate GitHub App
App ID: &amp;lt;NUMERIC_GITHUB_APP_ID&amp;gt;
Key: contents of converted-github-app.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the numeric &lt;strong&gt;App ID&lt;/strong&gt;, not the Client ID.&lt;/p&gt;

&lt;p&gt;After selecting a valid GitHub App credential in the GitHub Branch Source configuration, Jenkins should be able to verify the App.&lt;/p&gt;




&lt;h1&gt;
  
  
  27. Create/configure the Multibranch Pipeline
&lt;/h1&gt;

&lt;p&gt;Create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;New Item
→ Multibranch Pipeline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Branch Sources
→ Add source
→ GitHub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Credentials: github-app
Repository: your repository
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is important:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The GitHub App belongs in the Multibranch GitHub source configuration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You do &lt;strong&gt;not&lt;/strong&gt; need to wrap &lt;code&gt;publishChecks&lt;/code&gt; in a random PAT credential block.&lt;/p&gt;




&lt;h1&gt;
  
  
  28. Configure PR discovery
&lt;/h1&gt;

&lt;p&gt;For:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Discover pull requests from origin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;choose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merging the pull request with the current target branch revision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Jenkins to test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR head + current target branch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of only testing the PR tip in isolation.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feature commit
      │
      ├──────────┐
      │          │
      │       current dev/main
      │          │
      └──── merge test revision
                 │
                 ▼
              Jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is valuable because a PR can be individually valid but fail when combined with changes already present in the target branch.&lt;/p&gt;




&lt;h1&gt;
  
  
  29. Avoid duplicate PR builds
&lt;/h1&gt;

&lt;p&gt;There are two separate ways duplicate-looking Jenkins activity can happen:&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause A: PR discovery set to "Both"
&lt;/h2&gt;

&lt;p&gt;If PR discovery is configured to build both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Head
Merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then Jenkins is intentionally building two different revisions.&lt;/p&gt;

&lt;p&gt;For this tutorial, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause B: branch discovery also builds the same source branch
&lt;/h2&gt;

&lt;p&gt;If branch discovery is configured in a way that builds feature branches independently while the PR job also builds them, you can get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;branch job
PR merge job
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a PR-oriented flow, choose branch discovery rules that do not duplicate feature-branch PR builds. A common choice is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exclude branches that are also filed as PRs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then keep normal builds for long-lived branches such as &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;staging&lt;/code&gt; or &lt;code&gt;main&lt;/code&gt; as your workflow requires.&lt;/p&gt;




&lt;h1&gt;
  
  
  30. Do not manually checkout &lt;code&gt;dev&lt;/code&gt; in the Jenkinsfile
&lt;/h1&gt;

&lt;p&gt;This was one of the most important bugs in the pipeline.&lt;/p&gt;

&lt;p&gt;A Multibranch Pipeline already performs a special checkout for the PR.&lt;/p&gt;

&lt;p&gt;The log can look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR head
   +
target branch
   ↓
synthetic merge commit
   ↓
Declarative: Checkout SCM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you then add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Checkout'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;git&lt;/span&gt; &lt;span class="nl"&gt;branch:&lt;/span&gt; &lt;span class="s1"&gt;'dev'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'github-pat'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nl"&gt;url:&lt;/span&gt; &lt;span class="s1"&gt;'https://github.com/example/repo.git'&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you destroy the entire point of the PR merge checkout.&lt;/p&gt;

&lt;p&gt;Jenkins successfully checks out the PR merge revision, then your custom stage replaces the workspace with &lt;code&gt;dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pipeline "works", but it is testing/deploying the wrong code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correct solution
&lt;/h3&gt;

&lt;p&gt;Delete that manual stage.&lt;/p&gt;

&lt;p&gt;With:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'agent1'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Build'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Declarative Pipeline performs its normal SCM checkout automatically.&lt;/p&gt;

&lt;p&gt;If you intentionally disable the default checkout, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;checkout&lt;/span&gt; &lt;span class="n"&gt;scm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because &lt;code&gt;scm&lt;/code&gt; represents the exact branch/PR revision chosen by the Multibranch job.&lt;/p&gt;




&lt;h1&gt;
  
  
  31. Configure GitHub Checks in the Branch Source
&lt;/h1&gt;

&lt;p&gt;After installing &lt;strong&gt;Checks API&lt;/strong&gt; and &lt;strong&gt;GitHub Checks&lt;/strong&gt;, open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Multibranch Pipeline
→ Configure
→ Branch Sources
→ GitHub
→ Behaviors
→ Add
→ Status Checks Properties
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the final setup I want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Skip publishing status checks:
    unchecked

Status checks name:
    Jenkins

Skip GitHub Branch Source notifications:
    checked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;GitHub Branch Source can publish old-style Status API entries such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;continuous-integration/jenkins/branch
continuous-integration/jenkins/pr-merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the same time, the GitHub Checks plugin publishes the newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check Run.&lt;/p&gt;

&lt;p&gt;Without changing anything, your PR can suddenly show three Jenkins-looking checks.&lt;/p&gt;

&lt;p&gt;The GitHub Checks plugin explicitly supports disabling the Branch Source status notifications.&lt;/p&gt;

&lt;p&gt;Checking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Skip GitHub Branch Source notifications
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;removes those legacy status notifications while keeping the new Checks API result.&lt;/p&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Skip publishing status checks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because that would disable the new status-check behavior you actually want.&lt;/p&gt;

&lt;p&gt;After making this change, trigger a &lt;strong&gt;new commit/build&lt;/strong&gt;. Existing statuses on older commits do not disappear retroactively.&lt;/p&gt;




&lt;h1&gt;
  
  
  32. Publish the preview URL to GitHub
&lt;/h1&gt;

&lt;p&gt;This is the payoff.&lt;/p&gt;

&lt;p&gt;After deployment succeeds, Jenkins has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;preview-url.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;containing something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://ec2-xx-xx-xx-xx.eu-north-1.compute.amazonaws.com:3023
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;publishChecks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="nl"&gt;detailsURL:&lt;/span&gt; &lt;span class="n"&gt;previewUrl&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Publish Preview URL'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;allOf&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;expression&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fileExists&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'preview-url.txt'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;previewUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readFile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'preview-url.txt'&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;

            &lt;span class="n"&gt;currentBuild&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Preview: ${previewUrl}"&lt;/span&gt;

            &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Preview URL: ${previewUrl}"&lt;/span&gt;

            &lt;span class="n"&gt;publishChecks&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s1"&gt;'WhisperGate Preview'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="s1"&gt;'Preview deployment ready'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;summary:&lt;/span&gt; &lt;span class="s1"&gt;'Your PR preview is live.'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;text:&lt;/span&gt; &lt;span class="s2"&gt;"Preview: ${previewUrl}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;detailsURL:&lt;/span&gt; &lt;span class="n"&gt;previewUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="nl"&gt;conclusion:&lt;/span&gt; &lt;span class="s1"&gt;'SUCCESS'&lt;/span&gt;
            &lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical field is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="nl"&gt;detailsURL:&lt;/span&gt; &lt;span class="n"&gt;previewUrl&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub renders the check with a clickable Details destination.&lt;/p&gt;

&lt;p&gt;The PR now has two useful checks with different responsibilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Jenkins
    Overall CI/build status

✓ WhisperGate Preview
    Preview deployment ready
    Details → live application
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation is cleaner than hijacking the normal Jenkins build link.&lt;/p&gt;




&lt;h1&gt;
  
  
  33. Full cleaned Jenkinsfile
&lt;/h1&gt;

&lt;p&gt;The Jenkinsfile below is a consolidated reference version of the working design.&lt;/p&gt;

&lt;p&gt;It intentionally removes experimental duplicate SonarQube stages and the earlier Docker Hub push because ECR is the registry used for deployment.&lt;/p&gt;

&lt;p&gt;It also avoids the manual &lt;code&gt;dev&lt;/code&gt; checkout that would overwrite the Multibranch PR merge revision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'agent1'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;AWS_REGION&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'eu-north-1'&lt;/span&gt;
        &lt;span class="n"&gt;AWS_ACCOUNT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;AWS_ACCOUNT_ID&amp;gt;'&lt;/span&gt;

        &lt;span class="n"&gt;ECR_REGISTRY&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"&lt;/span&gt;
        &lt;span class="n"&gt;ECR_REPOSITORY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ci-cd/jenkins'&lt;/span&gt;
        &lt;span class="n"&gt;ECR_IMAGE&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${ECR_REGISTRY}/${ECR_REPOSITORY}"&lt;/span&gt;

        &lt;span class="n"&gt;TAG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${env.BUILD_NUMBER}"&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="cm"&gt;/*
         * No manual Git checkout here.
         *
         * In a Multibranch Pipeline, Jenkins' automatic SCM checkout
         * preserves the PR/merge revision selected by GitHub Branch Source.
         */&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Install &amp;amp; Build'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'npm ci'&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'npm run build'&lt;/span&gt;

                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                    docker build \
                      -t "$ECR_IMAGE:$TAG" \
                      .
                '''&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Test'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'npm test --if-present'&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SonarQube Analysis'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;scannerHome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="s1"&gt;'SonarScanner'&lt;/span&gt;

                    &lt;span class="n"&gt;withSonarQubeEnv&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SonarQube'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s2"&gt;"${scannerHome}/bin/sonar-scanner"&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;

                &lt;span class="n"&gt;archiveArtifacts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="nl"&gt;artifacts:&lt;/span&gt; &lt;span class="s1"&gt;'.scannerwork/report-task.txt'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="nl"&gt;allowEmptyArchive:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Trivy Image Scan'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                    TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")

                    trivy image \
                      --severity CRITICAL,HIGH,MEDIUM \
                      --output "trivy-report-${TIMESTAMP}.txt" \
                      "$ECR_IMAGE:$TAG"
                '''&lt;/span&gt;

                &lt;span class="n"&gt;archiveArtifacts&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                    &lt;span class="nl"&gt;artifacts:&lt;/span&gt; &lt;span class="s1"&gt;'trivy-report-*.txt'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="nl"&gt;allowEmptyArchive:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
                &lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Push to ECR'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
                    &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
                &lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                        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"
                    '''&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Terraform Init &amp;amp; Validate'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
                    &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
                &lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'terraform init -input=false'&lt;/span&gt;
                        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'terraform validate'&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Terraform Plan'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;
                    &lt;span class="o"&gt;[&lt;/span&gt;
                        &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
                    &lt;span class="o"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;sshUserPrivateKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'aws-ec2-ssh-key'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;keyFileVariable:&lt;/span&gt; &lt;span class="s1"&gt;'SSH_KEY_FILE'&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                            terraform plan \
                              -var "public_ssh_key=$(ssh-keygen -y -f "$SSH_KEY_FILE")" \
                              -var "ssh_cidr=&amp;lt;YOUR_JENKINS_PUBLIC_IP&amp;gt;/32" \
                              -out=tfplan
                        '''&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Terraform Apply'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([[&lt;/span&gt;
                    &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                    &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
                &lt;span class="o"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'terraform apply -auto-approve tfplan'&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy Preview to AWS'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;withCredentials&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;
                    &lt;span class="o"&gt;[&lt;/span&gt;
                        &lt;span class="n"&gt;$class&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'AmazonWebServicesCredentialsBinding'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'jenkins-ecr'&lt;/span&gt;
                    &lt;span class="o"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;sshUserPrivateKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'aws-ec2-ssh-key'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;keyFileVariable:&lt;/span&gt; &lt;span class="s1"&gt;'SSH_KEY_FILE'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;usernameVariable:&lt;/span&gt; &lt;span class="s1"&gt;'SSH_USER'&lt;/span&gt;
                    &lt;span class="o"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="nl"&gt;credentialsId:&lt;/span&gt; &lt;span class="s1"&gt;'whispergate-preview-env'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;variable:&lt;/span&gt; &lt;span class="s1"&gt;'PREVIEW_ENV_FILE'&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'terraform'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                            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 &amp;amp;&amp;amp;
                                     command -v aws &amp;gt;/dev/null &amp;amp;&amp;amp;
                                     command -v docker &amp;gt;/dev/null &amp;amp;&amp;amp;
                                     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 &amp;lt;&amp;lt;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&amp;gt;/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" \
                                    &amp;gt; /dev/null
                                then
                                    printf "http://%s:%s\\n" \
                                        "$EC2_HOST" \
                                        "$PORT" \
                                        &amp;gt; ../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
                        '''&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Publish Preview URL'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;allOf&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;changeRequest&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                    &lt;span class="n"&gt;expression&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;fileExists&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'preview-url.txt'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;}&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;previewUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;readFile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'preview-url.txt'&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;

                    &lt;span class="n"&gt;currentBuild&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Preview: ${previewUrl}"&lt;/span&gt;

                    &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Preview URL: ${previewUrl}"&lt;/span&gt;

                    &lt;span class="n"&gt;publishChecks&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s1"&gt;'WhisperGate Preview'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;title:&lt;/span&gt; &lt;span class="s1"&gt;'Preview deployment ready'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;summary:&lt;/span&gt; &lt;span class="s1"&gt;'Your PR preview is live.'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;text:&lt;/span&gt; &lt;span class="s2"&gt;"Preview: ${previewUrl}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;detailsURL:&lt;/span&gt; &lt;span class="n"&gt;previewUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                        &lt;span class="nl"&gt;conclusion:&lt;/span&gt; &lt;span class="s1"&gt;'SUCCESS'&lt;/span&gt;
                    &lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Build #${env.BUILD_NUMBER} succeeded."&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;failure&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Build #${env.BUILD_NUMBER} failed."&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Build #${env.BUILD_NUMBER} finished."&lt;/span&gt;
            &lt;span class="n"&gt;cleanWs&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  34. A note about &lt;code&gt;BUILD_NUMBER&lt;/code&gt; versus &lt;code&gt;CHANGE_ID&lt;/code&gt;
&lt;/h1&gt;

&lt;p&gt;The working preview design above uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BUILD_NUMBER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;image tags&lt;/li&gt;
&lt;li&gt;preview container names&lt;/li&gt;
&lt;li&gt;preview ports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is easy to implement because every Jenkins run already has a unique number.&lt;/p&gt;

&lt;p&gt;But it means a single PR can create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR #23
  build 101 → preview 101
  build 102 → preview 102
  build 103 → preview 103
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stronger Vercel-like design uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CHANGE_ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for the preview identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR #23 → whispergate-preview-23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then each push replaces the same PR environment.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR #23 opened
    ↓
preview-23

new commit pushed
    ↓
replace preview-23

PR #23 closed
    ↓
destroy preview-23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I would treat that as the next lifecycle improvement rather than mixing it into the first working version.&lt;/p&gt;




&lt;h1&gt;
  
  
  35. What the final GitHub PR should show
&lt;/h1&gt;

&lt;p&gt;When everything is connected correctly, a PR build should eventually show something conceptually like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;All checks have passed

✓ Jenkins
  Build completed successfully

✓ WhisperGate Preview
  Preview deployment ready
  Details →
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking &lt;strong&gt;Details&lt;/strong&gt; on &lt;code&gt;WhisperGate Preview&lt;/code&gt; opens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://&amp;lt;EC2_PUBLIC_DNS&amp;gt;:&amp;lt;PREVIEW_PORT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GitHub UI is now the entry point for the developer.&lt;/p&gt;

&lt;p&gt;They do not need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;open Jenkins&lt;/li&gt;
&lt;li&gt;inspect Terraform output&lt;/li&gt;
&lt;li&gt;SSH into EC2&lt;/li&gt;
&lt;li&gt;search a console log for a port&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the developer-experience win.&lt;/p&gt;




&lt;h1&gt;
  
  
  36. Troubleshooting: &lt;code&gt;publishChecks&lt;/code&gt; runs but GitHub shows no Check
&lt;/h1&gt;

&lt;p&gt;Symptom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Pipeline] publishChecks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;appears in Jenkins, but GitHub shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checks 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and only old statuses such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;continuous-integration/jenkins/branch
continuous-integration/jenkins/pr-merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Checks API&lt;/strong&gt; plugin is installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Checks&lt;/strong&gt; plugin is installed.&lt;/li&gt;
&lt;li&gt;GitHub Branch Source is using a &lt;strong&gt;GitHub App&lt;/strong&gt;, not only a PAT.&lt;/li&gt;
&lt;li&gt;The GitHub App has:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Checks: Read &amp;amp; write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The App is installed on the repository.&lt;/li&gt;
&lt;li&gt;Jenkins is using the correct App ID/private key.&lt;/li&gt;
&lt;li&gt;You generated the &lt;strong&gt;private key&lt;/strong&gt;, not an OAuth client secret.&lt;/li&gt;
&lt;li&gt;The GitHub App credential is selected in the Multibranch Branch Source.&lt;/li&gt;
&lt;/ol&gt;




&lt;h1&gt;
  
  
  37. Troubleshooting: three Jenkins checks appear
&lt;/h1&gt;

&lt;p&gt;Symptom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;continuous-integration/jenkins/branch
continuous-integration/jenkins/pr-merge
Jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not necessarily three different CI systems.&lt;/p&gt;

&lt;p&gt;It is usually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Branch Source old Status API
+
GitHub Checks new Checks API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Multibranch
→ Configure
→ Branch Sources
→ GitHub
→ Behaviors
→ Add
→ Status Checks Properties
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Skip GitHub Branch Source notifications
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Skip publishing status checks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;unchecked.&lt;/p&gt;

&lt;p&gt;Trigger a new commit afterward.&lt;/p&gt;




&lt;h1&gt;
  
  
  38. Troubleshooting: the PR pipeline deploys &lt;code&gt;dev&lt;/code&gt; instead of the PR
&lt;/h1&gt;

&lt;p&gt;Look at the checkout logs.&lt;/p&gt;

&lt;p&gt;If you see Jenkins first do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merging target branch into PR head
Checking out Revision &amp;lt;synthetic-merge&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then later see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checking out Revision &amp;lt;dev SHA&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;your Jenkinsfile probably contains a manual checkout like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;git&lt;/span&gt; &lt;span class="nl"&gt;branch:&lt;/span&gt; &lt;span class="s1"&gt;'dev'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete it.&lt;/p&gt;

&lt;p&gt;In a Multibranch Pipeline, trust the &lt;code&gt;scm&lt;/code&gt; selected by Jenkins.&lt;/p&gt;

&lt;p&gt;This bug is especially dangerous because the pipeline may remain completely green while testing the wrong revision.&lt;/p&gt;




&lt;h1&gt;
  
  
  39. Troubleshooting: EC2 exists but deployment says Docker/AWS is missing
&lt;/h1&gt;

&lt;p&gt;Terraform is finished; cloud-init is not.&lt;/p&gt;

&lt;p&gt;Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 100 /var/log/cloud-init-output.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/lib/cloud/instance/boot-finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait until the bootstrap script is actually complete.&lt;/p&gt;

&lt;p&gt;Do not use an arbitrary &lt;code&gt;sleep 10&lt;/code&gt; and hope.&lt;/p&gt;

&lt;p&gt;Use readiness conditions.&lt;/p&gt;




&lt;h1&gt;
  
  
  40. Troubleshooting: &lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt; AccessDenied
&lt;/h1&gt;

&lt;p&gt;Validate credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sts get-caller-identity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that succeeds but:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecr get-login-password
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fails, inspect IAM.&lt;/p&gt;

&lt;p&gt;Remember:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ecr:GetAuthorizationToken"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;must be allowed.&lt;/p&gt;

&lt;p&gt;Then make sure the repository-specific push actions are also allowed.&lt;/p&gt;




&lt;h1&gt;
  
  
  41. Troubleshooting: Docker works on the host but not on the Jenkins agent
&lt;/h1&gt;

&lt;p&gt;Check the mounted socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; agent1 &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Jenkins user's groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; agent1 &lt;span class="nb"&gt;id &lt;/span&gt;jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the host socket GID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'%g'&lt;/span&gt; /var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The group permissions inside the container need to match.&lt;/p&gt;




&lt;h1&gt;
  
  
  42. Troubleshooting: SonarQube is reachable from your browser but not Jenkins
&lt;/h1&gt;

&lt;p&gt;From your browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;works because your browser is outside Docker.&lt;/p&gt;

&lt;p&gt;From the Jenkins Docker network, Jenkins should use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://sonarqube:9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the Jenkins container, &lt;code&gt;localhost&lt;/code&gt; means Jenkins itself.&lt;/p&gt;




&lt;h1&gt;
  
  
  43. Security improvements I would make before calling this production-grade
&lt;/h1&gt;

&lt;p&gt;This architecture is a learning/preview platform, not the finished version of a production deployment system.&lt;/p&gt;

&lt;p&gt;Here are the first things I would harden.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Stop exposing a range of raw ports publicly
&lt;/h2&gt;

&lt;p&gt;The demo uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EC2_HOST:3001
EC2_HOST:3002
EC2_HOST:3003
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cleaner architecture is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pr-23.preview.example.com
        ↓
wildcard DNS
        ↓
TLS
        ↓
reverse proxy / ALB / ingress layer
        ↓
container
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removes the need to expose &lt;code&gt;3000-3999&lt;/code&gt; to the public internet.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Restrict SSH properly
&lt;/h2&gt;

&lt;p&gt;For learning, it is common to temporarily allow broad SSH while debugging.&lt;/p&gt;

&lt;p&gt;Do not leave:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0.0.0/0 → port 22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;in a real environment.&lt;/p&gt;

&lt;p&gt;Restrict it to the Jenkins egress IP, VPN, bastion or another trusted network.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Replace &lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Manage host keys or known hosts instead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt; is convenient while iterating, but it removes a layer of SSH authenticity verification.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Avoid long-lived AWS user credentials where possible
&lt;/h2&gt;

&lt;p&gt;A stronger Jenkins deployment would run in AWS with an IAM role, use OIDC/federation, or otherwise obtain short-lived AWS credentials.&lt;/p&gt;

&lt;p&gt;The EC2 side already follows the better model by using an instance profile for ECR pull.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Treat Docker socket access as privileged
&lt;/h2&gt;

&lt;p&gt;The Jenkins agent can control the host Docker daemon.&lt;/p&gt;

&lt;p&gt;That means you should not treat arbitrary external fork PRs as safe workloads.&lt;/p&gt;

&lt;p&gt;If you want to build untrusted code, isolate builds more aggressively.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Make security checks enforce policy
&lt;/h2&gt;

&lt;p&gt;Once you trust the scans:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Critical vulnerability → fail build
Quality gate failed     → fail build
Tests failed            → fail build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reporting is step one.&lt;/p&gt;

&lt;p&gt;Enforcement is step two.&lt;/p&gt;




&lt;h1&gt;
  
  
  44. Where I would take the preview platform next
&lt;/h1&gt;

&lt;p&gt;The first working version gets the developer experience right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR → CI → temporary deployment → clickable preview
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next improvements are mostly lifecycle and routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stable PR identities
&lt;/h2&gt;

&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CHANGE_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BUILD_NUMBER
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for container/environment identity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Destroy previews when PRs close
&lt;/h2&gt;

&lt;p&gt;Time-based expiry works, but PR lifecycle is a better signal.&lt;/p&gt;

&lt;p&gt;Eventually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR closed
    ↓
Jenkins/GitHub event
    ↓
remove preview immediately
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep TTL cleanup as a safety net.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wildcard domains
&lt;/h2&gt;

&lt;p&gt;Move toward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pr-23.preview.whispergate.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ec2-public-host:3023
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  HTTPS
&lt;/h2&gt;

&lt;p&gt;Use an ALB, Caddy, Traefik, Nginx, Cloudflare or another edge/reverse-proxy approach to terminate TLS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reuse infrastructure
&lt;/h2&gt;

&lt;p&gt;Provisioning the EC2 host should not necessarily happen from scratch for every PR.&lt;/p&gt;

&lt;p&gt;Terraform state can keep the base host stable while Jenkins creates/removes preview containers on it.&lt;/p&gt;

&lt;p&gt;At a larger scale, you might graduate to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ECS
Kubernetes
Nomad
serverless container platforms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;but building the simpler EC2 version first makes the underlying mechanics much easier to understand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Better observability
&lt;/h2&gt;

&lt;p&gt;Add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;centralized application logs&lt;/li&gt;
&lt;li&gt;deployment duration metrics&lt;/li&gt;
&lt;li&gt;preview inventory&lt;/li&gt;
&lt;li&gt;container CPU/memory monitoring&lt;/li&gt;
&lt;li&gt;alerting for failed preview cleanup&lt;/li&gt;
&lt;li&gt;GitHub comments/check annotations for scan summaries&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  45. What I learned building this
&lt;/h1&gt;

&lt;p&gt;The most interesting part of this project was not any individual tool.&lt;/p&gt;

&lt;p&gt;It was the boundaries between them.&lt;/p&gt;

&lt;p&gt;Terraform can say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EC2 created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while cloud-init is still installing Docker.&lt;/p&gt;

&lt;p&gt;Docker can say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;container started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while the application is about to crash.&lt;/p&gt;

&lt;p&gt;Jenkins can say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PR checkout successful
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then your own checkout stage can silently replace the PR with &lt;code&gt;dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;publishChecks&lt;/code&gt; can execute in Jenkins while your GitHub authentication is still incapable of publishing the kind of Check Run you expect.&lt;/p&gt;

&lt;p&gt;GitHub can show three checks that all appear to be "Jenkins" but are actually two different GitHub reporting APIs.&lt;/p&gt;

&lt;p&gt;AWS credentials can successfully call STS and still fail ECR because one specific permission is missing.&lt;/p&gt;

&lt;p&gt;That is what made the project useful.&lt;/p&gt;

&lt;p&gt;A CI/CD pipeline is not one system.&lt;/p&gt;

&lt;p&gt;It is a chain of contracts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub
  → Jenkins SCM
  → workspace revision
  → build
  → scanner
  → image
  → registry
  → Terraform
  → EC2
  → cloud-init
  → Docker
  → application health
  → preview URL
  → GitHub Check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline is only as reliable as the weakest contract between two stages.&lt;/p&gt;




&lt;h1&gt;
  
  
  46. Final result
&lt;/h1&gt;

&lt;p&gt;At the end, the workflow I wanted was finally there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is still a lot I would improve before calling it a production preview platform.&lt;/p&gt;

&lt;p&gt;But that is exactly why I liked this project.&lt;/p&gt;

&lt;p&gt;Something as simple as a &lt;strong&gt;Preview&lt;/strong&gt; 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.&lt;/p&gt;




&lt;h1&gt;
  
  
  Reference documentation
&lt;/h1&gt;

&lt;p&gt;The following official/project documentation is useful when reproducing this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Jenkins GitHub Checks plugin: &lt;a href="https://plugins.jenkins.io/github-checks/" rel="noopener noreferrer"&gt;https://plugins.jenkins.io/github-checks/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jenkins Checks API plugin: &lt;a href="https://plugins.jenkins.io/checks-api/" rel="noopener noreferrer"&gt;https://plugins.jenkins.io/checks-api/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jenkins GitHub Branch Source — GitHub App authentication guide: &lt;a href="https://github.com/jenkinsci/github-branch-source-plugin/blob/master/docs/github-app.adoc" rel="noopener noreferrer"&gt;https://github.com/jenkinsci/github-branch-source-plugin/blob/master/docs/github-app.adoc&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Docs — registering a GitHub App: &lt;a href="https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app" rel="noopener noreferrer"&gt;https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Docs — managing private keys for GitHub Apps: &lt;a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps" rel="noopener noreferrer"&gt;https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jenkins AWS Credentials plugin: &lt;a href="https://plugins.jenkins.io/aws-credentials/" rel="noopener noreferrer"&gt;https://plugins.jenkins.io/aws-credentials/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS — pushing images to ECR: &lt;a href="https://docs.aws.amazon.com/AmazonECR/latest/userguide/docker-push-ecr-image.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonECR/latest/userguide/docker-push-ecr-image.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS — IAM permissions for ECR image push: &lt;a href="https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-push-iam.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-push-iam.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HashiCorp — Terraform output values: &lt;a href="https://developer.hashicorp.com/terraform/language/values/outputs" rel="noopener noreferrer"&gt;https://developer.hashicorp.com/terraform/language/values/outputs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HashiCorp — &lt;code&gt;terraform output&lt;/code&gt;: &lt;a href="https://developer.hashicorp.com/terraform/cli/commands/output" rel="noopener noreferrer"&gt;https://developer.hashicorp.com/terraform/cli/commands/output&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Trivy installation: &lt;a href="https://www.trivy.dev/docs/latest/getting-started/installation/" rel="noopener noreferrer"&gt;https://www.trivy.dev/docs/latest/getting-started/installation/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jenkins SonarQube Scanner plugin: &lt;a href="https://plugins.jenkins.io/sonar/" rel="noopener noreferrer"&gt;https://plugins.jenkins.io/sonar/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Jenkins Workspace Cleanup plugin: &lt;a href="https://plugins.jenkins.io/ws-cleanup/" rel="noopener noreferrer"&gt;https://plugins.jenkins.io/ws-cleanup/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A final note about the code in this article
&lt;/h2&gt;

&lt;p&gt;The snippets here are a cleaned and generalized version of a real working learning pipeline.&lt;/p&gt;

&lt;p&gt;I intentionally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;replaced AWS account-specific identifiers with placeholders&lt;/li&gt;
&lt;li&gt;removed secrets&lt;/li&gt;
&lt;li&gt;consolidated experimental duplicate stages&lt;/li&gt;
&lt;li&gt;removed the manual branch checkout that interfered with Multibranch PR merge builds&lt;/li&gt;
&lt;li&gt;removed an unnecessary duplicate Docker Hub push from the final reference flow&lt;/li&gt;
&lt;li&gt;added explicit PR-only guards to the infrastructure/deployment stages&lt;/li&gt;
&lt;li&gt;kept the raw EC2 hostname/port model because that was the milestone being demonstrated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes the tutorial safer to publish and easier for someone else to reproduce without copying environment-specific mistakes.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cicd</category>
      <category>devops</category>
      <category>terraform</category>
    </item>
  </channel>
</rss>
