<?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%2Faf7abde5-1c79-400a-a7a8-aec899ca213c.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>Deploying a Next.js and Spring Boot Monorepo to One EC2 Instance</title>
      <dc:creator>Caleb Ajibade</dc:creator>
      <pubDate>Tue, 15 Sep 2026 16:23:49 +0000</pubDate>
      <link>https://dev.to/caleb_ajibade/deploying-a-nextjs-and-spring-boot-monorepo-to-one-ec2-instance-35km</link>
      <guid>https://dev.to/caleb_ajibade/deploying-a-nextjs-and-spring-boot-monorepo-to-one-ec2-instance-35km</guid>
      <description>&lt;p&gt;I started with two repositories: a Next.js forum frontend and a Spring Boot API. I wanted one deployable unit: a monorepo, two immutable Docker images, and one EC2 instance exposing the complete application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git push (devops)
        ↓
GitHub Actions using OIDC
        ↓
Amazon ECR: API image + frontend image
        ↓
private S3 release package → AWS Systems Manager → EC2
        ↓
Docker Compose: Nginx → Next.js at /, Spring Boot at /api, MongoDB privately
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API is Java 21 / Spring Boot 3.5 with MongoDB and JWT authentication. The frontend is Next.js 15. Both now live in one repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;forum-api/
├── src/                 # Spring Boot API
├── frontend/            # Next.js application
├── deploy/
│   ├── docker-compose.yml
│   ├── nginx.conf
│   └── deploy-on-ec2.sh
└── .github/workflows/deploy.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. Containerise both applications
&lt;/h2&gt;

&lt;p&gt;The API image is built from the repository root with its existing multi-stage &lt;code&gt;dockerfile&lt;/code&gt;. The frontend has its own multi-stage build in &lt;code&gt;frontend/dockerfile&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="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_API_BASE_URL&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/.next ./.next&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/package.json ./package.json&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["npm", "start"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important frontend build argument is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--file&lt;/span&gt; frontend/dockerfile &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;NEXT_PUBLIC_API_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; forum-frontend:local frontend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/api&lt;/code&gt; is a relative browser URL. The browser calls the same hostname that served the frontend, which avoids exposing a second public port or relying on CORS between separate origins.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Run both containers behind Nginx
&lt;/h2&gt;

&lt;p&gt;Docker Compose runs four services on the EC2 host: &lt;code&gt;frontend&lt;/code&gt;, &lt;code&gt;api&lt;/code&gt;, &lt;code&gt;mongo&lt;/code&gt;, and &lt;code&gt;nginx&lt;/code&gt;. Only Nginx publishes a host port.&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;api&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;${IMAGE_URI:?Set IMAGE_URI to the ECR image URI}&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="pi"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;RUNTIME_ENV_FILE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;-../.env&lt;/span&gt;&lt;span class="pi"&gt;}]&lt;/span&gt;
    &lt;span class="na"&gt;expose&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"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;frontend&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;${FRONTEND_IMAGE_URI:?Set FRONTEND_IMAGE_URI to the ECR image URI}&lt;/span&gt;
    &lt;span class="na"&gt;expose&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;3000"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;mongo&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;mongo:7&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="nv"&gt;mongo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;/data/db&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;nginx&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;nginx:1.27-alpine&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;]&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;80:80"&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/conf.d/default.conf:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Nginx configuration has two deliberately different routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://frontend:3000&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 API proxy keeps the &lt;code&gt;/api&lt;/code&gt; prefix because the Spring controllers are already mapped below it. Every other route, including Next.js page routes, reaches the frontend container.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Use two private ECR repositories
&lt;/h2&gt;

&lt;p&gt;I created two immutable, scan-on-push ECR repositories in &lt;code&gt;eu-north-1&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;forum-api&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;forum-frontend&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The GitHub deployment role can push only to those repositories. The EC2 instance role can pull only from those repositories and download releases from one private S3 bucket. This keeps AWS permissions narrow while still allowing one workflow to deploy the complete stack.&lt;/p&gt;

&lt;p&gt;The GitHub repository variables are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AWS_REGION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;eu-north-1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ECR_REPOSITORY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;forum-api&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FRONTEND_ECR_REPOSITORY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;forum-frontend&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EC2_INSTANCE_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the target instance ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEPLOYMENT_BUCKET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the private release bucket&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one GitHub environment secret is &lt;code&gt;AWS_DEPLOY_ROLE_ARN&lt;/code&gt;. It is an IAM role ARN, not an access key. The JWT secret and runtime database/mail settings stay in &lt;code&gt;/opt/forum-api/.env&lt;/code&gt; on EC2 and are never committed or copied into GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Build and deploy through GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;devops&lt;/code&gt; workflow uses GitHub OIDC to obtain short-lived AWS credentials. It builds both images, tags each one with the commit SHA, and only pushes a tag if it does not already exist because the ECR repositories reject overwritten tags.&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="s"&gt;docker build --file dockerfile --tag "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" .&lt;/span&gt;
&lt;span class="s"&gt;docker build --file frontend/dockerfile \&lt;/span&gt;
  &lt;span class="s"&gt;--build-arg NEXT_PUBLIC_API_BASE_URL=/api \&lt;/span&gt;
  &lt;span class="s"&gt;--tag "$REGISTRY/$FRONTEND_ECR_REPOSITORY:$IMAGE_TAG" frontend&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow then packages &lt;code&gt;deploy/&lt;/code&gt;, uploads it to private S3, and sends an SSM command to EC2. The host script logs into ECR, sets both image URIs, then runs:&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 &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/docker-compose.yml pull
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No SSH port, GitHub deploy key, or long-lived AWS access key is required on the server. On a small EC2 root disk, use Next.js &lt;code&gt;output: "standalone"&lt;/code&gt; and copy only &lt;code&gt;.next/standalone&lt;/code&gt;, &lt;code&gt;.next/static&lt;/code&gt;, and &lt;code&gt;public&lt;/code&gt; into the runtime image. This avoids downloading a full development dependency tree. I also prune unused images before a pull, never volumes or running containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verification and next steps
&lt;/h2&gt;

&lt;p&gt;After a successful workflow, visit the EC2 public IP or a domain pointed to it:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;You should see the Next.js frontend. A request such as &lt;code&gt;/api/topics/&lt;/code&gt; should be handled by Spring Boot through Nginx. The API, MongoDB, and frontend ports remain private to the Docker network; port 80 is the only public application port. Because Nginx reads its configuration when it starts, I explicitly recreate that one stateless container after extracting a release package; otherwise a changed bind-mounted configuration would not take effect until its next restart.&lt;/p&gt;

&lt;p&gt;For a real production deployment, I would add a custom domain and HTTPS, configure a working SMTP provider, add an unauthenticated health endpoint, and move MongoDB to a managed database before the application needs durability across instance replacement.&lt;/p&gt;

&lt;p&gt;The useful pattern here is modest but robust: a monorepo gives one release version, ECR gives immutable images, Nginx gives one public entry point, and GitHub Actions plus OIDC and SSM gives an automated deployment without managing server SSH credentials.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>docker</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>How to Set Up AdGuard Home as Network-Wide DNS and DHCP on an Airtel Router</title>
      <dc:creator>Caleb Ajibade</dc:creator>
      <pubDate>Sun, 13 Sep 2026 18:37:39 +0000</pubDate>
      <link>https://dev.to/caleb_ajibade/how-to-set-up-adguard-home-as-network-wide-dns-and-dhcp-on-an-airtel-router-3md9</link>
      <guid>https://dev.to/caleb_ajibade/how-to-set-up-adguard-home-as-network-wide-dns-and-dhcp-on-an-airtel-router-3md9</guid>
      <description>&lt;p&gt;Many Airtel home routers provide DHCP but do not expose a setting for&lt;br&gt;
choosing the DNS server distributed to connected devices.&lt;/p&gt;

&lt;p&gt;That creates a problem if you want to run AdGuard Home for the entire&lt;br&gt;
network. You can install AdGuard and manually configure every phone,&lt;br&gt;
laptop, and TV to use it, but that quickly becomes inconvenient.&lt;/p&gt;

&lt;p&gt;A cleaner solution is to let &lt;strong&gt;AdGuard Home provide both DNS and DHCP&lt;/strong&gt;,&lt;br&gt;
while the Airtel router continues to provide Wi-Fi, NAT, and internet&lt;br&gt;
access.&lt;/p&gt;

&lt;p&gt;This tutorial shows how to build that setup using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  an Airtel home router&lt;/li&gt;
&lt;li&gt;  a Fedora computer&lt;/li&gt;
&lt;li&gt;  Docker and Docker Compose&lt;/li&gt;
&lt;li&gt;  AdGuard Home&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The example network used here is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Router:        192.xxx.x.1
Fedora:        192.xxx.x.13
Network:       192.xxx.x.0/24
Subnet Mask:   255.255.255.0
DHCP Pool:     192.xxx.x.100 - 192.xxx.x.200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your Airtel router may use different addresses. Check your own network&lt;br&gt;
before copying the IP addresses in this tutorial.&lt;/p&gt;


&lt;h2&gt;
  
  
  What We Are Building
&lt;/h2&gt;

&lt;p&gt;The final architecture will look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         Internet
                            │
                            ▼
                    ┌───────────────┐
                    │ Airtel Router │
                    │ 192.xxx.x.1  │
                    │               │
                    │ Gateway + NAT │
                    │ DHCP: OFF     │
                    └───────┬───────┘
                            │
              ┌─────────────┴─────────────┐
              │                           │
              ▼                           ▼
    ┌──────────────────┐             Other Devices
    │ Fedora Computer  │
    │ 192.xxx.x.13    │
    │                  │
    │ AdGuard Home     │
    │ DNS  → Port 53   │
    │ DHCP → Port 67   │
    └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The responsibilities are split like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Airtel Router
├── Wi-Fi
├── Internet connection
├── Default gateway
└── NAT

Fedora + AdGuard Home
├── DNS
├── DNS filtering
├── DNS caching
└── DHCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your normal internet traffic does &lt;strong&gt;not&lt;/strong&gt; pass through the Fedora&lt;br&gt;
computer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DNS:
Phone → AdGuard → Upstream DNS

Normal internet traffic:
Phone → Airtel Router → Internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction is important. AdGuard is the DNS and DHCP server, not&lt;br&gt;
the router.&lt;/p&gt;


&lt;h2&gt;
  
  
  Before You Start
&lt;/h2&gt;

&lt;p&gt;You should already have Docker and Docker Compose available on Fedora.&lt;/p&gt;

&lt;p&gt;Verify them:&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="nt"&gt;--version&lt;/span&gt;
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should also know the name of your active network interface.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nb"&gt;link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this tutorial, the Wi-Fi interface is:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;You can find your router's address with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route | &lt;span class="nb"&gt;grep &lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;default via 192.xxx.x.1 dev wlo1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.xxx.x.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is the Airtel router.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Create the AdGuard Home Docker Compose Project
&lt;/h2&gt;

&lt;p&gt;Create a directory for AdGuard:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/Documents/Dev/compose/adguard
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/Documents/Dev/compose/adguard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;compose.yaml
&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 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;adguardhome&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;adguard/adguardhome:edge&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;adguardhome&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&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;adguard_work:/opt/adguardhome/work&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;adguard_conf:/opt/adguardhome/conf&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;adguard_work&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adguard_conf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it:&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;/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 shell"&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the &lt;code&gt;adguardhome&lt;/code&gt; container running.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Why We Use Host Networking
&lt;/h2&gt;

&lt;p&gt;The important line in the Compose file is:&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;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AdGuard will eventually act as the DHCP server.&lt;/p&gt;

&lt;p&gt;DHCP clients initially have no IP configuration, so they use broadcast&lt;br&gt;
packets to discover a DHCP server.&lt;/p&gt;

&lt;p&gt;The process is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client                         AdGuard
  │                               │
  │──── DHCP Discover ───────────►│
  │                               │
  │◄──── DHCP Offer ──────────────│
  │                               │
  │──── DHCP Request ────────────►│
  │                               │
  │◄──── DHCP ACK ────────────────│
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is commonly called the DHCP &lt;strong&gt;DORA&lt;/strong&gt; process.&lt;/p&gt;

&lt;p&gt;Using host networking lets AdGuard interact directly with Fedora's&lt;br&gt;
physical network interface instead of sitting behind Docker's normal&lt;br&gt;
bridge network.&lt;/p&gt;

&lt;p&gt;Do not add a &lt;code&gt;ports:&lt;/code&gt; section when using this configuration. With host&lt;br&gt;
networking, AdGuard binds directly to ports on the Fedora host.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: Complete the Initial AdGuard Setup
&lt;/h2&gt;

&lt;p&gt;Open the AdGuard Home interface in your browser.&lt;/p&gt;

&lt;p&gt;Depending on the initial AdGuard setup state, the installation wizard&lt;br&gt;
may initially use another port. Complete the wizard and configure the&lt;br&gt;
final administrative interface to listen locally.&lt;/p&gt;

&lt;p&gt;For the finished configuration, use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Web interface:
127.0.0.1:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the administrative dashboard accessible from the Fedora&lt;br&gt;
computer without exposing it unnecessarily to every device on the Wi-Fi.&lt;/p&gt;

&lt;p&gt;After configuration, 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://127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4: Give the Fedora Computer a Static IP
&lt;/h2&gt;

&lt;p&gt;Before making Fedora the network's DNS and DHCP server, give it a&lt;br&gt;
predictable address.&lt;/p&gt;

&lt;p&gt;Find the active NetworkManager connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli connection show &lt;span class="nt"&gt;--active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy its UUID.&lt;/p&gt;

&lt;p&gt;You may have multiple saved connections with the same Wi-Fi name, so&lt;br&gt;
using the UUID is safer than using the connection name.&lt;/p&gt;

&lt;p&gt;For this example, Fedora will use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.xxx.x.13/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure 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;sudo &lt;/span&gt;nmcli connection modify &amp;lt;CONNECTION-UUID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.method manual &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.addresses &lt;span class="s2"&gt;"192.xxx.x.13/24"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.gateway &lt;span class="s2"&gt;"192.xxx.x.1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.dns &lt;span class="s2"&gt;"127.0.0.1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.ignore-auto-dns &lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli connection down &amp;lt;CONNECTION-UUID&amp;gt;
nmcli connection up &amp;lt;CONNECTION-UUID&amp;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;ip addr show wlo1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'inet '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.xxx.x.13/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;without the &lt;code&gt;dynamic&lt;/code&gt; flag.&lt;/p&gt;

&lt;p&gt;Check the default route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route | &lt;span class="nb"&gt;grep &lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should still point to the Airtel router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default via 192.xxx.x.1 dev wlo1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Airtel router remains your gateway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Reserve the Fedora Address on the Airtel Router
&lt;/h2&gt;

&lt;p&gt;Open the Airtel router's administration page.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://192.xxx.x.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for a feature named something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MAC-IP Bind
IP-MAC Binding
Address Reservation
Static Lease
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact name depends on the Airtel router model and firmware.&lt;/p&gt;

&lt;p&gt;Find Fedora's active Wi-Fi MAC address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;show wlo1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a binding between that MAC address and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.xxx.x.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents the router from accidentally assigning the server address&lt;br&gt;
to another device.&lt;/p&gt;

&lt;p&gt;Be aware that modern Wi-Fi systems may use randomized MAC addresses. Use&lt;br&gt;
the MAC address actually active on the Airtel Wi-Fi connection rather&lt;br&gt;
than assuming the adapter's permanent hardware address is being used.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 6: Configure AdGuard's DNS Listeners
&lt;/h2&gt;

&lt;p&gt;AdGuard should listen for DNS requests on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1
192.xxx.x.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;The purpose of each listener is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1:53
    Fedora itself can use AdGuard.

192.xxx.x.13:53
    Other devices on the LAN can use AdGuard.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the dashboard on:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;if you only want it accessible from the Fedora machine.&lt;/p&gt;

&lt;p&gt;Restart AdGuard if necessary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker restart adguardhome
&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-lntup&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;':53'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see AdGuard listening on both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1:53
192.xxx.x.13:53
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 7: Do Not Disable systemd-resolved
&lt;/h2&gt;

&lt;p&gt;Fedora normally runs &lt;code&gt;systemd-resolved&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You may see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.53:53
127.0.0.54:53
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;when checking port 53.&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 shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-lntup&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;':53'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;may show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1:53     AdGuardHome
192.xxx.x.13:53 AdGuardHome
127.0.0.53:53    systemd-resolved
127.0.0.54:53    systemd-resolved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is valid.&lt;/p&gt;

&lt;p&gt;The processes use different IP addresses even though they use the same&lt;br&gt;
port number.&lt;/p&gt;

&lt;p&gt;There is no need to disable &lt;code&gt;systemd-resolved&lt;/code&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 8: Test AdGuard Locally
&lt;/h2&gt;

&lt;p&gt;Test DNS directly against AdGuard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then test the LAN address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @192.xxx.x.13 google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both should return successful DNS responses.&lt;/p&gt;

&lt;p&gt;At this point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fedora → AdGuard DNS → Upstream DNS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is working.&lt;/p&gt;

&lt;p&gt;Do not disable Airtel DHCP yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Open DNS in Fedora's Firewall
&lt;/h2&gt;

&lt;p&gt;Other LAN devices need permission to reach Fedora on port 53.&lt;/p&gt;

&lt;p&gt;Open UDP DNS:&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 &lt;/span&gt;firewall-cmd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;53/udp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open TCP DNS:&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 &lt;/span&gt;firewall-cmd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;53/tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload:&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 &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&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;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="nt"&gt;--list-ports&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;53/tcp
53/udp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;among the configured ports.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Test AdGuard from Another Device
&lt;/h2&gt;

&lt;p&gt;Before changing DHCP, test whether another device can reach AdGuard.&lt;/p&gt;

&lt;p&gt;Temporarily configure a phone or another computer to use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DNS Server:
192.xxx.x.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Browse normally and check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdGuard Home → Query Log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see DNS requests from the device.&lt;/p&gt;

&lt;p&gt;This proves that LAN DNS works before making the entire network&lt;br&gt;
dependent on it.&lt;/p&gt;

&lt;p&gt;Return the test device to automatic DNS afterward if desired. DHCP will&lt;br&gt;
distribute the AdGuard address automatically later.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 11: Allow DHCP Through Fedora's Firewall
&lt;/h2&gt;

&lt;p&gt;AdGuard also needs to receive DHCP traffic.&lt;/p&gt;

&lt;p&gt;Run:&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 &lt;/span&gt;firewall-cmd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dhcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload:&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 &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&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;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="nt"&gt;--list-services&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;Do not confuse this with &lt;code&gt;dhcpv6-client&lt;/code&gt;, which is a separate service.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Configure AdGuard's DHCP Server
&lt;/h2&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://127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go to AdGuard's DHCP settings.&lt;/p&gt;

&lt;p&gt;Select the physical Wi-Fi interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wlo1 - 192.xxx.x.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not select one of Docker's bridge interfaces.&lt;/p&gt;

&lt;p&gt;Configure IPv4 DHCP as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gateway:       192.xxx.x.1
Range Start:   192.xxx.x.100
Range End:     192.xxx.x.200
Subnet Mask:   255.255.255.0
Lease:         86400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pool deliberately does not contain &lt;code&gt;192.xxx.x.13&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Save the configuration and enable AdGuard's DHCP server.&lt;/p&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;&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-lunp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;':67'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A successful configuration should show &lt;code&gt;AdGuardHome&lt;/code&gt; listening on UDP&lt;br&gt;
port 67 on &lt;code&gt;wlo1&lt;/code&gt;.&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;0.0.0.0%wlo1:67
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point AdGuard is ready to provide DHCP.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Disable DHCP on the Airtel Router
&lt;/h2&gt;

&lt;p&gt;Only do this after confirming that AdGuard is listening on port 67.&lt;/p&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://192.xxx.x.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Navigate to the Airtel router's LAN/DHCP settings.&lt;/p&gt;

&lt;p&gt;A typical Airtel interface may show something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IP Address:      192.xxx.x.1
Subnet Mask:     255.255.255.0
DHCP Server:     Enable
DHCP IP Pool:    192.168.18.2 - 192.168.18.253
DHCP Lease Time: 24 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DHCP Server: Enable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DHCP Server: Disable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the change.&lt;/p&gt;

&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; change the router's LAN address.&lt;/p&gt;

&lt;p&gt;It should remain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.xxx.x.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because it is still the network's gateway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 14: Reconnect a Client
&lt;/h2&gt;

&lt;p&gt;Take a phone or another computer and make sure its network configuration&lt;br&gt;
is set to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IP:  Automatic / DHCP
DNS: Automatic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Disconnect and reconnect it to the Airtel Wi-Fi.&lt;/p&gt;

&lt;p&gt;Then open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdGuard Home → DHCP → DHCP Leases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The device should appear as a dynamic lease.&lt;/p&gt;

&lt;p&gt;A client should receive something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IP:      192.xxx.x.105
Gateway: 192.xxx.x.1
DNS:     192.xxx.x.13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact IP can be anywhere inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;192.xxx.x.100 - 192.xxx.x.200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 15: Verify DNS Filtering
&lt;/h2&gt;

&lt;p&gt;From Fedora:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @192.xxx.x.13 google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open websites from another connected device and inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdGuard Home → Query Log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the client's DNS requests.&lt;/p&gt;

&lt;p&gt;If you have a domain intentionally blocked through a custom rule, you&lt;br&gt;
can test it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @192.xxx.x.13 blocked-example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on AdGuard's configured blocking mode, a blocked request may&lt;br&gt;
return &lt;code&gt;0.0.0.0&lt;/code&gt;, &lt;code&gt;NXDOMAIN&lt;/code&gt;, or another configured blocking response.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 16: Add Blocklists Carefully
&lt;/h2&gt;

&lt;p&gt;AdGuard Home can use DNS blocklists to block advertising, tracking,&lt;br&gt;
telemetry, malicious domains, and other unwanted endpoints.&lt;/p&gt;

&lt;p&gt;It is tempting to enable every available list.&lt;/p&gt;

&lt;p&gt;Avoid doing that.&lt;/p&gt;

&lt;p&gt;Several lists can contain substantial overlap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;List A
├── ads.example.com
├── tracker.example.com
└── telemetry.example.com

List B
├── ads.example.com
├── tracker.example.com
└── another-tracker.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More rules do not automatically mean better filtering.&lt;/p&gt;

&lt;p&gt;Large numbers of overlapping lists can increase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  memory usage&lt;/li&gt;
&lt;li&gt;  update time&lt;/li&gt;
&lt;li&gt;  filter reload time&lt;/li&gt;
&lt;li&gt;  false positives&lt;/li&gt;
&lt;li&gt;  application breakage&lt;/li&gt;
&lt;li&gt;  troubleshooting difficulty&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with a reputable general-purpose filter and add complementary&lt;br&gt;
lists only when you identify a specific gap.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 17: Configure Per-Device Policies
&lt;/h2&gt;

&lt;p&gt;Not every device on a home network needs identical filtering.&lt;/p&gt;

&lt;p&gt;AdGuard Home can maintain persistent clients and apply different&lt;br&gt;
policies.&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;Personal Laptop
└── Standard filtering

Parent Phone
└── Standard filtering

Child Phone
├── Filtering
├── Parental controls
├── Safe Search
└── Restricted services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can create static DHCP leases for devices that should always receive&lt;br&gt;
predictable addresses.&lt;/p&gt;

&lt;p&gt;This provides policy control at the DNS layer without applying parental&lt;br&gt;
restrictions to every person on the network.&lt;/p&gt;

&lt;p&gt;Remember that DNS filtering is not equivalent to full network isolation.&lt;br&gt;
Devices using encrypted DNS, hard-coded IP addresses, VPNs, or other&lt;br&gt;
bypass mechanisms may require controls at the router/firewall level.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 18: Understand the Query Log
&lt;/h2&gt;

&lt;p&gt;The Query Log is one of AdGuard Home's most useful diagnostic tools.&lt;/p&gt;

&lt;p&gt;It shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client
  ↓
Requested Domain
  ↓
Allowed / Blocked
  ↓
Matching Filter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, a DNS request does not prove that someone intentionally visited&lt;br&gt;
a website.&lt;/p&gt;

&lt;p&gt;Applications frequently contact domains in the background for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  advertising&lt;/li&gt;
&lt;li&gt;  analytics&lt;/li&gt;
&lt;li&gt;  telemetry&lt;/li&gt;
&lt;li&gt;  notifications&lt;/li&gt;
&lt;li&gt;  updates&lt;/li&gt;
&lt;li&gt;  content delivery&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For deeper investigation, combine AdGuard with packet-capture tools such&lt;br&gt;
as Wireshark or PCAPdroid.&lt;/p&gt;

&lt;p&gt;A useful mental model is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdGuard Query Log
        │
        └── What DNS name was requested,
            and was it blocked?

Wireshark / PCAPdroid
        │
        └── What network connection
            was actually attempted?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 19: Observe DHCP with Wireshark
&lt;/h2&gt;

&lt;p&gt;This setup also provides a useful networking exercise.&lt;/p&gt;

&lt;p&gt;Start Wireshark on Fedora and capture traffic on:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Use the display filter:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Disconnect and reconnect another device to Wi-Fi.&lt;/p&gt;

&lt;p&gt;You should be able to observe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DHCP Discover
DHCP Offer
DHCP Request
DHCP ACK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DHCP process that is normally hidden inside the router is now being&lt;br&gt;
performed by a service running on your own Fedora machine.&lt;/p&gt;

&lt;p&gt;For DNS traffic, use:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;as the display filter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 20: Remember That Fedora Is Now Infrastructure
&lt;/h2&gt;

&lt;p&gt;Once Airtel DHCP is disabled, the Fedora computer becomes an important&lt;br&gt;
part of the network.&lt;/p&gt;

&lt;p&gt;If Fedora:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  shuts down&lt;/li&gt;
&lt;li&gt;  sleeps&lt;/li&gt;
&lt;li&gt;  disconnects from Wi-Fi&lt;/li&gt;
&lt;li&gt;  leaves the house&lt;/li&gt;
&lt;li&gt;  stops Docker&lt;/li&gt;
&lt;li&gt;  stops the AdGuard container&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then new devices may fail to obtain DHCP leases and existing clients may&lt;br&gt;
lose DNS resolution.&lt;/p&gt;

&lt;p&gt;The Airtel router can still have a perfectly good internet connection&lt;br&gt;
while devices appear unable to browse.&lt;/p&gt;

&lt;p&gt;For experimentation, a laptop is fine.&lt;/p&gt;

&lt;p&gt;For a permanent deployment, move AdGuard to an always-on device such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Raspberry Pi&lt;/li&gt;
&lt;li&gt;  low-power mini PC&lt;/li&gt;
&lt;li&gt;  home server&lt;/li&gt;
&lt;li&gt;  dedicated networking appliance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once family members start asking why the internet stopped when you&lt;br&gt;
rebooted your laptop, the experiment has officially become&lt;br&gt;
infrastructure.&lt;/p&gt;


&lt;h2&gt;
  
  
  Emergency Recovery
&lt;/h2&gt;

&lt;p&gt;If something goes wrong during the DHCP handover, the fastest recovery&lt;br&gt;
is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AdGuard DHCP → OFF
Airtel DHCP  → ON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reconnect your devices afterward.&lt;/p&gt;

&lt;p&gt;That restores the Airtel router as the DHCP server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full Rollback
&lt;/h2&gt;

&lt;p&gt;If you want to return Fedora to normal router-managed networking, first&lt;br&gt;
disable AdGuard DHCP and re-enable DHCP on the Airtel router.&lt;/p&gt;

&lt;p&gt;Then restore NetworkManager:&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 &lt;/span&gt;nmcli connection modify &amp;lt;CONNECTION-UUID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.method auto &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.addresses &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.gateway &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.dns &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.ignore-auto-dns no &lt;span class="se"&gt;\&lt;/span&gt;
  ipv6.dns &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ipv6.ignore-auto-dns no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli connection down &amp;lt;CONNECTION-UUID&amp;gt;
nmcli connection up &amp;lt;CONNECTION-UUID&amp;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;ip addr show wlo1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'inet '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see an address marked:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip route | &lt;span class="nb"&gt;grep &lt;/span&gt;default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should resemble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default via 192.xxx.x.1 dev wlo1 proto dhcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl status wlo1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router should once again appear as the DNS server, 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;Current DNS Server: 192.xxx.x.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the firewall rules added for AdGuard:&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 &lt;/span&gt;firewall-cmd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remove-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;53/tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permanent&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remove-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;53/udp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permanent&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remove-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dhcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permanent&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you created a MAC-IP binding for Fedora in the Airtel router, remove&lt;br&gt;
it as well.&lt;/p&gt;

&lt;p&gt;You do not have to delete AdGuard.&lt;/p&gt;

&lt;p&gt;You can keep the container and its configuration for future use. With&lt;br&gt;
AdGuard DHCP disabled and Fedora returned to router-provided DNS, the&lt;br&gt;
normal network no longer depends on it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Final Architecture
&lt;/h2&gt;

&lt;p&gt;When everything is enabled, the network is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         Internet
                            ▲
                            │
                      Airtel Router
                      192.xxx.x.1
                       Gateway + NAT
                            ▲
                            │
          ┌─────────────────┼─────────────────┐
          │                 │                 │
        Phone              TV              Laptop
          │                 │                 │
          └──────── DNS + DHCP ──────────────┘
                            │
                            ▼
                       Fedora Host
                      192.xxx.x.13
                       AdGuard Home
                        DNS + DHCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important idea is not simply that AdGuard blocks advertisements.&lt;/p&gt;

&lt;p&gt;The setup separates services that consumer routers normally hide inside&lt;br&gt;
one device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Routing ≠ DHCP ≠ DNS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Airtel router can remain the internet gateway while another machine&lt;br&gt;
provides DHCP and DNS to the LAN.&lt;/p&gt;

&lt;p&gt;That makes AdGuard Home a useful filtering tool, but it also turns a&lt;br&gt;
normal home network into a practical environment for learning how DNS,&lt;br&gt;
DHCP, Linux networking, Docker, firewalls, and client configuration work&lt;br&gt;
together.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Tracing a Complete HTTP Request with Wireshark: From DNS to TCP to HTTP</title>
      <dc:creator>Caleb Ajibade</dc:creator>
      <pubDate>Mon, 07 Sep 2026 17:46:42 +0000</pubDate>
      <link>https://dev.to/caleb_ajibade/tracing-a-complete-http-request-with-wireshark-from-dns-to-tcp-to-http-109l</link>
      <guid>https://dev.to/caleb_ajibade/tracing-a-complete-http-request-with-wireshark-from-dns-to-tcp-to-http-109l</guid>
      <description>&lt;p&gt;When you type a URL into your browser, it feels almost instantaneous.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;http://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a webpage appears.&lt;/p&gt;

&lt;p&gt;But underneath that simple action, your computer performs several separate networking operations. It may need to discover the server's IP address, establish a connection, send an HTTP request, receive a response, and eventually close or reuse the connection.&lt;/p&gt;

&lt;p&gt;Wireshark lets us watch these operations happen packet by packet.&lt;/p&gt;

&lt;p&gt;In this tutorial, we're going to generate a simple HTTP request and trace its journey through:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS → TCP → HTTP → TCP termination&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We'll also use Wireshark's &lt;strong&gt;Flow Graph&lt;/strong&gt; to turn the packets into a visual conversation between our computer and the servers involved.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Wireshark?
&lt;/h2&gt;

&lt;p&gt;Wireshark is a network protocol analyzer.&lt;/p&gt;

&lt;p&gt;It captures network traffic passing through a network interface and allows us to inspect the individual packets.&lt;/p&gt;

&lt;p&gt;Instead of seeing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser → Website
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wireshark lets us see what is actually happening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    ↓
HTTP
    ↓
TCP
    ↓
IP
    ↓
Ethernet / Wi-Fi
    ↓
Network
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes Wireshark particularly useful for learning networking because concepts such as TCP handshakes, ports, DNS queries, HTTP headers, and IP addresses stop being purely theoretical.&lt;/p&gt;

&lt;p&gt;We can actually see them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happens When We Make an HTTP Request?
&lt;/h2&gt;

&lt;p&gt;Before opening Wireshark, let's understand what we're looking for.&lt;/p&gt;

&lt;p&gt;Suppose we request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a simplified level, the process looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Computer
   │
   │  "What is the IP address of example.com?"
   ▼
DNS Resolver
   │
   │  "example.com is x.x.x.x"
   ▼
Computer
   │
   │  Establish TCP connection
   ▼
Web Server
   │
   │  Send HTTP GET request
   ▼
Web Server
   │
   │  Return HTTP response
   ▼
Computer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are therefore several different protocols involved in what appears to us as one operation.&lt;/p&gt;

&lt;p&gt;We'll trace them individually.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Start Wireshark
&lt;/h2&gt;

&lt;p&gt;Open Wireshark.&lt;/p&gt;

&lt;p&gt;You'll see a list of network interfaces available on your computer.&lt;/p&gt;

&lt;p&gt;Examples might include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eth0
eno1
wlan0
wlo1
lo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my Fedora machine, the Wi-Fi interface is &lt;code&gt;wlo1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you're unsure which interface you're using, Linux users can run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr
&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;ip &lt;span class="nb"&gt;link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for the interface that is currently active.&lt;/p&gt;

&lt;p&gt;Select that interface in Wireshark and start capturing.&lt;/p&gt;

&lt;p&gt;You'll immediately see packets appearing.&lt;/p&gt;

&lt;p&gt;Don't worry if there are hundreds of them.&lt;/p&gt;

&lt;p&gt;Your computer is constantly communicating over the network even when you aren't actively doing anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Generate an HTTP Request
&lt;/h2&gt;

&lt;p&gt;We're deliberately going to use &lt;strong&gt;HTTP instead of HTTPS&lt;/strong&gt;.&lt;/p&gt;

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

&lt;p&gt;HTTPS encrypts HTTP traffic using TLS. Wireshark would still be able to observe the connection, but we wouldn't normally be able to simply read the HTTP request and response.&lt;/p&gt;

&lt;p&gt;Plain HTTP makes the experiment much easier to understand.&lt;/p&gt;

&lt;p&gt;Open another terminal and run:&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;-v&lt;/span&gt; http://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Another useful test site is:&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;-v&lt;/span&gt; http://httpforever.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-v&lt;/code&gt; option tells curl to display additional information about the request.&lt;/p&gt;

&lt;p&gt;Once the request finishes, stop the Wireshark capture.&lt;/p&gt;

&lt;p&gt;Now we can begin investigating.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Find the HTTP Request
&lt;/h2&gt;

&lt;p&gt;Wireshark probably captured a lot more than our curl command.&lt;/p&gt;

&lt;p&gt;Let's filter the traffic.&lt;/p&gt;

&lt;p&gt;Enter this into the display filter:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;You should see packets containing something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET / HTTP/1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a response 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;HTTP/1.1 200 OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Click the GET packet.&lt;/p&gt;

&lt;p&gt;In the packet details panel, expand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hypertext Transfer Protocol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see information similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET / HTTP/1.1
Host: example.com
User-Agent: curl/...
Accept: */*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the actual HTTP request sent by our computer.&lt;/p&gt;

&lt;p&gt;It's no longer an abstract example from a networking textbook.&lt;/p&gt;

&lt;p&gt;That's the request that just left our machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Find the TCP Connection
&lt;/h2&gt;

&lt;p&gt;HTTP doesn't independently deliver the request across the Internet.&lt;/p&gt;

&lt;p&gt;With HTTP/1.1 in this example, the HTTP messages are transported using TCP.&lt;/p&gt;

&lt;p&gt;Right-click the HTTP GET packet and select:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Follow → TCP Stream&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wireshark will reconstruct the conversation belonging to that TCP connection.&lt;/p&gt;

&lt;p&gt;This is also extremely useful when investigating larger packet captures because Wireshark identifies the TCP stream for us.&lt;/p&gt;

&lt;p&gt;After identifying the stream number, close the TCP Stream window.&lt;/p&gt;

&lt;p&gt;Suppose Wireshark identifies it as stream &lt;code&gt;4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We can filter for the entire connection using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tcp.stream == 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now instead of seeing every TCP packet captured by our computer, we see only the TCP conversation containing our HTTP request.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Find the TCP Three-Way Handshake
&lt;/h2&gt;

&lt;p&gt;Before our computer could send the HTTP request, it first needed to establish a TCP connection with the web server.&lt;/p&gt;

&lt;p&gt;TCP does this using the famous &lt;strong&gt;three-way handshake&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;Client                         Server
   │                              │
   │──────── SYN ────────────────&amp;gt;│
   │                              │
   │&amp;lt;────── SYN + ACK ────────────│
   │                              │
   │──────── ACK ────────────────&amp;gt;│
   │                              │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first packet is the client saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I want to establish a TCP connection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The server responds:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I received your request, and I'm ready.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The client then acknowledges the server's response.&lt;/p&gt;

&lt;p&gt;The connection is established.&lt;/p&gt;

&lt;p&gt;In Wireshark, click the first TCP packet and expand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Transmission Control Protocol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then inspect:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The first packet should have the SYN flag set.&lt;/p&gt;

&lt;p&gt;The response should have both SYN and ACK set.&lt;/p&gt;

&lt;p&gt;The third packet should have ACK set.&lt;/p&gt;

&lt;p&gt;We have now observed the TCP three-way handshake directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Look at the Ports
&lt;/h2&gt;

&lt;p&gt;While inspecting the TCP information, look at the source and destination ports.&lt;/p&gt;

&lt;p&gt;You might see something resembling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source Port: 48732
Destination Port: 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port &lt;code&gt;80&lt;/code&gt; is the conventional port for HTTP.&lt;/p&gt;

&lt;p&gt;But why is our source port something random like &lt;code&gt;48732&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Our operating system chooses a temporary &lt;strong&gt;ephemeral port&lt;/strong&gt; for the client side of the connection.&lt;/p&gt;

&lt;p&gt;So our connection might effectively 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;192.168.1.20:48732 → 93.x.x.x:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The combination of IP addresses and ports allows the operating systems to identify the connection.&lt;/p&gt;

&lt;p&gt;This becomes especially important because your computer may have many TCP connections open simultaneously.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Inspect the HTTP GET Request
&lt;/h2&gt;

&lt;p&gt;After the TCP connection has been established, we should eventually see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET / HTTP/1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Select that packet.&lt;/p&gt;

&lt;p&gt;Wireshark will show several layers.&lt;/p&gt;

&lt;p&gt;Something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Frame
└── Ethernet II
    └── Internet Protocol Version 4
        └── Transmission Control Protocol
            └── Hypertext Transfer Protocol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of my favorite parts of the experiment because it demonstrates &lt;strong&gt;encapsulation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The HTTP message isn't simply thrown onto the network.&lt;/p&gt;

&lt;p&gt;Conceptually, we have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Request
     ↓
TCP Segment
     ↓
IP Packet
     ↓
Ethernet/Wi-Fi Frame
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer adds information required for its particular job.&lt;/p&gt;

&lt;p&gt;HTTP understands things 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;GET
Host
User-Agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP understands things 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;Ports
Sequence numbers
Acknowledgements
Flags
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IP understands things 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;Source IP
Destination IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lower network layer handles delivery over the local network.&lt;/p&gt;

&lt;p&gt;Wireshark lets us expand these layers individually.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Inspect the HTTP Response
&lt;/h2&gt;

&lt;p&gt;The server now needs to answer our request.&lt;/p&gt;

&lt;p&gt;Look for something similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 200 OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on the website, the exact status code may be different.&lt;/p&gt;

&lt;p&gt;Expand the HTTP section.&lt;/p&gt;

&lt;p&gt;You might see headers 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;HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server may then send the HTML document requested by the client.&lt;/p&gt;

&lt;p&gt;If the response is sufficiently large, it won't necessarily fit inside a single TCP segment.&lt;/p&gt;

&lt;p&gt;You may therefore see multiple packets transporting parts of the response.&lt;/p&gt;

&lt;p&gt;TCP is responsible for making sure that this data can be correctly reconstructed by the receiving application.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: What Happened Before TCP? DNS
&lt;/h2&gt;

&lt;p&gt;There's still something missing.&lt;/p&gt;

&lt;p&gt;How did our computer know the server's IP address?&lt;/p&gt;

&lt;p&gt;We gave curl a domain name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP ultimately needs to connect to an IP address.&lt;/p&gt;

&lt;p&gt;This is where DNS comes in.&lt;/p&gt;

&lt;p&gt;Remove the TCP stream filter and enter:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Look for a DNS query involving the domain you requested.&lt;/p&gt;

&lt;p&gt;You may see something resembling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Standard query
A example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;A&lt;/code&gt; query asks for an IPv4 address.&lt;/p&gt;

&lt;p&gt;The DNS resolver may then return something resembling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Standard query response
A example.com
A x.x.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our computer can now use that IP address to establish the TCP connection.&lt;/p&gt;

&lt;p&gt;One important caveat is caching.&lt;/p&gt;

&lt;p&gt;If your operating system or local DNS resolver already knows the address, you might not see the DNS request you expect on your network interface.&lt;/p&gt;

&lt;p&gt;Using a domain that hasn't recently been requested can make the experiment easier.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Visualize Everything with Wireshark's Flow Graph
&lt;/h2&gt;

&lt;p&gt;Reading individual packets is useful, but Wireshark has another feature that makes this experiment much easier to understand.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Statistics → Flow Graph&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wireshark can represent network communication using arrows between endpoints.&lt;/p&gt;

&lt;p&gt;Instead of interpreting dozens of rows, we can get something conceptually similar to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client                         Server

  │                              │
  │──────── SYN ────────────────&amp;gt;│
  │                              │
  │&amp;lt;────── SYN, ACK ─────────────│
  │                              │
  │──────── ACK ────────────────&amp;gt;│
  │                              │
  │────── GET / HTTP/1.1 ───────&amp;gt;│
  │                              │
  │&amp;lt;──── HTTP/1.1 200 OK ────────│
  │                              │
  │&amp;lt;──── Response Data ──────────│
  │                              │
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is especially useful when learning TCP because the request becomes a &lt;strong&gt;conversation&lt;/strong&gt; instead of a table of packets.&lt;/p&gt;

&lt;p&gt;The Flow Graph can also be useful as a screenshot when documenting the experiment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building a Clean Flow Graph
&lt;/h2&gt;

&lt;p&gt;There's one trap here.&lt;/p&gt;

&lt;p&gt;If we filter Wireshark using only:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;and then create our graph from only displayed packets, we may hide the TCP packets.&lt;/p&gt;

&lt;p&gt;Our graph would show the HTTP request and response but not the TCP handshake that made them possible.&lt;/p&gt;

&lt;p&gt;Instead, identify the TCP stream associated with the HTTP request and 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;tcp.stream == 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;4&lt;/code&gt; with your actual stream number.&lt;/p&gt;

&lt;p&gt;Now the Flow Graph can show the entire TCP conversation rather than just HTTP packets.&lt;/p&gt;

&lt;p&gt;For a more complete investigation, DNS can be examined separately because DNS resolution is a separate conversation from the TCP connection.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Journey
&lt;/h2&gt;

&lt;p&gt;After examining the capture, we can finally reconstruct what happened when we executed one simple command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The high-level process was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Application requests example.com
             ↓
2. DNS resolves the domain
             ↓
3. Client obtains server IP
             ↓
4. TCP SYN
             ↓
5. TCP SYN-ACK
             ↓
6. TCP ACK
             ↓
7. TCP connection established
             ↓
8. HTTP GET request
             ↓
9. Server processes request
             ↓
10. HTTP response
             ↓
11. Response data transferred
             ↓
12. TCP connection eventually closed or reused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What appeared to be one operation was actually cooperation between several protocols.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;Before doing this experiment, it's easy to think about HTTP as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Computer → Website → Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wireshark exposes how much is hidden behind that abstraction.&lt;/p&gt;

&lt;p&gt;DNS answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where is the server?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;IP answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where should this packet go?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;TCP handles:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do we reliably exchange this stream of data?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;HTTP handles:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What resource does the client want, and what should the server return?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each protocol solves a different problem.&lt;/p&gt;

&lt;p&gt;And together they make something as simple as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;possible.&lt;/p&gt;

&lt;p&gt;That's what makes Wireshark such a useful tool for learning networking.&lt;/p&gt;

&lt;p&gt;Instead of just reading about SYN packets, ACKs, ports, DNS queries, HTTP headers, and encapsulation, you can generate a request yourself and watch every layer do its job.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>dns</category>
    </item>
    <item>
      <title>Setting Up a Local DNS Resolver with Custom Domains on Fedora Using Unbound</title>
      <dc:creator>Caleb Ajibade</dc:creator>
      <pubDate>Mon, 07 Sep 2026 15:58:59 +0000</pubDate>
      <link>https://dev.to/caleb_ajibade/setting-up-a-local-dns-resolver-with-custom-domains-on-fedora-using-unbound-5fjg</link>
      <guid>https://dev.to/caleb_ajibade/setting-up-a-local-dns-resolver-with-custom-domains-on-fedora-using-unbound-5fjg</guid>
      <description>&lt;p&gt;I recently gave myself a simple networking task:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Set up a local DNS resolver and add custom domains.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before jumping into Unbound and configuration files, it's worth understanding what we're actually building.&lt;/p&gt;

&lt;p&gt;If you've used the internet, you've used DNS constantly — usually without realizing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is DNS?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DNS (Domain Name System)&lt;/strong&gt; is the system that translates human-readable domain names into IP addresses.&lt;/p&gt;

&lt;p&gt;Computers communicate over networks using IP addresses.&lt;/p&gt;

&lt;p&gt;For example, a server might have an address like:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Humans, however, don't want to remember IP addresses for every website they visit.&lt;/p&gt;

&lt;p&gt;We'd much rather type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DNS provides the mapping between the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google.com
      │
      │ DNS
      ▼
142.250.151.100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can think of DNS somewhat like the contacts app on your phone.&lt;/p&gt;

&lt;p&gt;You don't memorize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+234 801 234 5678
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;and your phone knows which number belongs to John.&lt;/p&gt;

&lt;p&gt;DNS does something similar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google.com → 142.250.x.x
github.com → 140.82.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means when you enter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;your computer first needs to answer an important question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What IP address should I connect to for &lt;code&gt;google.com&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's where a &lt;strong&gt;DNS resolver&lt;/strong&gt; comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is a DNS Resolver?
&lt;/h2&gt;

&lt;p&gt;A DNS resolver is the component responsible for taking your DNS question and finding an answer.&lt;/p&gt;

&lt;p&gt;For example, your browser wants to visit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs an IP address, so a simplified version of what happens looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
   │
   │ "What is the IP for google.com?"
   ▼
DNS Resolver
   │
   │ finds the answer
   ▼
142.250.151.100
   │
   ▼
Browser connects to server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usually, you don't manually choose the resolver every time you browse the internet.&lt;/p&gt;

&lt;p&gt;Your computer receives DNS configuration from your network, often through your router.&lt;/p&gt;

&lt;p&gt;Your ISP may provide the DNS server, or you might use a public resolver such as Google's or Cloudflare's.&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;8.8.8.8 → Google Public DNS
1.1.1.1 → Cloudflare DNS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So DNS is the overall naming system, while a &lt;strong&gt;DNS resolver is one of the components that helps you find answers within that system&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's an important distinction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DNS
│
└── The overall system for translating names into network information

DNS Resolver
│
└── A service that receives DNS questions and finds/returns answers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Run My Own DNS Resolver?
&lt;/h2&gt;

&lt;p&gt;This was the part that made the project interesting.&lt;/p&gt;

&lt;p&gt;Instead of relying entirely on whatever DNS resolver my network gives me, I can run one &lt;strong&gt;on my own computer&lt;/strong&gt;.&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;My Computer
     │
     │ google.com?
     ▼
My DNS Resolver
     │
     ▼
142.250.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there's another benefit.&lt;/p&gt;

&lt;p&gt;Because I control the resolver, I can teach it names that &lt;strong&gt;don't exist on the public internet&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example, I 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;myserver.home.arpa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;myserver.home.arpa → 127.0.0.1
&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 plaintext"&gt;&lt;code&gt;website.home.arpa → 127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody needs to register these domains with a public domain registrar.&lt;/p&gt;

&lt;p&gt;My resolver knows about them because &lt;strong&gt;I told it about them&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So our resolver will eventually be able to handle two types of questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    My DNS Resolver
                         │
             ┌───────────┴───────────┐
             │                       │
             ▼                       ▼
       Public domain             My domain
        google.com         website.home.arpa
             │                       │
             ▼                       ▼
       Ask upstream DNS         Local record
             │                       │
             ▼                       ▼
       142.250.x.x              127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what we're going to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;We'll use &lt;strong&gt;Unbound&lt;/strong&gt;, a DNS resolver, and run it locally on Fedora.&lt;/p&gt;

&lt;p&gt;By the end, we'll have this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌─────────────────────┐
                    │       Unbound       │
                    │     127.0.0.1:53    │
                    └──────────┬──────────┘
                               │
              ┌────────────────┼────────────────┐
              │                │                │
              ▼                ▼                ▼
       google.com       myserver.home.arpa   website.home.arpa
              │                │                │
              ▼                └───────┬────────┘
        Upstream DNS                   ▼
              │                    127.0.0.1
              ▼
       Google's IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's build it incrementally.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What DNS Am I Already Using?
&lt;/h2&gt;

&lt;p&gt;Before installing anything, I wanted to see how DNS currently worked on my machine.&lt;/p&gt;

&lt;p&gt;On Fedora, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My Wi-Fi interface showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current DNS Server: 192.168.18.1
DNS Servers: 192.168.18.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;192.168.18.1&lt;/code&gt; is my router.&lt;/p&gt;

&lt;p&gt;I also checked:&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; /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



&lt;p&gt;At first this might seem contradictory.&lt;/p&gt;

&lt;p&gt;Why does one command say &lt;code&gt;192.168.18.1&lt;/code&gt; while another says &lt;code&gt;127.0.0.53&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Because Fedora is using &lt;code&gt;systemd-resolved&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The path is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    │
    ▼
127.0.0.53
systemd-resolved
    │
    ▼
192.168.18.1
Router
    │
    ▼
Internet DNS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;127.0.0.53&lt;/code&gt; is a local DNS stub provided by &lt;code&gt;systemd-resolved&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actual upstream DNS server in my case was being provided by my network.&lt;/p&gt;

&lt;p&gt;We can see this when querying a domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Part of my output looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;;; ANSWER SECTION:
google.com.    214    IN    A    142.251.209.110

;; SERVER: 127.0.0.53#53
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So normal DNS was already working.&lt;/p&gt;

&lt;p&gt;Now I wanted to create &lt;strong&gt;another DNS resolver that I controlled myself&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Install Unbound
&lt;/h2&gt;

&lt;p&gt;I chose Unbound as the resolver.&lt;/p&gt;

&lt;p&gt;Install it on Fedora with:&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 &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify the installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unbound &lt;span class="nt"&gt;-V&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was running:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Start the service:&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 &lt;/span&gt;systemctl start unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check its status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should show 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;Active: active (running)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Is Unbound Actually Listening for DNS Requests?
&lt;/h2&gt;

&lt;p&gt;DNS normally uses port &lt;strong&gt;53&lt;/strong&gt;, so we can inspect what's listening on that port:&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 &lt;/span&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;':53'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Among the output, I found:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;being used by Unbound.&lt;/p&gt;

&lt;p&gt;This means I now effectively had two local DNS-related services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.53 → systemd-resolved
127.0.0.1  → Unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's an important distinction for the rest of this tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Query Unbound Directly
&lt;/h2&gt;

&lt;p&gt;Normally I can run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But that uses my system-configured DNS.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dig&lt;/code&gt; also lets us explicitly choose which DNS server to query.&lt;/p&gt;

&lt;p&gt;The syntax is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dig @DNS_SERVER DOMAIN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Therefore, to query Unbound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initially, mine returned:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Unbound was running and receiving my request, but wasn't successfully resolving the domain.&lt;/p&gt;

&lt;p&gt;So I configured upstream DNS servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Configure Unbound
&lt;/h2&gt;

&lt;p&gt;Create a custom configuration file:&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 &lt;/span&gt;nano /etc/unbound/conf.d/local.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server:
    interface: 127.0.0.1
    access-control: 127.0.0.0/8 allow

forward-zone:
    name: "."
    forward-addr: 1.1.1.1
    forward-addr: 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;forward-zone:
    name: "."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;"."&lt;/code&gt; represents the DNS root, so this forwarding rule catches queries that aren't answered locally.&lt;/p&gt;

&lt;p&gt;The two &lt;code&gt;forward-addr&lt;/code&gt; entries specify upstream DNS resolvers.&lt;/p&gt;

&lt;p&gt;Our setup is now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dig
 │
 │ google.com?
 ▼
Unbound
127.0.0.1
 │
 │ I don't have a local record
 ▼
Upstream DNS
 │
 ▼
google.com IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Validate Before Restarting
&lt;/h2&gt;

&lt;p&gt;Instead of blindly restarting Unbound and hoping the configuration works, we can validate it first:&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 &lt;/span&gt;unbound-checkconf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A valid configuration should give:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unbound-checkconf: no errors in /etc/unbound/unbound.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart:&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 &lt;/span&gt;systemctl restart unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now try again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 google.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time I got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status: NOERROR
&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;;; ANSWER SECTION:
google.com.    150    IN    A    142.250.151.138
google.com.    150    IN    A    142.250.151.100
google.com.    150    IN    A    142.250.151.101
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More importantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;;; SERVER: 127.0.0.1#53
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We now have a working local DNS resolver.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Creating Our Own Domains
&lt;/h2&gt;

&lt;p&gt;Now for the fun part.&lt;/p&gt;

&lt;p&gt;I wanted domains that don't exist on the public internet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;myserver.home.arpa
website.home.arpa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used &lt;code&gt;home.arpa&lt;/code&gt; rather than something like &lt;code&gt;.local&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;home.arpa&lt;/code&gt; is reserved for naming resources within home networks, while &lt;code&gt;.local&lt;/code&gt; has special meaning for multicast DNS (mDNS).&lt;/p&gt;

&lt;p&gt;Edit the configuration again:&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 &lt;/span&gt;nano /etc/unbound/conf.d/local.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My complete configuration became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server:
    interface: 127.0.0.1
    access-control: 127.0.0.0/8 allow

    local-zone: "home.arpa." static

    local-data: "myserver.home.arpa. IN A 127.0.0.1"
    local-data: "website.home.arpa. IN A 127.0.0.1"

forward-zone:
    name: "."
    forward-addr: 1.1.1.1
    forward-addr: 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it again:&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 &lt;/span&gt;unbound-checkconf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&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 &lt;/span&gt;systemctl restart unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Test the Custom Domains
&lt;/h2&gt;

&lt;p&gt;Now let's ask our resolver:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 myserver.home.arpa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;;; ANSWER SECTION:
myserver.home.arpa.    3600    IN    A    127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try the second domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 website.home.arpa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;;; ANSWER SECTION:
website.home.arpa.    3600    IN    A    127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both queries took essentially no time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query time: 0 msec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's because Unbound already knows the answers. They're defined directly in its configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What Does the &lt;code&gt;A&lt;/code&gt; Mean?
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;website.home.arpa. IN A 127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a DNS &lt;strong&gt;A record&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An A record maps a hostname to an IPv4 address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;website.home.arpa
        │
        │ A record
        ▼
    127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We could point another name at another machine on our network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local-data: "server.home.arpa. IN A 192.168.18.50"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now our resolver would answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server.home.arpa → 192.168.18.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is essentially the same fundamental concept used by public DNS.&lt;/p&gt;

&lt;p&gt;The difference is that these particular records only exist inside &lt;strong&gt;our resolver&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Local Domains vs Internet Domains
&lt;/h2&gt;

&lt;p&gt;We can now ask the same DNS server three questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 google.com

dig @127.0.0.1 myserver.home.arpa

dig @127.0.0.1 website.home.arpa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;                        Unbound
                      127.0.0.1
                          │
           ┌──────────────┼──────────────┐
           │              │              │
           ▼              ▼              ▼

      google.com     myserver...    website...
           │              │              │
           ▼              ▼              ▼
      Not local        LOCAL          LOCAL
           │              │              │
           ▼              └──────┬───────┘
     Upstream DNS                ▼
           │                 127.0.0.1
           ▼
     Google IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the part that made DNS click for me.&lt;/p&gt;

&lt;p&gt;A DNS resolver isn't necessarily some mysterious server sitting somewhere on the internet.&lt;/p&gt;

&lt;p&gt;I can run one on my own laptop.&lt;/p&gt;

&lt;p&gt;And I can tell it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If someone asks for this name, give them this IP address.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  11. Why Didn't I Change Fedora's DNS?
&lt;/h2&gt;

&lt;p&gt;There was one more decision to make.&lt;/p&gt;

&lt;p&gt;I could configure Fedora to use:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;as its normal DNS server.&lt;/p&gt;

&lt;p&gt;Then applications would automatically use Unbound.&lt;/p&gt;

&lt;p&gt;For this experiment, however, I decided &lt;strong&gt;not to change my system DNS&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My normal Fedora DNS remains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Applications
     │
     ▼
systemd-resolved
127.0.0.53
     │
     ▼
Router
     │
     ▼
Internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And my experimental resolver remains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dig @127.0.0.1
       │
       ▼
    Unbound
       │
       ├── custom domains
       │
       └── upstream DNS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means I can experiment with Unbound without accidentally breaking DNS for the rest of my system.&lt;/p&gt;

&lt;p&gt;When I want to test it, I explicitly use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig @127.0.0.1 example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  12. Starting and Stopping the Resolver
&lt;/h2&gt;

&lt;p&gt;Because I'm using this as a local experiment, I don't necessarily need Unbound running every time my computer starts.&lt;/p&gt;

&lt;p&gt;Start it with:&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 &lt;/span&gt;systemctl start unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And stop 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;sudo &lt;/span&gt;systemctl stop unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I eventually decide I want it permanently available, I can enable 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;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;This was a small project, but it cleared up several concepts I'd previously treated as separate things.&lt;/p&gt;

&lt;p&gt;I now have a much better mental model of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What DNS actually does&lt;/li&gt;
&lt;li&gt;What a DNS resolver is&lt;/li&gt;
&lt;li&gt;The difference between a DNS resolver and the overall DNS system&lt;/li&gt;
&lt;li&gt;The difference between my system's DNS stub and its upstream DNS server&lt;/li&gt;
&lt;li&gt;Why &lt;code&gt;127.0.0.53&lt;/code&gt; appears in &lt;code&gt;/etc/resolv.conf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;How DNS uses port 53&lt;/li&gt;
&lt;li&gt;How to query a specific DNS server with &lt;code&gt;dig&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;How DNS forwarding works&lt;/li&gt;
&lt;li&gt;What an A record does&lt;/li&gt;
&lt;li&gt;How custom/private DNS names can exist without being registered publicly&lt;/li&gt;
&lt;li&gt;How a local resolver can answer some queries itself and forward others upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final setup is surprisingly small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    My Laptop
                       │
              ┌────────┴─────────┐
              │                  │
        Normal Fedora       My DNS Lab
              │                  │
       127.0.0.53           127.0.0.1
              │                  │
    systemd-resolved           Unbound
              │                  │
           Router         ┌──────┴──────┐
              │           │             │
         Internet     Local DNS      Internet
                      records        forwarding
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And all it took to create a domain of my own was essentially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local-data: "website.home.arpa. IN A 127.0.0.1"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obviously, this is only the beginning of DNS.&lt;/p&gt;

&lt;p&gt;But building a tiny working version locally made the bigger system much easier to understand.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Environment used:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fedora Linux&lt;/li&gt;
&lt;li&gt;Unbound 1.26&lt;/li&gt;
&lt;li&gt;&lt;code&gt;systemd-resolved&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dig&lt;/code&gt; / BIND utilities&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>dns</category>
      <category>networking</category>
    </item>
    <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>
